How to build a Trello alternative

How to build a Trello alternative with lists, cards you drag between columns, and a membership check. Follow the prompts, check each step, skip live sync.

  • guide
  • vibe-coding
  • trello

You can learn how to build a Trello alternative as lists, cards, and a drag that writes a new position. Trello is that product. The catalog line is “Cards move left. Cards move right. Work happens.”

You pay $10–$17.5 a person a month. That is $120–$210 a year, and the card says the price scales with the team. The verdict is “Classic weekend win”. The first estimate is 3 hours: columns, drag-and-drop cards.

There is no Trello teardown on this site yet. The scope post already uses Trello as the 3-hour example. The stack post names Astro, Alpine, and D1. The prompt post uses Trello’s skip list. This guide is the walk. Prompts in order, then what to look at.

The audience is a small team managing one shared project board. If twenty people need live cursors, or you live in Power-Ups, keep paying. The same v1 also replaces Asana.

How to build a Trello alternative without live sync

The Trello page splits the work into three rows.

The first row is the one you want. One board. Three ordered columns. Cards you create and drag. Integer positions that survive a refresh. Membership checked on the server. That is the 3-hour v1.

The second row is 10 hours. Checklists, labels, filters, an activity timeline. Do that after a teammate has moved a real card.

The third row is 1 month and up. Real-time sync, Power-Ups, automation, mobile apps. Skip it.

If you tell an agent to “clone Trello”, it aims at the third row. Pin every prompt to the first one.

Here is the shape of the data. Nothing else has to exist at 3 hours.

const board = {
  slug: 'launch-week',
  title: 'Launch week',
}

const columns = [
  { id: 'todo', title: 'To do', position: 1 },
  { id: 'doing', title: 'In progress', position: 2 },
  { id: 'done', title: 'Done', position: 3 },
]

const cards = [
  { id: 'card-14', columnId: 'todo', title: 'Write the landing copy', position: 1 },
  { id: 'card-15', columnId: 'todo', title: 'Record the demo', position: 2 },
  { id: 'card-16', columnId: 'doing', title: 'Ship the blog post', position: 1 },
]

A board, three columns, cards with an integer position inside a column. Refreshing keeps the order. The card also lists ChecklistItem and Comment. Those wait for the 10-hour row.

The official journeys mention invites, labels, checklists, comments, and archive. The first estimate does not. Seed two members so the membership check has someone to refuse. Leave the invite mailer alone.

NAME THE BOARD Launch week, slug launch-week Owner Flavio, member Jordan SEED THREE COLUMNS To do, In progress, Done Positions 1, 2, 3. No fractions DRAG ONE CARD Ship the blog post to Done Alpine moves it. D1 stores it WRITE POSITIONS One transaction, gapless integers
One board, three columns, a drag that writes integers. Refresh keeps the order.

Pick the stack you already write

Every product prompt ships in four flavors: AHA (Astro, HTMX, Alpine.js), Next.js, Laravel, and Rails. The capability line for Trello is Astro, Alpine, and D1.

This site is an Astro app on Cloudflare, so that is the path I default to. Use Next.js if that is your weekday language. Alpine is on the card because cards move in the browser. You can still ship that with Livewire or a Client Component. Do not invent a second product to get drag-and-drop.

Open Build your own Trello. The guardrail is the useful sentence: enforce board membership on every mutation, not only in the interface. The non-goal sits next to it. No workspace billing, Power-Ups, complex automation, or portfolio views.

Pick one stack and stay on it.

Prompt 1. Scaffold Deck and stop

Copy the AHA prompt from the product page. Paste it into your coding tool. Put this block above it.

Ship the 3-hour row only. Columns, drag-and-drop cards.

Project name: Deck.
Owner is Flavio. Member is Jordan Lee.
One shared board. No guests.

Do not add checklists, labels, filters, or an
activity timeline yet.
Do not add workspace billing, Power-Ups,
automation, or portfolio views.
Do not add WebSockets, Durable Objects, or
a realtime engine.

