The tutorial app has three pages and no cache, so every tutorial works. Ours had 40 routes — authenticated dashboards next to public marketing — and a pricing banner that showed a $49 tier for six hours after we'd shipped the $39 one. Support tickets, not a monitoring alert, told us. The banner was a statically rendered route, so the Full Route Cache handed out build-time HTML while the Data Cache underneath it held the stale number; nothing invalidated either until we redeployed. No bigger Vercel plan fixes that. Understanding that App Router caches four separate things at four layers does.
We've shipped a dozen App Router apps since it went stable, and the same four failures recur: cache defaults that assume your data never changes, a Server/Client boundary that leaks bundle weight one way and throws serialization errors the other, hydration mismatches that only surface in `next start`, and middleware billed as free Node that is neither free nor Node. None are exotic. Each is the framework doing something clever on your behalf and not saying so loudly. If you're hiring a Next.js team or weighing a migration, these decide whether the project is calm or a 2am page.
The cache is a distributed system you didn't design
`fetch()` is request-memoized, the Data Cache persists across requests, the Full Route Cache stores rendered HTML, and the client Router Cache holds prefetched segments for 30 seconds in the browser. The banner bug lived across two of them at once: the Data Cache served the old price, and because the route was static, the Full Route Cache never re-rendered to pick up a new one. You didn't write a cache; Next wrote four, and every default assumes immutable data.
The discipline is to be explicit at the point of every fetch. Call `revalidateTag` inside the write path so a mutation busts exactly the entries it dirtied, mark anything user-specific `cache: 'no-store'`, and choose each `revalidate` window deliberately instead of inheriting the framework's guess. Next 15 flipped `fetch` and Route Handlers to uncached by default, which helps — but plenty of production apps sit on 14, where one unmarked `fetch` silently flips a dynamic page to static and freezes it at build time with no warning. We review the caching config like a database schema: commented, and tested by firing a second request and asserting the value actually moved.
The Server/Client boundary leaks in both directions
`'use client'` is not a per-file switch, it's a boundary: every module imported below it ships to the browser. Put the directive on a component that also pulls in `moment` and a charting library, and a page rendering a static table now downloads 200KB+ of JavaScript to do nothing interactive. The reverse fails louder — pass a function, a Date method, or a class instance from a Server Component into a Client Component and you get `Only plain objects can be passed to Client Components`, because the boundary serializes props and functions don't serialize.
The shape that holds is Client Components as leaves. Fetch and compose on the server, isolate the interactive parts — a dropdown, a form, a chart — into small client islands, and pass serializable props down. When an island needs server work, call a Server Action instead of smuggling a closure across the wire. Bundle size then tracks actual interactivity, not the accident of where someone dropped the directive in the tree.
Hydration mismatches and the things that only break in prod
Hydration errors are the single most common thing we inherit from other teams' code, and the cause is always the same: the server rendered HTML the client can't reproduce byte-for-byte. `new Date().toLocaleString()`, `Math.random()`, reading `window`, or a password-manager extension injecting a `<div>` before React attaches — each yields server markup that disagrees with the first client render, so React 18 discards the whole subtree and re-renders it on the client. In dev that's a red console warning you can ignore. In prod it's a flash of the wrong content plus a layout shift that lands directly on your CLS score.
Anything truly client-only goes behind a mounted flag (`useEffect` sets `mounted`, render null until then) or a `dynamic(() => import(...), { ssr: false })`. Locale and timezone formatting must be pinned to fixed values on both sides — leave it to the runtime and a server in UTC will disagree with a browser in PST every time. The deeper lesson: `next dev` lies. Streaming order differs, the Edge runtime is missing Node APIs you assumed, and dev and prod render through different code paths. Nothing is done here until it has survived a `next build && next start` on the machine at least once.
Edge runtime and middleware are not free Node
Middleware runs on the Edge runtime, so there is no `fs`, no `crypto` in the Node shape you expect, a ~1–4MB bundle ceiling, and a tight CPU budget — and it executes on every matched request, static assets included, if your matcher is lazy. We've watched a team drop `jsonwebtoken` plus its dependencies into middleware to verify auth, blow past the Edge bundle cap on deploy, then rewrite it against `jose` only to burn CPU verifying a token on every image and font request. A missing `config.matcher` turns middleware into a per-asset tax on the entire site.
Keep middleware to what Edge is good at: cheap redirects, header rewrites, and a signature-only token check. Anything touching a database, a Node library, or real compute belongs in a Route Handler or Server Action on the Node runtime. Pick the runtime per route on purpose — Node where you need the ecosystem, Edge only where the work is trivial and the latency win is real — rather than accepting Edge because it was the default.
How we scope a Next.js build
Before any code, we sort every route into three buckets: fully static (marketing, docs), dynamic per-request (dashboards, anything behind auth), and incrementally revalidated on a timer (catalogs, a blog index). That table sets the caching strategy per route, decides Server Action versus Route Handler, and forces the auth boundary into view early — the seam where most App Router migrations tear, because a single authed `fetch` in a shared layout drags a whole subtree dynamic. A caching layer you drew on paper beats one you're reverse-engineering from stale prod HTML after launch.
Next.js is the right default for full-stack work in 2026 — RSC keeps client JavaScript small, streaming makes slow queries feel instant, and Server Actions delete a whole tier of API glue. But it pays out only for teams who can name what the framework is doing under each abstraction. The four failures above aren't reasons to skip it; they're the checklist that separates an app holding steady at 40 routes from the demo that fell over reaching them.