How to build a Mailchimp alternative

How to build a Mailchimp alternative with one consented list, a markdown campaign, and a send that stops before the next letter reaches someone who left.

  • guide
  • vibe-coding
  • mailchimp

How to build a Mailchimp alternative is one list, one campaign, and a send you can stop. Mailchimp is that product. The catalog line is “One list, a few tags, and newsletters that arrive.”

You pay $20–$350 a month. The verdict is “If your list is simple”. The first estimate is 12 hours: a subscriber list and a broadcast.

The Mailchimp teardown covers the public statuses, the send split, and reputation. This guide is the screens, the rules, and the prompts. Read that post when a provider word needs a source.

The audience is a small company sending a weekly newsletter to one audience. If the company already lives in visual journeys and a CRM, keep paying. The same v1 also replaces MailerLite, Brevo, ActiveCampaign, and Constant Contact when the job is “people who asked, then one email”. A creator letter with a public archive is Kit, $29–$79 a month, about 10 hours. A public site that also emails is Ghost Pro, $9–$35 a month. The Ghost guide is that site. A signup that should stay a line is GetWaitlist, $15–$50 a month, and there is a waitlist guide.

How to build a Mailchimp alternative as one list

The Mailchimp page splits the work into three rows.

The first row is the one you want. A table of emails. A markdown campaign. A job that hands each subscribed row to a transactional provider. The catalog names the project Postbird.

The second row is 4 days, 32 hours. Tags, a send-at clock, and open tracking, on top of double opt-in. The third row is 3 months and up, 480 hours. Visual journeys, a template builder, and a full CRM. The non-goal already names those, plus multivariate tests.

The confirm link sits with the 12-hour row. The goal on the card is consented subscribers, and the guardrail says to keep consent evidence. Tags, scheduling, and open pixels can wait.

If you tell an agent to “clone Mailchimp”, it aims at the third row. Paste the prompts below, and keep each one on one list and one regular campaign.

Flavio runs the Bootcamp.dev list. Ada is [email protected], source footer-form. The campaign subject is “What shipped this week”. The from-name is Bootcamp.dev. The reply-to is [email protected].

Where a send actually runs

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 scope post is why journeys wait.

The browser shows the form, the table, and the editor. Astro on the request writes the subscriber, confirms a token, saves the draft, and inserts delivery rows. It does not wait for every inbox. D1 holds the rows. A Worker on a one-minute cron takes queued deliveries and calls the provider. The provider returns bounces to a webhook.

You rent the pipe. Postmark is one catalog option at $15–$18 a month, and the Postmark post is that log.

TanStack Start keeps the same screens, with server functions, a SQL database, and a job runner in place of the cron.

The schedule line is the whole background story.

crons = ["* * * * *"]

That fires every minute.

BROWSER Form, table, editor Flavio is signed in ASTRO ON THE REQUEST Confirm, draft, queue Does not wait on inboxes D1 ROWS Subscriber, campaign, delivery WORKER EACH MINUTE One queued row, one call EMAIL PROVIDER Send, then a webhook back OUTSIDE V1 Journeys, templates, CRM
The request queues. The minute worker talks to the provider.

The rows one campaign needs

The card lists Subscriber, Tag, Campaign, Delivery, and LinkClick. This v1 needs the subscriber, the campaign, and the delivery. Tag and LinkClick wait with open tracking.

A subscriber has a stable id, an email stored in lowercase, a status, a source, and a confirm time. Status is pending, subscribed, unsubscribed, or cleaned. The teardown takes those words from Mailchimp’s public member statuses. pending has not confirmed. subscribed may be mailed. unsubscribed and cleaned must never enter a send.

The confirm token is random and single-use. A match on a pending row sets subscribed and clears the token. A second click of the same link does not create a second row.

A campaign has a subject, a from-name, a reply-to, a markdown body, and a status of draft, sending, or sent. Send freezes the subject and the body. A later edit to the draft does not change letters that are already queued.

A delivery id is stable. For Ada and this campaign it is del-camp-oct-28-sub-ada. Status moves from queued to sent, failed, or skipped. A retry that finds the id does not call the provider again.

const delivery = {
  id: 'del-camp-oct-28-sub-ada',
  status: 'queued',
}

The sending domain lives on the list, bootcamp.dev, not on each campaign. Send stays disabled until a test message to your own inbox has come back as sent. That is the stand-in for DKIM being in place. The teardown names the records the provider will ask for.

BOOTCAMP.DEV One list SUBSCRIBERS Ada subscribed Lea pending Old cleaned CAMPAIGN What shipped Draft DELIVERY Ada, queued TAG Later LINK CLICK Later
Ada can be mailed. Lea and the cleaned row cannot. The delivery id is the lock.

