How to build a Linear alternative

How to build a Linear alternative with durable issue ids, one weekly cycle, and an assignee. Follow the prompts, check each step, and skip the sync engine.

  • guide
  • vibe-coding
  • linear

How to build a Linear alternative is a backlog of issues, one weekly cycle, and an assignee. Linear is that product. The catalog calls it “A sharp issue tracker for the way your team already works.”

You pay $10–$16 a person a month. That is $120–$192 a year per person, and the price scales with the team. The verdict is “For one team, yes”. The first estimate is 12 hours: issues, statuses, a backlog view.

The Linear teardown already separates the tracker from the sync engine. This guide is the screens, the rules, and the prompts in order.

The audience is one product team shipping in weekly cycles. If that team already treats Linear as the system of record, keep paying. The same v1 also replaces Jira and Shortcut. Dragging cards between columns is the Trello guide, at $10–$17.5 a person a month, about 3 hours.

How to build a Linear alternative without a sync engine

The Linear page splits the work into three rows.

The first row is the one you want. Issues, statuses, a backlog. Durable ids. An assignee. That is the 12-hour v1.

A cycle is the first piece of the 4-day row. The audience line is weekly cycles, so add one active cycle after the backlog is honest. The command menu, the throughput chart, and GitHub links stay on that 4-day row. Build them after a real week has closed.

The third row is 3 months and up. Offline sync, real-time everything, roadmaps. That row is the sync engine.

If you tell an agent to “clone Linear”, it aims at the third row. Pin every prompt to issues, one cycle, and an assignee.

The catalog names the project Straightline. The sample issue is LIN-1, “Fix timezone on the booking confirmation”. Jordan is the assignee. The week runs Monday 2 Nov through Sunday 8 Nov, in Europe/Rome. The guardrail says to use durable unique identifiers and to log every state transition. The non-goal leaves out roadmaps, cross-team initiatives, custom workflows, and deep Git synchronization.

The parts and where a status change goes

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Linear is Astro, HTMX, and D1. This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. The stack post is which tab you copy. The prompt post is how you pin the first estimate. The scope post is why the sync engine waits.

Straightline stores rows. A status change belongs in one transaction with the transition log, which is what D1 is for. Object storage waits, because there is no upload. A cron trigger waits, because close is a button. TanStack Start keeps the same screens, with server functions and a SQL database with migrations. The hosting post is the D1 binding when you leave localhost.

FLAVIO'S BROWSER Workspace, issue, cycle Sign-in required ASTRO ON THE REQUEST Create, assign, status, close Form post, then a new render D1 HOLDS THE ROWS Team, issue, cycle, log Version on each issue STAYS ON THE REQUEST Close waits for the owner No scheduled job OUTSIDE V1 Sync engine and realtime Command menu, charts, roadmaps
A status change is a form post. D1 stores the row and the log. The next page reads that row.

The browser posts a form. Astro writes D1. The next render reads the new row. A websocket room or a local replica of every issue is the third row.

The data one issue carries

The card lists Team, Issue, Cycle, Label, and Comment. This v1 needs Team, Issue, and Cycle, plus a transition log under the issue. A label manager and a comment thread wait until two people disagree on LIN-1. The teardown already says comments can wait an afternoon.

A team has a key, a counter, a timezone, and members. The key is LIN. The timezone is Europe/Rome. The counter only rises. Flavio is the owner. Jordan is a member.

An issue carries a number, a title, a markdown description, a status, a priority, an assignee, an optional cycle, and a version. The public id is the key plus the number. LIN-1 stays LIN-1 when the title changes.

A cycle has a name, a start, an end, and a state: upcoming, active, or closed. One active cycle. Dates use the team timezone.

A transition is append-only. Status, assignee, priority, and cycle changes each write one line, with the member and the time. The issue row is the current truth. The log is how you see that Jordan moved LIN-1 at 14:10.

TEAM LIN Counter only rises ISSUE LIN-1 Status, priority, version Assignee Jordan CYCLE Week of 2 Nov TRANSITION Append only LABEL Later COMMENT Later RETIRED IDS STAY RETIRED
One team, many issues, one optional cycle. The log appends. The counter never hands LIN-1 out again.