When the plan is done, stop and wait. Do not write
implementation code until I say go.

The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.

You want pages like these:

  • /boards for the owner list
  • /boards/launch-week for the board
  • /boards/launch-week/cards/card-14 for a title and a column

You want tables like boards, columns, cards, memberships. checklist_items should not appear. powerups should not appear. A comments table can wait.

If the plan includes Stripe, a marketplace, or a Durable Object “so two people see the drag live”, send it back. Paste the non-goal again.

When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.

What to check after the scaffold

Start the app. Sign in as Flavio. You should see an empty boards list and a create action.

Open the migrations. cards has column_id and an integer position. There is a uniqueness story on (column_id, position) or a plan to renumber in a transaction. If the README is a placeholder, stop and make the agent write a real one before you add logic.

Prompt 2. Name the board and put cards on it

Now fill the board. Keep drag off until the rows exist.

Implement the board editor for Deck.

The owner can save slug and title. Slug is
launch-week in v1. The board URL is
https://boards.flaviocopes.com/boards/launch-week.

The owner can add, rename, reorder, and delete
columns. Each column has id, title, and an integer
position. Three columns is enough tonight.

The owner and Jordan can add a card with a title
to a column. Title is required, 1 to 120 characters.
New cards append at the end of that column.

Seed this board:
- slug launch-week
- title Launch week
- members Flavio (owner) and Jordan Lee
- columns in this order: To do, In progress, Done
- cards:
  Write the landing copy / To do
  Record the demo / To do
  Ship the blog post / In progress

Phone-sized view scrolls columns sideways. A card
title wraps. Do not add assignees, due dates, or
labels yet.

Done when I can refresh
https://boards.flaviocopes.com/boards/launch-week
and see those three columns and three cards.

A second “pretty preview” board is how you ship a layout that wraps badly on a phone. The public board is the editor.

What to check after the cards save

Open /boards/launch-week. To do has two cards. In progress has one. Done is empty.

Refresh. Same order. Rename “Ship the blog post” to “Ship the launch post”. Refresh. The new title is still there.

Narrow the window to 390 pixels. You can reach Done by scrolling sideways. You do not get three skinny columns stacked into a 12-pixel soup.

If the agent adds a label picker, a due date, or a checklist, delete those controls. They belong to the 10-hour row.

Prompt 3. Drag a card and write integers

The mini-prompt on the card is “Card ordering that survives the ten-thousandth drag”. Tonight you need the short version. Explicit integers. One transaction. No fractions.

Build drag-and-drop for Deck on
https://boards.flaviocopes.com/boards/launch-week.

Alpine (or the Client Component) moves the card in
the browser. The server is the arbiter of order.

Store explicit integer positions per card within a
column. A move, inside a column or across columns,
computes the target index and renumbers affected
siblings in one transaction. Rewrite the column.
Do not use fractional positions.

Handle these drops:
- top of a column
- bottom of a column
- the only card leaving a column
- a card moving from In progress to Done

Each request carries a client-generated operation id.
A double submit with the same id writes once.

Reject a drop on a missing column with a visible
error and revert the optimistic move.

Seed Jordan as a second browser. Do not open a
WebSocket.

Done when I drag Ship the launch post to Done,
refresh, and it is still the first card in Done,
and To do is still Write the landing copy then
Record the demo.

Fractional positions look clever for about a week. Then you get 0.5, 0.25, 0.125, and a card that cannot decide if it sits above another card. Rewrite the integers after every drop so the tenth drag and the thousandth stay ordered.

function orderAfterMove(columnCards, cardId, toIndex) {
  const moving = columnCards.find((card) => card.id === cardId)
  const without = columnCards.filter((card) => card.id !== cardId)
  const next = [
    ...without.slice(0, toIndex),
    moving,
    ...without.slice(toIndex),
  ]

  return next.map((card, index) => ({
    ...card,
    position: index + 1,
  }))
}

