A newsletter without a newsletter provider
No free managed tier will email your list the moment you publish — that's the feature kept behind the paywall. So I didn't rent it. This site's newsletter is three small pieces I already run for free: a static form, a serverless function that holds the API key, and a CI job that broadcasts on publish.
I wanted one thing from a newsletter: when I publish, email the people who asked to hear about it — a short summary and a link. Every managed provider does this. None of the free tiers do — "send automatically when I publish" is precisely the feature they hold back for a paid plan.
So I didn't buy the plan. The thing I needed turned out to be small enough to own.
The site is a pure static export — there is no server to hold an API key. That single constraint shapes the whole design.
Three pieces, all on free tiers I already run#
The job is three concerns: capture an email, store it, and broadcast to the list when a post goes out. Not one of them needs a newsletter SaaS.
Capture: a form that cannot leak the key#
A static page can't hold the API key — anything in the bundle is public. The one place a secret can live on a static host is a Cloudflare Pages Function: a single serverless endpoint that deploys alongside the static files. The form POSTs to it; the key never reaches the browser.
// functions/api/subscribe.ts — the only server-side surface on the site
export async function onRequestPost({ request, env }) {
if (!env.RESEND_API_KEY) {
return json({ error: "Newsletter is not configured." }, 500);
}
const { email, website } = await readForm(request);
if (website) return json({ ok: true }); // honeypot: a bot filled it
if (!EMAIL_RE.test(email)) {
return json({ error: "Enter a valid email." }, 400);
}
await fetch("https://api.resend.com/contacts", {
method: "POST",
headers: { authorization: `Bearer ${env.RESEND_API_KEY}` },
body: JSON.stringify({ email, unsubscribed: false }),
});
return json({ ok: true });
}
The form degrades gracefully — it's a real <form action="/api/subscribe" method="post">, so it works with JavaScript off; the fetch handler only exists to skip a full-page navigation. A hidden honeypot field catches bots without asking a human to solve a puzzle.
Store: the list lives in Resend#
Resend is the relay — it holds the contacts and does the actual sending. The free tier is 1,000 contacts and 3,000 emails a month, which is a long runway for a personal blog. The function above creates a plain global contact; a Resend segment groups them, and that segment is what the broadcast targets.
Broadcast: the publish hook#
This is the feature I refused to pay for, and it is a dozen lines of CI. When a new file lands under content/posts/, a GitHub Action renders a branded email — the post's summary and a link — then asks Resend to create a broadcast and send it to the segment.
// create the broadcast, then send it
const { id } = await resend("/broadcasts", {
segment_id: SEGMENT_ID,
subject: post.title,
html: renderEmail(post),
});
await resend(`/broadcasts/${id}/send`, {}); // fire
A small ledger file records which slugs have already been mailed, so re-running the job — or pushing an unrelated change — never double-sends.
What it costs to own#
Nothing in dollars; something in surface area. I now own three small pieces instead of one dashboard — if the broadcast logic breaks, that's mine to fix, not a support ticket. The Resend free tier caps me at 1,000 contacts and 3,000 sends a month, a limit I will happily renegotiate the day it bites. And it is single opt-in today; if deliverability ever wobbles, the honest fix is a confirmation step — which I would add before scaling the list, not after.
That is the trade: a few moving parts I understand, instead of a monthly line item I don't.
The takeaway#
A "newsletter provider" is, mostly, a hosted form, a contacts table, and a send button. When the only thing you actually need from behind the paywall is email my list when I publish, you can usually assemble it from infrastructure you already run for free — and own the result instead of renting it. The provider sells convenience; sometimes the convenience isn't worth the dependency.
Keep reading
ULIDs over UUIDs: standardizing identifiers across 40 services
An implicit decision left unenforced becomes 40 services split 60/40 between two incompatible identifier shapes. Here's the standard I wrote to close the door — and why ULID won.
- Architecture
- PostgreSQL
- Platform engineering
- DDD
ADR: Outbox over dual-write for cross-context events
Why every state-changing event in the Ghasi suite goes through a transactional outbox instead of a direct publish — and what that costs.
- Event-driven
- DDD
- Reliability
- PostgreSQL
Stop calling new Date() in your services
Time and randomness are infrastructure. When every service rolls its own clock port, you get six file paths and three interface names for one concern. The fix is one platform port — and the testability it unlocks.
- Testability
- Hexagonal architecture
- DDD
- TypeScript