Personal Blog and Newsletter Platform

Owning the whole publishing surface — the writing, the delivery, and the list.

Yaswanth Gudivada · yswnth.me

1 · Problem Statement

I wanted to write. I did not want to run a publishing operation.

Every hosted platform solves the writing and then owns the surface around it:

  • the URL structure
  • the email list
  • the styling
  • the analytics

Leaving the platform means leaving the readers behind.

But rolling your own usually costs more

The honest failure mode of a self-built blog:

  • publishing becomes a chore {reveal}
  • the chore becomes a backlog {reveal}
  • the backlog becomes an abandoned blog {reveal}

The tool has to disappear, or it does not get used.

The constraint

Publishing must cost exactly one git push.

Add a file. Commit it. Everything else — the deploy, the RSS feed, the email to every subscriber — has to follow on its own.

Every decision in this deck is downstream of that one sentence.

2 · Why This Tech Stack

Choice Why this one
Next.js App Router Renders MDX at build time; no content fetching at runtime
MDX files in git The repo is the CMS — no database for content, no admin UI
PostgreSQL + Drizzle Subscribers are relational, must not be lost, and want real constraints
Resend Transactional email with React templates, not a marketing suite
GitHub Actions The trigger already exists — the push itself
Tailwind Styling stays next to the markup; nothing to keep in sync

The pattern behind the table

Content is static, so it lives in the repo and compiles away.


Subscribers are state, so they live in Postgres.


Nothing else earns a runtime.

That split is what keeps the site cheap to run and impossible to lose.

3 · High Level Design — one push, two consequences

MDX file main Vercel build Static pages · RSS Actions · detect Broadcast job Inboxes subscribers git push new post only Resend

Reading the diagram

  • Left of the fork — one commit to main, the entire author-facing surface
  • Top path — Vercel builds; MDX is parsed and rendered at build time
  • Bottom path — Actions diffs the commit range and decides whether anyone gets mail
  • The gate — new post only, and that gate is the whole trick

Nothing is scheduled and nothing is polled. The push is the only trigger.

4 · Demo — the running site

What to look at

  • An article page — typography comes from the MDX component map, not per-post CSS
  • The "On this page" rail — one SVG path traced through the heading hierarchy, revealed with an animated clip-path as you scroll
  • The subscribe box — the only part of the site with a database behind it

Everything on screen was a file in a commit.

5 · Low Level Design — subscribing, and its races

Reader POST /api/subscribe Postgres Resend { email } select … where email null insert … returning unsubscribeToken welcome mail + List-Unsubscribe delivered Zod: trim + lowercase

Normalize before you compare

const subscribeSchema = z.object({
  email: z.string().trim().toLowerCase().email('Valid email is required'),
});

Me@Example.com and me@example.com are the same person.

Lowercasing inside the schema means every path into the handler gets the same normalization — there is no second entry point left to forget.

The lookup is not the guarantee

The select and then the insert are two round trips. Two simultaneous requests can both see "not found".

try {
  [created] = await db.insert(subscribers).values({ email })
    .returning({ unsubscribeToken: subscribers.unsubscribeToken });
} catch (error: unknown) {
  if (isUniqueViolation(error)) {
    return NextResponse.json({ status: "already_subscribed" });
  }
  throw error;
}

The unique index is the guarantee. SQLSTATE 23505 is that index doing its job.

The unsubscribe token

export const subscribers = pgTable("Subscriber", {
  active: boolean("active").notNull().default(true),
  unsubscribeToken: text("unsubscribeToken").notNull()
    .$defaultFn(() => randomUUID()),
}, (t) => [
  uniqueIndex("Subscriber_unsubscribeToken_key").on(t.unsubscribeToken),
]);

The token — never the email address — is what travels in the URL.

An edited or guessed link cannot unsubscribe somebody else, and the same token feeds the List-Unsubscribe header that renders Gmail's own unsubscribe control next to the sender name.

Broadcast: deciding who is actually new

push → main detect job diff --diff-filter=A new .mdx added? send job · fan out per slug skip — nobody is mailed yes no

The line the whole system rests on

git diff --name-only --diff-filter=A "$BASE" "$AFTER" -- 'content/blogs/*.mdx'

Publishing a post is usually two file changes:

Change Should it email?
The new article yes
Demoting the previously featured post no

--diff-filter=A keeps additions only, so edits never re-notify anyone.

6 · Challenges — the one that silently sends nothing

On a repository's first push there is no parent commit.

BASE="$(git rev-parse --verify --quiet "${AFTER}^" || echo "$EMPTY_TREE")"

A bare git rev-parse <sha>^ prints the unresolved string back instead of failing. That string becomes a bogus revision, git diff matches nothing, and the job reports success having mailed no one.

--verify --quiet turns a silent no-op into a real failure.

Concurrency, in two different places

In the workflow — overlapping runs queue instead of cancelling, so a follow-up push cannot kill a send that is already in flight.

concurrency:
  group: broadcast-${{ github.ref }}
  cancel-in-progress: false

In the API — the 23505 catch, because a check-then-write is never atomic on its own.

Same class of bug, one in CI and one in Postgres.

The toolchain moving underneath

The original build ran Prisma, and CI needed a prisma generate step whose only job was writing TypeScript types — but it still demanded the database secret to run at all.

Moving to Drizzle removed the code generation entirely: the schema is the TypeScript, so there is no client to build and one less way for CI to fail before it has done anything.

The workflow still checks every required secret up front, so a missing one fails with a readable message rather than deep inside Postgres or Resend.

Still open: the broadcast is not idempotent

Re-running the job re-sends the email. Nothing records which posts have already been announced.

A SentBroadcast table keyed on the slug fixes it: write the slug before sending, and treat the insert conflict as "already announced" — the same 23505 trick the subscribe endpoint already uses.

Known, written down, and next on the list.

What I would keep, and what I would do sooner

Keep

  • Content in git, subscribers in Postgres — the split held
  • The push as the only trigger — no scheduler to babysit
  • Handling the constraint violation instead of trusting the lookup

Sooner

  • Idempotency keys before the first real send, not after

Thank you

yswnth.me · github.com/Yaswanth6303/blog

Built to be read, and to keep being written in.

4px
1 / 22
← → navigate  ·  O overview  ·  F fullscreen  ·  L laser  ·  D draw  ·  ? controls