Owning the whole publishing surface — the writing, the delivery, and the list.
Yaswanth Gudivada · yswnth.me
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:
Leaving the platform means leaving the readers behind.
The honest failure mode of a self-built blog:
The tool has to disappear, or it does not get used.
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.
| 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 |
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.
main, the entire author-facing surfaceNothing is scheduled and nothing is polled. The push is the only trigger.
clip-path as you scrollEverything on screen was a file in a commit.
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 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.
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.
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.
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.
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 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.
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.
Keep
Sooner
yswnth.me · github.com/Yaswanth6303/blog
Built to be read, and to keep being written in.
press ? any time