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.
The recurring thread across the series was that I built defences past the actual threat model — the dummy bcrypt hash, IP HMAC pseudonymization, magic-byte MIME sniffing, log-string sanitization, timing-safe bearer comparison — and flagged each as more than this site needs. Living with them for a while, I can split the list cleanly into two columns.
Earned their keep as patterns, not as production defences. Nothing here prevented an actual incident. The dummy hash hasn't blocked a timing probe; the IP HMAC hasn't stopped a rainbow-table attack; the magic-byte sniff hasn't caught a spoofed image/png. The site sees the traffic of a personal blog, and the realistic threat model on a personal blog is "occasional bot tries the login page and gives up." What these patterns did do is calibrate my judgment for the next time the threat model isn't hypothetical. I now have a feel for what "by the book" looks like across half a dozen surfaces, and that's the part I'd build again.
The actual cost was negligible. Two hundred lines of auth, twelve bytes of magic sniffing, one centralized sanitizeLogString call. None of it is load on the runtime, none of it makes the code harder to read, none of it has needed maintenance. The cost-of-keeping-it is essentially zero, which is what made it worth doing on a low-stakes project. On a fast-moving startup codebase, the same defences would be the wrong call — not because they're wrong, but because the maintenance budget would be allocated elsewhere.
A short list of the things that, regardless of project size, I'd pull forward to day one:
parseJsonBody helper. Single chokepoint for every request body, paths-only logging, contract test that asserts submitted values never appear in logs. Cost: about 60 lines including the helper. Benefit: every future API route inherits the discipline for free.const [...] as const array of tags, a derived TypeScript type, one auditLog(tag, payload) call per successful admin write. Typos fail at compile time; aggregator grep sets stay valid; the cost of adding a new admin resource is one line. Cheap to set up early, costly to retrofit once the call sites multiply.useOptimisticMutation with the MutateResult discriminant. Two production consumers and a clean enough shape that the third one (when it shows up) will land in under ten lines. The superseded vs. failure split is the part that pays dividends — without the discriminant, the third consumer would have re-invented the same race I already paid for.now, filter at read time. The future-dated content pattern. Generalizes to anything where the cache key shouldn't include the "is this currently visible?" decision: scheduled releases, feature-flag rollouts keyed on time, anything with a valid_until.React.cache() for per-request dedupe. Cheap, scoped, and removes the duplicate work that the server-component model lets you accidentally introduce by splitting a page across generateMetadata and the page body. I'll be adding it reflexively from now on.The posts schema. The Posts table predates this rebuild by years and survived the migration mostly unchanged. There are columns I'd model differently if I were drawing it fresh today, but the table gets me 90% of what I want, and the cost of a schema migration plus a backfill across two hundred rows plus updating every read path is more than I'm willing to spend chasing the remaining 10%. It's the kind of thing I'd modernize if I were starting over, and won't touch as long as the current shape isn't actively chafing.
The projects schema. This one I'd actually revisit. Tags and buckets currently live in fields that weren't designed for them; every time I add a project I end up massaging the data to fit, and there are features I've talked myself out of building because the model would have to bend further first. A proper tags table and a real category type would unblock more than they'd cost. The reason it hasn't happened yet is the same reason as for posts: the existing data works well enough that the migration tax keeps losing the argument against "do it later." But unlike posts, "later" here means "when I want to build the feature that needs it," and that feature is on the list.
The cache. unstable_cache was, in retrospect, a learning exercise more than a load requirement. A site with two hundred posts and personal-blog traffic doesn't need cache padding for page-1 reads or read-time datetime filtering — direct DB queries on every request would have been fine, and would have sidestepped Next's continuing churn around the caching APIs. I'd still build the future-dated puzzle the same way (the design's correct), but I'd reach for caching only once there was a real bill or a real latency number that warranted it, not because the framework dangles the helper at me.
Eight posts about a personal site is more than a personal site warrants, which is on-brand for the whole exercise. The point of the rebuild wasn't the site — it was the chance to build something end-to-end on the current generation of the tools I use professionally, take every decision seriously, and write down what I learned while it was still fresh. The site itself is the artifact; the seven previous posts are the lab notebook.
Thanks for reading along. There's one postscript still to come — the tooling that keeps me out of the admin — and after that the next thing I publish here probably won't be about Next.js.
If any of the series saved you a debugging hour, or if you've shipped something similar and want to compare notes, @roland.leth.ro is the place.