Back in [the opener][series] I wrote "No CMS" and moved on in four words, without saying what happens instead. Then I spent three posts hardening an admin — auth, input validation, a [write contract][contract] with an audit log — and [called the series done][prev]. Consider this the postscript, because the honest answer to that "No CMS" is a small toolbox whose only job is keeping me out of the admin I spent all that effort building.
Seven posts in, the rebuild has been live long enough that the things I called "more than this site needs" have had a chance to either earn their keep or expose themselves as decoration. This is the closer: a short retrospective on the verdict, the patterns I'd reach for again, and the handful of decisions I'd revisit if I started over.
Every post on the site is markdown in a database column. Three different consumers turn that column into something readable, and each one wants the output in a different shape: blog pages want React with syntax highlighting; the Atom feed's <content> element wants self-contained HTML with no external CSS; the feed's <summary> and the OG/SEO meta description want plain text. One pipeline can't serve all three without compromising at least one of them, so I built three.
Server components are the best thing Next.js has done in years. Most of my code never ships to the browser, the few interactive widgets are visibly marked with "use client", and the data layer is one fetch away from any page that needs it. That part of the App Router pitch is real.
The blog has about 200 posts. They're stored in Postgres, edited through an admin UI, rendered as markdown, and cached with unstable_cache so the average page load doesn't touch the database. None of that is interesting on its own. What's interesting is the third state every post can be in.
A post is one of three things at any moment: a draft, published-and-visible, or scheduled ("published, but the publish date is in the future"). Drafts shouldn't appear anywhere public. Scheduled posts shouldn't appear until their datetime passes. The moment a scheduled post crosses that boundary, it should appear, without me logging in to nudge a cache.
Every admin write on the site does three things, in order: it mutates the database, it writes a structured audit line, and (on the UI side) it commits an optimistic state update before the network resolves. Each of those was its own copy-paste mess for a while: a handful of handlers each doing roughly the same thing, with subtle drift on the parts that mattered. This post is about the small toolkit they collapsed into.
This is a layer further inside than [the validation post][prev]. By the time we get here, the body has been parsed, the user is authenticated, and we have typed data in hand. The interesting work is what the handler does with it.
Every admin write on the site goes through one of two helpers at the request boundary: parseJsonBody, which Zod-validates the JSON body, and the multipart parser inside the upload route. Both refuse to log the values they received. Both produce log lines I'd be comfortable pasting into a screenshot.
A site with one admin and a handful of write endpoints doesn't need a log-string sanitizer, paths-only Zod logging, or a 12-byte magic sniff on uploaded images. The line between "real defense" and "decoration" is instructive, and where each of these falls is the point of the walkthrough.
I'm the only user on this site. There's no signup, no password reset, no OAuth providers, no "log in with GitHub" button. Just me, a cookie, and a login page nobody else has any reason to visit. So I didn't reach for NextAuth. It would have done what I needed plus thirty things I don't. The auth layer ended up at about 200 lines, and I want to walk through them, because most of the interesting work isn't the auth itself; it's the small defenses around it.
The other month I rebuilt my site on Next.js. I want to write about it, but not the migration itself, which is mostly a long list of "this was the equivalent on the new side." What's worth writing about is the shape of the app I ended up with, and the small handful of decisions that shaped everything that came after.
I'll start with what I did and didn't pick.
I've been working for a while on a new project (stay tuned!) which has a Watch app. Up until now I used a script that automatically increases the build number of the app, based on the value in Info.plist:
buildNumber=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$INFOPLIST_FILE")
buildNumber=$(($buildNumber + 1))
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "$INFOPLIST_FILE"This works really well, the only problem with Watch targets is that the build number has to be the same for all targets. First idea was to just add this build script to all targets, which might work well, but it just felt wrong.