The shape of a Next.js app
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 framework on top of the framework
There's no NextAuth here, 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 has to justify itself.
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, take it literally: a lot of the choices below would be the wrong call on a team or for a multi-tenant app, and I'll flag where.
Server components as the default
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; there's a whole post on three of them.
Boring data, picky boundaries
Postgres on Vercel, Prisma as the ORM, Zod at every request boundary. Nothing exotic; the edges needed more thought than the database layer did.
Two patterns kept the data layer honest:
- Every API route's request body goes through a small
parseJsonBodyhelper that Zod-validates and only ever logs the failing field's path, never its value. The validation post walks through it. - Every admin mutation writes a structured audit log entry with a fixed shape, derived from a
const [...] as constarray 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.
Single-user auth, by hand
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 matter in a way I didn't expect. That's the next post.
Tailwind only, dark mode day one
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: 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.
Vercel, with a small dependency on Upstash
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.
What the rest of the series is
A handful of posts about decisions I made along the way:
- The shape of a Next.js app (this one)
- Auth for a single-user site, without NextAuth: a jose session cookie, bcrypt with a dummy hash, per-IP rate limiting, and why the IPs get pseudonymized.
- Input validation and logs that don't leak: Zod at every request boundary, logging the failing path and never the value, plus a magic-byte sniff on uploads.
- The admin write contract: the audit log, the optimistic mutations and the return-type discriminant every admin write goes through.
- 200 markdown posts and the future-dated content puzzle: drafts, published and scheduled posts under
unstable_cache, and the two bugs in the obviousWHEREclause. - Three surprises about server components:
<Link>prefetching every visible row,<Link>onmailto:and static files,unstable_cacheassuming JSON. - Three markdown pipelines and a theme toggle: one column rendered three ways, and the bug that explained
useSyncExternalStore. - What I'd do differently: which of the extra defenses earned their keep, and what I'd revisit.
- The tools that keep me out of the admin: posts as files, projects as a manifest, a script that imports both.
Each one leans 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: @roland.leth.ro or @rolandleth.