Every query is scoped to the team. A guessed id from a signed-out window returns nothing useful.

Deleting LIN-1 retires the number. Gaps are fine. The teardown notes that Linear’s own API can fetch an issue by that short id. Straightline keeps the same rule on its own pages. The form posts the version it loaded. If D1 has a newer version, the write is refused and the page shows the newer status. That matches the catalog scenario for a stale save.

Here is the shape of LIN-1 after Jordan starts it.

const issue = {
  id: 'LIN-1',
  title: 'Fix timezone on the booking confirmation',
  status: 'In progress',
  assignee: 'jordan',
  cycle: 'Week of 2 Nov',
  version: 3,
}

The version is what the conflict check compares. The cycle name is empty while the issue sits in the backlog.

The screens you ship

Three surfaces. Keep them boxes and labels. If the agent starts matching Linear’s live UI, send it back.

The workspace is home. Backlog lists issues with no cycle. This cycle lists issues on the active cycle. Done lists status Done, including issues on a closed week. A finished issue can appear in both This cycle and Done while that week is still active. Each row shows the id, title, status, priority, and assignee. Write the status out, and add a mark beside it. Create asks for a title. The defaults are status Backlog, priority Medium, no assignee, and no cycle.

STRAIGHTLINE + New issue BACKLOG THIS CYCLE DONE LIN-1 Timezone fix Backlog · Jordan LIN-2 Launch notes Backlog · Flavio
Backlog before the week starts. LIN-1 already has an assignee. LIN-2 stays with Flavio.

The issue page is one id. Title and description edit in place. Status, priority, assignee, and cycle save on their own. Copy link uses the id URL. The transition list is newest first. A stale version shows a banner and keeps the stored row.

LIN-1 Copy link Timezone fix Status · In progress Priority · High Assignee · Jordan Cycle · Week of 2 Nov VERSION 3 · TRANSITIONS 14:10 Jordan · In progress 09:04 Flavio · assigned Jordan Stale save shows this banner
The happy path is the log. The coral banner appears only when the form version is old.

The cycle page is the week itself. Dates, timezone, state, and the issues on it. The owner can start it when no other cycle is active. Close lists every issue that is not Done and asks for a destination: the next cycle, or the backlog. Completed issues stay on the closed cycle. Jordan can open the page. Jordan cannot start or close it.

WEEK OF 2 NOV Mon 2 Nov to Sun 8 Nov Active · Europe/Rome LIN-1 · Done Stays on this week LIN-3 · In progress Needs a destination To backlog To next cycle Close week
Close lists the open issue. LIN-1 is Done, so it needs no destination.

The path from create to a closed week

One issue moves through four states you can point at: no cycle, on the active week, Done on that week, or sent somewhere on purpose when the week closes.

1 · CREATE LIN-1 Backlog, no cycle, no assignee 2 · ASSIGN JORDAN One transition, version 2 3 · START THE WEEK Move LIN-1 and LIN-3 on 4 · VERSION MATCHES LIN-1 becomes Done Log the change STALE TAB Keep the newer row 5 · CLOSE THE WEEK LIN-1 stays. LIN-3 must move. BACKLOG Clear the cycle NEXT CYCLE Upcoming week Closed week still shows LIN-1
Create, assign, start the week, finish LIN-1, then send LIN-3 somewhere on purpose.
  1. Flavio creates LIN-1 with the timezone title. Status is Backlog. Priority is Medium. Assignee and cycle are empty. The backlog shows the row. The URL is the id.
  2. He assigns Jordan. The log gains one line, and the version increments.
  3. He creates LIN-2, “Write the launch notes”, and leaves it in the backlog with himself as assignee. He creates LIN-3 for the confirmation email.
  4. He starts “Week of 2 Nov”, Monday 2 Nov through Sunday 8 Nov. A second start is refused while this week is active. He sets the cycle on LIN-1 and LIN-3. LIN-2 stays in Backlog.
  5. Jordan sets LIN-1 to In progress, then Done. Each accepted save appends a transition. Flavio’s tab, still showing the old version, is refused.
  6. Flavio opens the cycle page and presses close. LIN-1 needs nothing. LIN-3 is not Done, so he sends it to the backlog or to the next cycle before close finishes.
  7. The closed week still lists LIN-1 as Done. This cycle is empty until he starts another week. LIN-3 is visible on the destination he picked.