The screens you ship

Four surfaces. Keep them boxes. If the agent starts a journey canvas, send it back.

The public form can live at /subscribe. Email, a consent checkbox, and a hidden source of footer-form. Success says to check the inbox. It does not say “you are subscribed” while the row is pending. A duplicate email stays on the existing row and does not mint a second confirm if that row is already subscribed.

SUBSCRIBE Bootcamp.dev [email protected] I asked for this letter Send the confirm link Pending until the link is opened
The form stores pending. The confirm page is what flips the status.

The table is /subscribers. Columns are email, status, source, and confirmed time. Filter chips show all, pending, subscribed, unsubscribed, and cleaned. Empty state says to share the form. A CSV import previews rows, then writes pending and sends confirm links. It never writes subscribed from a file.

The editor is /campaigns/camp-oct-28. Subject, from-name, reply-to, and a markdown body. A preview renders the markdown plus a footer line with the unsubscribe link. Test sends to one address you type, and it does not insert audience deliveries. Send is disabled while the domain test is still red. After Send, the same page shows queued, sent, failed, and skipped counts.

CAMPAIGN Draft What shipped this week From Bootcamp.dev Four posts went out. The invoice one is live. Preview plus unsubscribe footer Domain test is green Test one inbox Send now Queued 1 · Sent 0 · Skipped 0
Test hits one address. Send freezes the markdown and fills the queue.

The unsubscribe page needs no login. It shows the address and a confirm button, because inbox bots click links. The one-click header on the provider call can skip that button. Both paths set unsubscribed in the same request and skip any delivery still queued for that person.

1 · ADA SUBMITS Row is pending, mail is one link BAD TOKEN Stays pending TOKEN MATCHES Subscribed 2 · WRITE AND TEST One inbox, no audience rows 3 · QUEUE Subscribed only SKIP LEA Still pending 4 · WORKER SENDS ADA Same id on a retry UNSUBSCRIBE Queued rows skip HARD BOUNCE Status cleaned NEXT CAMPAIGN Ada is off the audience
Pending never enters the queue. A retry reuses the delivery id.
  1. Ada submits the form with the box checked. The table shows pending and the source footer-form. She gets one confirm mail.
  2. She opens the link. Status becomes subscribed and the time is stored. Lea, who never opens hers, stays pending.
  3. Flavio writes “What shipped this week” and sends a test to himself. The table of deliveries for the campaign is still empty.
  4. He presses Send. One delivery exists, del-camp-oct-28-sub-ada, status queued. Lea has no delivery.
  5. The cron runs. Ada’s row becomes sent. Running the cron again does not send a second copy.
  6. Ada opens the unsubscribe page and confirms. Her status is unsubscribed before the next page load finishes. A second campaign queues nothing for her.
Build Postbird, a newsletter
for one company list.

Use Node.js and Astro.
Server-rendered pages.
Ordinary HTML forms.
Cloudflare D1 for the rows.
A Worker cron will send
later. If you use TanStack
Start instead, use server
functions and a SQL database
with migrations. Same screens.

Seed Flavio as the only
signed-in owner. The public
form at /subscribe needs no
account. Email, a required
consent checkbox, and source
footer-form.

New rows are pending.
Send one confirm link.
A matching token sets
subscribed, stores the time,
and clears the token.
A bad token changes nothing.
A second submit of an
existing subscribed email
does not create a duplicate.

The table at /subscribers
shows email, status, source,
and confirmed time. Filters
for the four statuses.
CSV import previews, then
writes pending and sends
confirm links. It must not
write subscribed from a file.

Done when Ada is pending,
the link makes her
subscribed, and a private
window cannot open the table.

What to check after the list exists

Submit Ada with the box unchecked. The page refuses and the table stays empty.

Submit with the box checked. The row is pending. Open the confirm link. The row is subscribed and a time is visible. Open the link again. There is still one row.

Import a two-line CSV. Both addresses are pending, not subscribed.

Prompt 2. A markdown campaign and a test

Add the editor at
/campaigns/:id.

Fields are subject,
from-name, reply-to, and
markdown body. The sample
subject is "What shipped
this week". From-name is
Bootcamp.dev. Reply-to is
[email protected].

Preview shows the rendered
body and a footer line for
the unsubscribe link.
There is no template gallery
and no drag-and-drop blocks.

Test sends to the single
address typed in the test
field. It uses the provider
adapter. It writes no
audience delivery rows.

Send stays disabled until
a test to the owner has
returned sent. Store the
sending domain bootcamp.dev
on the list.

No open pixel. No click
redirect. No schedule field.

Done when a test arrives
in one inbox and the
delivery table is still
empty.

What to check after the test