orderAfterMove(
  [
    { id: 'card-14', title: 'Write the landing copy', position: 1 },
    { id: 'card-15', title: 'Record the demo', position: 2 },
  ],
  'card-15',
  0,
)
//[
//  { id: 'card-15', title: 'Record the demo', position: 1 },
//  { id: 'card-14', title: 'Write the landing copy', position: 2 },
//]

Cross-column moves touch two lists. Update columnId, then renumber the source and the destination in the same transaction. If the destination write fails, the card is still where it started.

function moveCard({ card, fromCards, toCards, toIndex }) {
  const leaving = fromCards.filter((item) => item.id !== card.id)
  const arriving = toCards.filter((item) => item.id !== card.id)
  const placed = { ...card, columnId: toCards[0]?.columnId ?? card.columnId }

  return {
    from: leaving.map((item, index) => ({ ...item, position: index + 1 })),
    to: [
      ...arriving.slice(0, toIndex),
      placed,
      ...arriving.slice(toIndex),
    ].map((item, index) => ({ ...item, position: index + 1 })),
  }
}

The toCards[0]?.columnId line is the destination you already loaded. Pass the destination columnId in explicitly if the column is empty. An empty Done column is a real drop target.

What to check after the drag writes

Drag “Ship the launch post” to the top of Done. The card sits there before the request returns. Refresh. It is still first in Done. In progress is empty.

Drag “Record the demo” to the top of To do. Refresh. Record, then Write.

Drag the only card out of Done, back to In progress. Done stays a drop target. It does not vanish.

Open the database. Positions in each column are 1, 2, 3 with no gaps and no twins.

If two cards share position: 1 after a refresh, the agent wrote the optimistic order and skipped the transaction. Send prompt 3 back.

Prompt 4. Check membership, then let Jordan catch up

The official scenario is two members on one board. One moves a card. Both views end on the same column and position, with one card, not two. The matching mini-prompt uses optimistic UI plus a server version. Poll. Do not build a realtime engine.

The guardrail is the other half. Hiding Add card for Sam does not stop a POST.

Enforce membership and cheap catch-up for Deck.

Every board, column, and card mutation checks
active membership on the server. Flavio and
Jordan may move cards on launch-week. A signed-out
request, or a user whose membership was removed,
gets 403. The card stays put.

Seed a third user, Sam Rivera, with no membership.
Sam can sign in. Sam cannot read or mutate
launch-week, including by guessing
/boards/launch-week/cards/card-14.

When a move succeeds, bump board.version and store
the operation id. Other open boards poll every
three seconds for versions after their last-seen
number and replace the card list.

The mover's own echo, same operation id, does not
replay as a second animation.

If the server rejects the move, revert the
optimistic card and show the reason.

No WebSockets. No Durable Objects. No presence
avatars.

Done when Jordan sees Ship the launch post in Done
after one poll, Sam gets 403, and a removed
membership cannot POST a move that still sits in
the form.

Agents hide the “Add card” button for Sam and call that security. The catalog already told you to check membership on the server.

function canMutate(memberships, boardId, userId) {
  return memberships.some((row) => (
    row.boardId === boardId
    && row.userId === userId
    && row.status === 'active'
  ))
}

canMutate(
  [
    { boardId: 'launch-week', userId: 'flavio', status: 'active' },
    { boardId: 'launch-week', userId: 'jordan', status: 'active' },
  ],
  'launch-week',
  'sam',
)
//false

Three seconds is slow if you are used to Linear. It is fast enough for a three-person launch board, and it keeps you off the 1-month row.

function applyRemote(local, remote) {
  if (remote.version < local.version) {
    return local
  }

  if (remote.lastOpId === local.lastOpId) {
    return { ...local, version: remote.version }
  }

  return remote
}

applyRemote(
  { version: 4, lastOpId: 'op-88', cards: [{ id: 'card-16', columnId: 'done' }] },
  { version: 4, lastOpId: 'op-88', cards: [{ id: 'card-16', columnId: 'done' }] },
)
//{ version: 4, lastOpId: 'op-88', cards: [{ id: 'card-16', columnId: 'done' }] }