Prompt 1. Seed the team and the session

Build Straightline, a tracker
for one product team.

Use Node.js and Astro.
Server-rendered pages.
Ordinary HTML forms.
Cloudflare D1 for the rows.
If you use TanStack Start
instead, use server functions
and a SQL database with
migrations. Same screens.
Same rules. No job runner.

Seed team key LIN, timezone
Europe/Rome. Flavio is the
owner. Jordan is a member.
Both sign in with a session
cookie. Passwords live in
env, not in the repo.

Every page and every mutation
checks team membership on
the server. A signed-out
visitor gets a login wall.
A guessed issue URL with no
session returns nothing
about the team.

One team only. No billing.
No GitHub login. No command
menu.

Done when both members can
sign in, and a private
window cannot read the
workspace.

What to check after login exists

Sign in as Flavio. Sign in as Jordan in another browser. Both see an empty workspace.

Open an issue URL with no cookie. You get the login wall.

Prompt 2. Mint an id that never returns

The first mini-prompt on the Linear page is this rule. The teardown calls it ABC-123 forever.

Add issue creation on the
Straightline workspace.

Create asks for a title.
Description is optional.
Defaults: status Backlog,
priority Medium, no assignee,
no cycle. The public id is
LIN- plus a number. The
first issue on a fresh
database is LIN-1.

Increment the team counter
in the same transaction as
the insert. Two creates at
once get two numbers. Use
a counter update or a row
lock inside that transaction.
Do not read the number in
application code, add one,
and write it back.

Renaming the title keeps
the id. Archive and delete
retire the number. The
counter never decreases.
The issue page loads by id.
Copy link copies that URL.

Done when two overlapping
creates get two ids, a
deleted LIN-1 never comes
back, and the next issue
is a higher number.

What to check after the first issue exists

Create “Fix timezone on the booking confirmation”. The row reads LIN-1. Rename the title. The old URL still opens it.

Create two issues from two browsers at the same moment. You get two numbers. Delete LIN-1 and create again. The new id is higher, and the old URL does not open a different bug.

Prompt 3. Build the backlog and the issue page

Build the Straightline
workspace and issue page.

Backlog lists issues with
no cycle. This cycle lists
issues on the active cycle.
Done lists status Done,
including closed weeks.
Show id, title, status,
priority, and assignee as
words, not color alone.
Filter by assignee and by
keyword. Empty backlog
tells you the next id will
be the following number.

The issue page edits the
title and a markdown
description. It sets status,
priority, assignee, and
cycle. Statuses are fixed:
Backlog, In progress, Done.
Priorities are fixed:
Low, Medium, High.

Each save posts the version
the page loaded. If D1 has
a newer version, keep the
stored row and show the
current status, assignee,
and who saved last.
Every accepted change to
status, priority, assignee,
or cycle appends a
transition with the member
and the time.

Sanitize the description.
A pasted script must not run.

No command menu. No websocket.
No GitHub link field yet.

Done when Jordan can set
LIN-1 to In progress,
Flavio's old tab is refused,
and the log shows Jordan's
change only.

What to check after a status change

Set LIN-1 to In progress as Jordan. Reload. The log has his name and a time.

Leave Flavio’s tab on the previous version and save a title. The banner shows the conflict, and the status stays In progress. Filter to Jordan, then clear it. View source on the description. A pasted script tag does not run.

Prompt 4. Close one cycle by hand

The close rule is already on the card. Rollover needs an explicit destination. One active cycle. Dates use the team timezone.

Add one weekly cycle to
Straightline.

A cycle has a name, a start,
an end, and a state:
upcoming, active, or closed.
Dates use Europe/Rome.
Only the owner can start
or close a cycle. A member
who posts close is refused.

Only one cycle is active.
Starting another while one
is active is refused.

The cycle page shows the
dates, the state, and the
issues on that cycle.
Setting the cycle on the
issue page writes a
transition.

