How to build a Kit alternative
How to build a Kit alternative as a markdown letter, a public archive of sent issues, and a send that skips anyone who already opted out. Prompts inside.
How to build a Kit alternative is a letter you write once, a send to people who asked, and the same issue on the web. Kit is that product. The catalog line is “A creator newsletter without the creator-economy cockpit.”
You pay $29–$79 a month. That is $348–$948 a year. The verdict is “Creator-sized is buildable”. The first estimate is 10 hours: subscribers and markdown broadcasts.
The Kit post is the teardown. It stops before the public archive. This guide ships that archive with the letter, because the card’s audience is “one creator publishing emails and a public archive.” Welcome steps, tags, and open stats stay on the later row.
The same v1 also replaces beehiiv and Buttondown when the job is a list and a letter. A company list with no public page is the Mailchimp guide, at $20–$350 a month, about 12 hours. A site that leads and a feed that follows is the Ghost guide, at $9–$35 a month for Ghost Pro. Styled sections and one send are Flodesk, $25–$54 a month, about 8 hours.
How to build a Kit alternative as a letter and a page
The Kit page splits the work into three rows.
The first row is subscribers and markdown broadcasts. This guide keeps that row and adds the public page of a sent issue. The catalog names the project Letterkit.
The second row is 4 days, 32 hours. A welcome sequence, tags, and send stats. The archive is listed there too. This guide pulls the page forward and leaves the sequence. The third row is a month and up, 160 hours. Paid newsletters, a recommendations network, and visual automations. The non-goal already names those, plus a second publication.
If you tell an agent to “clone Kit”, it aims at the third row. Paste the prompts below. One publication. One frozen issue. One public slug.
The example publication is Bootcamp Weekly. The sending domain is letter.flaviocopes.com. Ada is [email protected], source footer-form. The issue is “What shipped this week”. The public slug is what-shipped-this-week.
Where the letter and the page split
Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line is Astro, D1, and Workers. 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 browser shows a dashboard, an editor, a people table, and a public issue page. Astro on the request saves the draft, freezes it on Send, and inserts delivery rows. D1 holds the publication, the people, the broadcasts, and the deliveries. A Worker on a one-minute cron hands each queued row to a transactional provider. The public pages read the frozen snapshot. They never read the draft.
The minute schedule is the same shape as the Mailchimp guide.
crons = ["* * * * *"]
TanStack Start keeps these screens, with server functions, a SQL database, and a job runner in place of the cron. The archive is still a public route over the frozen body.
What one issue carries
The card lists Subscriber, Tag, Broadcast, SequenceStep, and Delivery. This v1 needs the subscriber, the broadcast, and the delivery. Tag and SequenceStep wait.
There is one publication. It stores the name, the from-address, the sending domain, a reusable header, and a reusable footer. Send copies the header and footer onto the broadcast. A later change to the publication chrome does not rewrite an issue that already went out.
A subscriber has an email in lowercase, a status, a source, and a joined time. Status is pending, active, unsubscribed, or bounced. The Kit post uses those words. pending has not confirmed. active may be mailed. The other two must never enter a send. A hard bounce sets bounced. A complaint sets unsubscribed.
A broadcast starts as draft. Send sets sent, writes the slug, and stores the frozen subject, preview text, body, header, and footer. The slug comes from the subject. “What shipped this week” becomes what-shipped-this-week. A second issue with the same subject gets a numeric suffix. Drafts have no public slug.
A delivery id is del-{broadcastId}-{subscriberId}. A retry that finds it does not call the provider again. The Mailchimp guide walks that lock. Here the status you select is active.
const issue = {
slug: 'what-shipped-this-week',
status: 'sent',
}
The public page renders that snapshot.
The screens you ship
Four surfaces. The dashboard and the editor are signed in. The archive is public.
The dashboard is /. It shows the publication name, how many people are active, the last sent subject, and whether a test from the sending domain has succeeded. Compose starts a blank draft. Send on a new issue stays disabled while that domain test is red.
The editor has subject, preview text, and markdown. Above the preview you see the publication header. Under it, the footer, including the unsubscribe line. Test sends to one address and writes no audience rows. Send now freezes the snapshot and queues active people.
The people table lists email, status, source, and joined time. A public form at /join requires a consent checkbox and stores pending until the confirm link. CSV import does the same. It never writes active from a file.
The issue page is /archive/what-shipped-this-week. It shows the title, the date, and the frozen body with the header and footer that were copied at send. The HTML head has that title, the preview text as the description, and a canonical URL for this path. /archive lists sent issues, newest first. /rss.xml lists the same issues and links each item to its page. A draft URL returns 404, and the 404 does not include the draft title.
The path from a join to a public URL
- Ada checks the consent box on
/join. The row ispendingwith sourcefooter-form. She opens the confirm link and becomesactive. - Flavio writes the issue. The preview shows Bootcamp Weekly at the top and the unsubscribe line at the bottom. A test goes only to him.
- He presses Send. The broadcast stores the frozen parts and the slug. One delivery is queued for Ada. Lea, still
pending, has none. - The cron marks Ada
sent. A second tick does not mail her again. /archivelists the issue. The public page matches the email body./rss.xmllinks to that page. A draft sitting in the editor is absent from both.- Ada unsubscribes. The next issue skips her.
what-shipped-this-weekstill renders.
Prompt 1. One publication and a confirm
Build Letterkit, a creator
newsletter with one
publication.
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.
Seed one publication named
Bootcamp Weekly. Sending
domain letter.flaviocopes.com.
A reusable header and footer.
Flavio is the only owner.
The public form at /join
needs a consent checkbox.
New rows are pending, with
source footer-form. A confirm
link sets active and stores
the time. A bad token changes
nothing. CSV import writes
pending, never active.
The people table shows email,
status, source, and joined
time. The dashboard shows the
active count and the last
sent subject.
No second publication.
No tags. No billing.
Done when Ada can confirm,
the dashboard count is 1,
and a signed-out visitor
cannot open the people table.
What to check after the join
Submit without the checkbox. Nothing is stored. Submit Ada, open the link, and see active plus a time. Submit her again. There is still one row.
Import a CSV with Lea. Lea is pending. The dashboard count stays 1.
Prompt 2. An editor that shows the chrome
Add the editor for a draft.
Fields are subject, preview
text, and markdown body.
The sample subject is
"What shipped this week".
Preview text is "Four posts
went out."
The preview wraps the body
in the current header and
footer, and the footer
includes an unsubscribe line.
Say on the page that Send
will copy that chrome.
Test sends to one typed
address through the provider
adapter. It writes no
audience deliveries.
Send stays disabled until
a test from the sending
domain has returned sent.
No welcome sequence.
No visual blocks.
No schedule field.
Done when the preview shows
header, body, and footer,
and a test leaves the
delivery table empty.
What to check after the preview
Type the sample subject and two lines of body. The preview shows Bootcamp Weekly and the unsubscribe line around that body.
Send a test to yourself. Confirm no delivery rows exist. Change the publication header and reload the draft. The preview uses the new header, because this issue is still a draft.
Prompt 3. Freeze, queue, and send once
Send copies subject, preview
text, body, header, and
footer onto the broadcast
and sets status to sent.
Build a unique slug from
the subject. The sample
slug is what-shipped-this-week.
Insert one delivery per
person whose status is
active. The id is
del-{broadcastId}-{subscriberId}.
If that id exists, skip.
Return counts for queued,
sent, failed, and skipped.
Do not call the provider
in this request.
A Worker cron runs every
minute. Before each call,
read the person again.
If they are no longer
active, mark the delivery
skipped. On success mark
sent. On error mark failed
and allow a retry of that
same id.
Editing the draft after
send must not change the
frozen fields or the
queued mail.
Done when Ada is sent once,
Lea has no delivery, and
a second cron tick does
not call the provider again.
What to check after the freeze
Send with Ada active and Lea pending. One queued row. Run the cron twice. One provider call.
Edit the draft body after send. Ada’s sent record and the frozen body stay on the old text. The editor can keep a new draft for the next issue.
Prompt 4. A public archive of sent issues
Add /archive, /archive/:slug,
and /rss.xml.
Archive lists sent issues,
newest first, with title
and date. The issue page
renders the frozen header,
body, and footer. The head
has the issue title, the
preview text as the
description, and a canonical
URL for that path.
RSS items are the same sent
issues. Each item links to
the issue page. Drafts are
absent from the list, the
feed, and the sitemap if
you have one.
A request for a draft slug,
or for a slug that was never
sent, is a 404. That HTML
does not include the draft
title.
Unsubscribe is a signed
link, a confirm button, and
a List-Unsubscribe header.
It sets unsubscribed in the
same request and skips
queued deliveries. It does
not remove the public issue.
A verified bounce webhook
sets bounced. Duplicate
event ids are ignored.
Done when the sample slug
is public, a draft is not,
and Ada's unsubscribe
leaves the page up while
the next issue skips her.
What to check after the page is public
Open /archive/what-shipped-this-week in a private window. You see the title, the date, and the body that was sent. View source and find the canonical URL and the preview text in the description.
Open /rss.xml and find one item pointing at that path. Create a second draft and confirm the feed still has one item.
Unsubscribe Ada, then queue another issue. She is missing. Reload the first public page. It still renders.
Failures the agent ships anyway
The archive reads the editor
You fix a typo after Send and the public page changes with it. Readers who got the email and readers who open the URL are looking at different bodies, or worse, the same moving target. The page has to render the copied fields.
Drafts in the feed
/archive shows “Notes for Friday” which you have not sent. The query used every broadcast. Filter on sent, and keep the slug empty until that moment.
A header that only exists in the email
The inbox shows Bootcamp Weekly and the page shows bare markdown. The freeze forgot to copy the chrome. The public body and the mailed body should be the same snapshot.
A welcome sequence on day one
A new active person receives three extra emails you did not write this week. That is the 4-day row. The Kit post already says step 2 fires after step 1 was sent, and an unsubscribe cancels the rest. Leave it until one real issue has gone out.
Two publications
A publication switcher, or a second from-address on the same login, means the agent started the skip row. Letterkit is one name and one domain.
A checkout next to Compose
A price, a Stripe button, or “recommend this newsletter” is the month-long row. Delete it. Paid recommendations and commerce are named in the non-goal.
Deploy the letter
Letterkit wants Astro, D1, and a Worker cron. Put the public archive on a hostname you already own, letter.flaviocopes.com. Add the provider’s DKIM records, send the test, then send the real issue.
The hosting post is the adapter, the D1 binding, the cron, and the domain.
Take a D1 backup before you import people you can explain. Confirm Ada, send the sample issue, open the public URL on a phone, and click unsubscribe. The page should remain. The next send should skip her.
Use it for a few real issues. If you miss a welcome note or an open count, those are the 32-hour row. Then cancel Kit, or beehiiv, or Buttondown, whichever letter you pay for.
What to skip on purpose
Leave out paid newsletters, a recommendations network, visual automations, and a second publication.
A list with no public page is Mailchimp. A blog that is the product, with email beside it, is Ghost. Letterkit is the letter first. The page is the copy you already sent.
Start now
Go to Build your own Kit. Copy the Astro prompt, or TanStack Start if that is what you write.
Tell the agent the first estimate, plus the public page of a sent issue. Subscribers, markdown broadcasts, and the archive. Leave the welcome sequence out.
Send one real issue. Open the slug in a private window before you add a paid tier.