The mover already applied op-88. The poll should not bounce the card.

What to check after membership holds

Open the board as Flavio and as Jordan. Drag “Write the landing copy” to In progress as Flavio. Within a few seconds Jordan’s board shows that card in In progress, once.

Sign in as Sam. /boards/launch-week is forbidden. Guessing /boards/launch-week/cards/card-14 is forbidden too.

Remove Jordan’s membership. Replay a move he still has in a tab. The server returns 403. The card did not move.

If Jordan’s poll paints a second “Write the landing copy”, the agent is appending remote cards instead of replacing the list. Point it at applyRemote.

Failures the agent ships anyway

Fractional positions

position: 0.5 between 1 and 2 works until it does not. Ten thousand drags is the mini-prompt’s test for a reason. Rewrite integers. At board scale that rewrite is cheap.

Membership only in the interface

Turn the button off for Sam, then POST the move anyway. The server has to refuse. Same for a membership you revoked while the tab stayed open.

A WebSocket “because Trello has one”

Live sync is the 1-month row. Presence avatars, typing, and a sync engine are a different product. The Linear teardown is that split: the tracker you can ship, and the sync engine you are still paying for.

Two cards after a refresh

Optimistic UI plus a sloppy insert is how you get twins. The operation id and a unique card id are the lock. The poll replaces the list so a second Write the landing copy cannot appear.

Archive as DELETE FROM cards

The 10-hour row can add archive. When it does, archive is reversible and keeps the card. The mini-prompt is “Archive is a promise you can take back”. Do not start with a hard delete you will regret on Monday.

A feedback board with votes is a different job. That is Canny, $79–$99 a month, about 10 hours.

Deploy the shared board

The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.

Deck wants Astro, Alpine, and D1. Put it on a subdomain you already own, boards.flaviocopes.com. The AHA prompt points at Cloudflare D1 for the tables and Workers for the endpoints.

Attach the custom domain on the Worker. The board URL is the product, so boards.flaviocopes.com/boards/launch-week has to resolve on the public internet, not only on localhost.

Ship with the seed board and two members. Open it as Flavio on a laptop. Open it as Jordan on a phone on cellular, not the same Wi-Fi. Drag one card. Confirm the phone catches up after a refresh or one poll. Point one real project at it for a week.

If you miss checklists or a filter, those are the 10-hour row and the other mini-prompts on the card. Add them after a teammate asks.

Take a D1 backup, then cancel Trello, or Asana, whichever one you pay for.

What to skip on purpose

Leave out workspace billing, Power-Ups, complex automation, and anything that looks like a portfolio of boards across teams.

3-HOUR V1 One board, three columns Drag that writes integers Membership checked on the server SKIP THIS HALF Live sync and mobile apps Power-Ups Automation and workspace billing
Ship the 3-hour board. Leave live sync, Power-Ups, and automation on the skip row.

Checklists are the honest next feature, not a v1 requirement. Same reorder function, smaller rows. Labels and a keyword filter sit next to them on the 10-hour row.

Linear is $10–$16 a person a month, about 12 hours, because the product is issues, a cycle, and an assignee. The Linear teardown is that walk. Use that page if the work is tickets. Use this v1 if the work is cards on a wall.

Airtable is $24–$54 a person a month, about 3 days, because the product is typed records and a table. The Airtable walkthrough says a kanban there groups the same rows by status. Do not copy those rows into a board table. Trello is the other way around.

The first-project post is which card to open. Trello is a better first board than Notion.

A booking page after a card hits Done is a different job. That is the Calendly guide. Weekly hours and a slot lock, not three columns.

A one-question intake before the work starts is the Typeform guide. Four questions and a table.

A public board that says whether the product is up is the status page guide. Components and incidents, not cards you drag.

More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.

Start now

Go to Build your own Trello. Copy the AHA prompt, or the stack you already write.

Tell the agent the first estimate only: columns, drag-and-drop cards. Three hours.

Publish boards.flaviocopes.com/boards/launch-week. Point one real project at it tonight.