Close is a button on that
page, not a cron. It lists
every issue on the cycle
whose status is not Done.
Each one needs a destination,
the next cycle or the
backlog, before close can
finish. Completed issues
stay on the closed cycle.
If every issue is Done,
close finishes with no
destination list.

After close, no cycle is
active until the owner
starts one. Rolled issues
stay visible on the
destination. The closed
cycle still shows LIN-1
as Done.

No burn-down. No email.
No midnight job.

Done when LIN-3 cannot
leave the week without a
destination, and the closed
week still shows LIN-1
after a next week exists.

What to check after the week closes

Put LIN-1 and LIN-3 on the active cycle. Mark LIN-1 Done. Leave LIN-3 in progress. Close as Jordan is refused. Close as Flavio lists LIN-3 and waits for a destination. LIN-1 stays on the closed cycle.

Reload the next day. LIN-1 is still on that week. LIN-3 is only on the destination you picked. The Worker has no cron trigger.

Failures the agent ships anyway

A counter you read, then write

Two creates can both read 1 and both try to insert LIN-1. The counter update and the insert have to share one transaction. Replay two creates and look for two numbers.

Recycled numbers

Archive LIN-1 and create again. If LIN-1 returns, old links and commit messages now point at a different bug. The counter stays above every id you have issued, including deleted ones.

A midnight rollover

The card says rollover requires an explicit destination. A scheduled job that moves leftovers into next week hides that choice. You notice on Monday, when the backlog is empty. Close stays a button on the cycle page.

A sync engine on day one

The teardown walks this half, including the public talks from Tuomas Artman. The 12-hour row is a table and a full page render. Stop when the plan mentions IndexedDB, a websocket, or a bootstrap dump of every issue. The Figma teardown is that same split: the file you edit, then the multiplayer layer under it.

A stale tab that overwrites

The catalog scenario is a conflict. Compare the version on the form with the version in D1 before you write. If Flavio’s old tab can set the status back to Backlog after Jordan moved it, the check is missing.

A GitHub mirror

Related links are the 4-day row. Two-way issue sync is the non-goal called deep Git synchronization. The teardown separates a pull request link from GitHub Issues Sync. If the agent asks for a GitHub token before the backlog works, paste the non-goal again.

A card you drag between columns is Trello, and the steps are in the Trello guide. A wiki of nested pages is Notion, $12–$24 a person a month, about 10 hours, written up in Don’t start by rebuilding Notion.

Deploy the tracker

Straightline wants Astro pages and a D1 database, on a Worker so the forms have somewhere to run. Put it on a subdomain you already own, such as issues.flaviocopes.com. The deploy is the pages, the D1 binding, and the domain.

The hosting post is that path: the adapter, then the D1 binding, then the custom domain.

Create the database. Run the migration. Seed LIN, Flavio, and Jordan. Sign in. Create LIN-1. Assign Jordan. Move it onto a cycle. Close the cycle with LIN-3 still open and confirm the destination stuck. Take a D1 backup before you drop the paid account. The ids and the transition log are the history you will miss.

Use it for one real week. A command menu, a throughput chart, or a GitHub link field is the 4-day row on the Linear page. If the team already lives in Linear’s realtime file, keep the subscription.

What to skip on purpose

Leave out the sync engine, roadmaps, custom workflows, cross-team initiatives, the command menu, the chart, and GitHub synchronization.

SHIP THIS Issues, statuses, backlog Assignee and durable ids One week, explicit close LEAVE FOR LATER Sync engine and realtime Command menu and charts Roadmaps and GitHub sync
Ship the list, the id, and one close ceremony. The sync engine stays on the later row.

The command menu mini-prompt on the Linear page is subsequence matching, command chaining, and a few thousand issues answering in under 50 milliseconds. The chart mini-prompt replays the transition log into a burn-down. Both wait until a week has closed with the button. A comment box can wait until two people disagree on LIN-1.

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 Linear. Copy the Astro prompt, or TanStack Start if that is what you write.

Tell the agent the first estimate, plus one cycle: issues, statuses, a backlog, an assignee, and one weekly close. Twelve hours for the list. The cycle is the next sitting, after LIN-1 survives a rename.

Related articles

Read all articles →