Save the draft, reload, and read the same subject back. Send a test to yourself. Confirm the audience delivery list is empty.

Click Send while the domain test is still red. The campaign stays draft.

Prompt 3. Queue, then a minute cron

Send freezes the subject
and the markdown. Insert
one delivery per subscriber
whose status is subscribed.
The id is del-{campaignId}-{subscriberId}.
If that id exists, skip.

Set the campaign to sending.
Return the page with counts
for queued, sent, failed,
and skipped. Do not call
the provider inside this
request.

A Worker cron runs every
minute. It loads queued
deliveries. Before each
call, read the subscriber
again. If status is no
longer subscribed, mark
the delivery skipped.

On success mark sent.
On a provider error mark
failed and allow a manual
retry of that same id.
A second cron tick must
not mail a sent id again.

No MTA. No batch of ten
thousand. No engagement
sort.

Done when Ada is sent,
Lea has no row, and two
cron runs produce one
provider call.

What to check after the cron

Press Send with Ada subscribed and Lea pending. Counts show one queued. Trigger the scheduled handler. Ada is sent. Trigger it again. The provider log shows one call.

Turn Ada to unsubscribed while a fresh delivery is still queued, then run the cron. That delivery becomes skipped.

Prompt 4. Unsubscribe and a hard bounce

Every campaign footer
links to /unsubscribe?token=
with a signed token for
that subscriber. The page
shows the address and a
confirm button. Confirm
sets unsubscribed in the
same request and marks
queued deliveries for
that person as skipped.

Also pass a List-Unsubscribe
header on the provider
call, one-click, hitting
the same status change.

A webhook accepts the
provider's bounce and
complaint events. Verify
the signature. A hard
bounce sets cleaned.
A spam complaint sets
unsubscribed. Ignore
duplicate event ids.

Do not mail cleaned or
unsubscribed on the next
campaign. Leave the row
on the table.

Done when Ada's confirm
removes her from the next
audience, and a hard bounce
on a different address
shows cleaned without a
second send.

What to check after the exit

Open Ada’s unsubscribe link, confirm, and reload the table. Status is unsubscribed. Queue another campaign. She is absent from the deliveries.

Post a signed hard-bounce event for a test address. The row reads cleaned. Post the same event id again. The row does not change twice, and no mail goes out.

Failures the agent ships anyway

Send inside the click

The button spins until the platform times out, and half the list got the letter. The request should only insert queued rows. The hosting post is where the cron binding goes. The minute schedule does the rest.

A random delivery id

Ada receives two copies after a retry. The id was a new uuid each time. Use del-camp-oct-28-sub-ada and treat a repeat as a no-op.

A CSV that subscribes everyone

The import wrote subscribed and the next campaign mailed people who never confirmed. The teardown’s warning is the rule. Import as pending and send the confirm link.

A test that is a real send

The test field contained your address, and every subscribed row got a delivery. Test must call the provider once and leave the delivery table alone.

Unsubscribe as a later job

The page says “you are removed” and the status is still subscribed because a queue will “get to it”. Flip the row in the confirm request. Then skip queued deliveries for that id.

An open pixel on the first row

A tracking image, or a redirect in front of every link, means the agent started the 4-day row. Leave LinkClick out. You can add a count after a real letter has gone out.

Deploy the list

Postbird wants Astro, D1, and a Worker cron. Put it on a subdomain you already own, such as letter.bootcamp.dev. Attach the domain, add the provider’s DKIM records, and send the test to yourself before you enable Send.

The hosting post is the adapter, the D1 binding, the cron, then the domain.

Take a D1 backup before you import a real list. Import a handful of addresses you can explain, including Ada. Confirm, test, send, and click unsubscribe. A second campaign should miss her.

Use it for a few real letters. If you miss tags or a Tuesday send time, those are the 32-hour row. Then cancel Mailchimp, or keep paying when shared-IP reputation is the product you wanted.

What to skip on purpose

Leave out visual journeys, a template builder, multivariate tests, and a CRM.

12-HOUR V1 A list with a confirm link One markdown campaign A cron send and an exit SKIP THIS HALF Tags, a clock, open pixels Journeys and a template mall A CRM beside the list
Ship the list and the letter. Leave the marketing suite on the skip row.

A public archive of the same letter is a different product. That is the Kit guide, and the Ghost guide if the site matters more than the list. Do not grow Postbird into either one on this pass.

Start now

Go to Build your own Mailchimp. Copy the Astro prompt, or TanStack Start if that is what you write.

Tell the agent the first estimate only: a subscriber list and a broadcast. Twelve hours.

Confirm Ada, send one letter, and click her unsubscribe before you add a tag.

Related articles

Read all articles →