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.
No NextAuth. No tRPC. No CMS. No Drizzle helpers layered on Prisma. The site is one user — me — writing posts to a database. Every dependency past that needs to earn its place.
Most "Next.js stack" posts read like a parts list. That's not how the choices felt to make. Most of mine were really one decision in different costumes: keep the surface area small, because a one-person site that I want to maintain for years is allergic to abstractions I'd have to relearn each time I open it.
So when I write "single user" in the rest of this series, it's load-bearing. A lot of choices below would be the wrong call on a team or for a multi-tenant app, and I'll flag where.
I went all-in on the App Router and server components, and the rule that kept the thing sane is: push client boundaries as far out toward the leaves as you can. A page is a server component unless it has to be otherwise. A section inside the page is a server component unless one of its children needs useState. The "use client" directive lives on the smallest piece that genuinely needs interactivity — a theme toggle, a carousel, a single input — and not one wrapper up.
That sounds like the obvious read of the React docs, but in practice the temptation is to mark the whole admin dashboard "use client" because three buttons inside it need state. Resist that, and Next.js gives you something pretty rare: a stack where most of your code never ships to the browser, and the small amount that does is so localized you can find every interactive piece by searching for the directive.
The downside is real, though: there are entire categories of bug you only hit because of the server-component model. I have a whole post planned on three of them. They're worth the price; they're not zero.
Postgres on Vercel, Prisma as the ORM, Zod at every request boundary. Nothing exotic. The interesting work was at the edges, not at the database layer.
Two patterns kept the data layer honest:
parseJsonBody helper that Zod-validates and only ever logs the failing field's path, never its value. I'll walk through it in the validation post.const [...] as const array of tags. Adding a new admin resource is a single edit, and forgetting to log is a type error rather than a silent omission.A lot of what's in the next few posts is more than this site needs. I built each defense because the cost of doing it right on a low-stakes project is small, and the cost of meeting it for the first time on a high-stakes one isn't. I'll flag where the line falls.
A small proxy.ts middleware checks a session JWT signed with jose; passwords are bcryptjs-hashed; the login route is per-IP rate-limited; everything's gated by a single /api/admin/* matcher. It's about 200 lines total. NextAuth would have done all of this, plus thirty other things I don't need. "200 lines I can read in one sitting" beat that trade for me.
The thing I had the most fun with in this layer is the rate-limit IP pseudonymization story: a small piece of privacy-aware engineering that turns out to be load-bearing in a way I didn't expect. That's the next post.
No CSS-in-JS, no styled-components, no module CSS. Just Tailwind, a small set of theme tokens, and the system color scheme. Dark + light from day one, because retrofitting either onto a site that's been live for a year is the kind of avoidable misery I've stepped on enough times.
This isn't a hot take any more, but it bears repeating: in 2026, "you'll regret Tailwind in two years" has had time to play out, and largely hasn't. The codebase doesn't have a styling layer that grows; it has class names on elements, which is where you were looking anyway when you opened the file.
Hosted on Vercel. The only piece of state that needs to outlive a request — the per-IP rate-limit bucket on the login route — lives in Upstash Redis. Everything else is the database. The free tier covers what a personal site sees comfortably, and the SDK reads like normal Redis rather than inventing its own vocabulary.
A handful of posts about interesting topics and decisions I made along the way:
Each one will lean on a bug or constraint that made me reach for the final solution. If any of the patterns above made you wince, the relevant post should explain itself. If it doesn't, I'd love to hear about it.