What I'd do differently
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 over-engineering verdict
The recurring thread across the series was that I built defenses 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, the list splits into two columns.
The first column: they earned their keep as patterns, not as production defenses. 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. What they 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 second column: the actual cost was negligible. Two hundred lines of auth, twelve bytes of magic sniffing, one centralized sanitizeLogString call. None of it loads the runtime or makes the code harder to read, and none of it has needed maintenance. The cost of keeping it all is near zero, which is what made it worth doing on a low-stakes project. On a fast-moving startup codebase, the same defenses would be the wrong call: the maintenance budget is better spent elsewhere.
Patterns I'd reach for again
A short list of the things that, regardless of project size, I'd pull forward to day one:
- The
parseJsonBodyhelper. 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. - The audit-log tag union. A
const [...] as constarray of tags, a derived TypeScript type, oneauditLog(tag, payload)call per successful admin write. Typos fail at compile time, aggregator grep sets stay valid, and the cost of adding a new admin resource is one line. Cheap to set up early, and costly to retrofit once the call sites multiply. useOptimisticMutationwith theMutateResultdiscriminant. Two production consumers and a clean enough shape that the third one (when it shows up) will land in under ten lines. Thesupersededvs.failuresplit is the part that pays dividends — without the discriminant, the third consumer would have re-invented the same race I already paid for.- Cache without
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 avalid_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 acrossgenerateMetadataand the page body. I'll be adding it reflexively from now on.
Things I'd revisit on a do-over
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%. I'd modernize it if I were starting over, and won't touch it 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.
Closing the series
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 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.
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.
Compare notes at @roland.leth.ro or @rolandleth, especially if you've shipped something similar.