v3.0
$ cd ../blog

How I Built a Fully Free Newsletter for This Portfolio

July 3, 2026 sajad shafi
sveltekitnewslettertursoresendgithub-actions

The card that did nothing

My portfolio ![https://sajadshafi.com] has had a “Subscribe” card on it for few days now. It validated your email, showed a toast that said “under development,” and then did absolutely nothing with what you typed. I kept putting off building the real thing because every option I looked at either cost money I didn’t want to spend on a personal blog, or handed my subscriber list to someone else’s dashboard.

So I set myself a constraint: build it properly, and keep it at $0 a month, indefinitely, not just “free while you’re small.” This post is how that actually came together — the pieces, the free-tier limits that matter, and the one architectural decision that makes the whole thing possible.

If you want the short version: subscribers live in my own database, and an email API is only ever used to send a message, never to store my contact list. That one split is why this stays free even as more people subscribe.

Why the obvious answer costs more than it looks like

The fast way to add a newsletter is to sign up for Resend, Mailchimp, or Brevo, drop in their subscribe widget, and use their built-in contact list / audience feature. It works, and for most people it’s the right call — I’m not going to pretend otherwise.

But look closely at what “free” means for those audience features specifically. Resend’s free plan caps you at 1,000 contacts the moment you use their Audiences product to store subscribers. That’s not a send limit, that’s a “how many people are allowed to be on your list” limit. Cross it and you’re paying, regardless of how often you actually email anyone.

The part that isn’t advertised as clearly: that cap only applies if you use their audience storage. Their plain transactional send API — “here’s an email, send it” — doesn’t know or care how you’re storing your list. So the fix isn’t finding a provider with a bigger free contacts number. It’s not storing contacts with the provider at all.

The stack, and what each piece actually costs

Here’s everything involved, and the free-tier ceiling on each one, because “free” is a claim worth being specific about:

  • Turso (subscriber database) — SQLite-compatible, hosted. Free tier: 5GB storage, 10 million row writes a month, 500 million row reads a month. A personal blog’s subscriber table will not get near this.
  • Resend (sending the actual email) — free tier: 100 emails a day, 3,000 a month. Used purely as a send API, not for contact storage, so the 1,000-contact Audiences cap never applies.
  • GitHub Actions (the trigger) — free minutes on both public and private repos are more than enough for a job that runs for a few seconds, a handful of times a month.
  • Vercel Hobby (hosting the site and the API routes) — already what the portfolio runs on, no upgrade needed.

None of these are “free while small, then surprise bill.” They’re free at the scale a personal blog actually operates at, and the architecture is deliberately shaped to stay inside those limits rather than bump against them.

Two tables, and that’s the whole database

The subscriber data lives in two tables. subscribers holds who’s signed up and their unsubscribe token. sent_posts exists purely so a post never gets emailed out twice, even if I re-run something or edit a post after it’s already gone out.

SQL
CREATE TABLE subscribers (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL UNIQUE,
  status TEXT NOT NULL DEFAULT 'subscribed',
  unsubscribe_token TEXT NOT NULL UNIQUE,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE sent_posts (
  slug TEXT PRIMARY KEY,
  sent_at TEXT NOT NULL DEFAULT (datetime('now')),
  recipient_count INTEGER NOT NULL DEFAULT 0
);

Setting up Turso itself is three commands — no server to provision, no ops:

Bash
turso db create newsletter-prod
turso db shell newsletter-prod < db/schema.sql
turso db tokens create newsletter-prod

That last command gives you an auth token, and turso db show newsletter-prod --url gives you the connection URL. Those two values are the only thing the app needs to talk to it — a plain @libsql/client call over the network, same SQL you’d write against local SQLite.

Publishing a post should just send the email

I didn’t want a step where I remember to log into a dashboard and hit send. Add a blog post with published: true, push it, and subscribers get emailed — automatically, without a server I have to keep alive myself.

Vercel does support deployment webhooks that fire the moment a build finishes, but that’s a Pro-plan feature. Cron jobs exist on the free Hobby plan, but they’re capped at once a day with imprecise timing, which is fine for a lot of things and wrong for “I just published something, tell people now.” The free option that’s actually instant is a GitHub Action on push:

YAML
on:
  push:
    branches: [deployment]
    paths:
      - 'content/blog/**/*.mdx'

It diffs whichever .mdx files changed, checks each one’s frontmatter for published: true, and calls one API route with the post’s slug:

JavaScript
const response = await fetch(NOTIFY_URL, {
  method: 'POST',
  headers: { Authorization: `Bearer ${secret}` },
  body: JSON.stringify({ slug })
});

That route is the only thing in the whole system guarded by a secret, because it’s the only one that costs anything if someone spams it — every other endpoint (subscribing, unsubscribing) is safe to leave open.

On the server side, the route does four things in order: check sent_posts so it never emails the same slug twice, pull everyone with status = 'subscribed' out of Turso, send through Resend in batches of 100 recipients per call, and write a sent_posts row when it’s done. Batching matters more than it sounds — sending one email per recipient one at a time on a serverless function risks running past the execution time limit once the list grows past a couple dozen people. A hundred at a time keeps it well inside a single request.

Staying under the free ceiling on purpose

The one limit worth watching is Resend’s 100-emails-a-day cap. It applies to the whole day’s sending, not per campaign, so if the subscriber list ever grows past roughly a hundred people, a single post notification would hit that ceiling in one batch. That’s a future-me problem, and a cheap one to solve when it actually shows up — Resend’s paid tier is $20 a month for 50,000 emails, which is a fair trade once a list is big enough to need it. Building around that edge case today, for a list that doesn’t exist yet, would have been effort spent on a problem I don’t have.

What I’d tell someone setting this up

If owning your subscriber list doesn’t matter to you, just use a provider’s built-in audience feature — it’s less code and a completely reasonable choice. This approach is for the specific case where you want your data portable and you’re willing to spend an evening wiring three free services together instead of picking one paid one.

And if this post landed in your inbox — that’s not a coincidence. It’s the first real send this whole setup has ever made.

NORMAL blog/building-a-terminal-portfolio-with-sveltekit-and-a-newsletter-i-actually-own.svelte main