You lead a Next.js App Router codebase where routes keep drifting from static to dynamic as features land, and nobody notices until the hosting bill does. What conventions and guardrails would you put in place, and what tradeoffs would you accept?
answer
- inferred, invisible, non-local
- name the routes with a contract
- turn the contract into a build failure
- diff route classification in CI
- refuse the silent force-static fix
basics
~20 sMake rendering mode an explicit, enforced property of each route: guard the load-bearing static routes so a request-time read fails the build, diff the build's route classification in CI, and confine request-scoped reads to modules the team recognises as dynamic.
solid answer
~50 sDrift happens because rendering mode is inferred, invisible in review, and can be changed by a module three imports away. So I make it explicit and enforced. First, classify routes deliberately — a short list that must stay static, and everything else. Second, guard that list with `export const dynamic = 'error'`, which turns an accidental `cookies()` read into a build failure that names the call. Third, put the build's per-route Static/Dynamic summary in CI and diff it between builds, so a flip appears in the pull request rather than in a graph a week later. Fourth, set a code convention that request-scoped reads live in clearly named server modules and are passed down as props, never buried in shared UI or a util. The costs I accept: some duplicated layouts, personalization that flashes or arrives client-side, and a guard that occasionally blocks a legitimate change.
go deeper
Know that a route's static or dynamic mode is inferred from the code rather than declared, and that the build output is where you check which one a route got.
Be able to explain why drift is hard to see in review — the trigger is one line, often in shared code or a dependency, and it reclassifies the whole route rather than one component.
Show that you would automate the detection rather than trusting discipline: a guard on protected routes and a CI diff of the build's route classification, plus a fix that preserves correctness.
Own the framing that rendering mode is a contract per route with a cost attached, argue the tradeoffs of guards, duplicated layouts and client-side personalization out loud, and rule out the remediation that trades a cost bug for a correctness bug.
## Why this drifts in the first place Three properties of the model combine badly at team scale. **It is inferred, not declared.** Nothing in a route says "this one is prerendered". The mode is a consequence of what the code happens to do, so no one owns it. **It is invisible in review.** The pull request that flips a route from static to dynamic often adds one line — an import, or a helper call in a shared header. There is no diff marker for "this now runs on every request for every route in the app". **It is non-local.** The classification is decided by what executes during the render, so a Dynamic API inside a shared package changes routes that never mention it. Blast radius is the whole route, including everything under a shared layout. And the failure mode is not an error. Pages keep working; only cost, cold starts and TTFB move. Anything that only shows up on a graph will be discovered late. ## Guardrail 1 — decide which routes have a contract Not every route needs to be static. Trying to keep all of them static is how you end up fighting the framework. Instead, name the small set where static rendering is load-bearing: the landing pages, the docs, the highest-traffic marketing paths, anything a CDN carries at volume. That list is a product decision, not a technical one, and it should be short enough that people remember it. ## Guardrail 2 — make the guard executable For routes on that list, `export const dynamic = 'error'` converts the contract into a build failure: any Dynamic API or uncached read in the route stops the build and names the call. This is the highest-leverage single line here, because it moves detection from production metrics to the pull request, and it explains itself to whoever hits it. The deliberate refusal is `'force-static'`. It also restores the Static label, but by making `cookies()` and `headers()` return empty values — so the page prerenders while treating every visitor as anonymous. A guard that converts a cost bug into a correctness bug is worse than no guard. ## Guardrail 3 — put the classification in CI The build prints a per-route Static/Dynamic summary. Capture it as a build artifact and diff it against the base branch; fail or warn on any route that moved toward dynamic. This catches the routes not on the protected list, catches framework upgrades that reclassify things by changing defaults, and gives reviewers a fact instead of an intuition. It costs one CI step. ## Guardrail 4 — a convention about where request reads live Most drift arrives through shared code: a header component, a `getUser()` util, an SDK wrapper. The convention that holds up is that request-scoped reads happen in a named, obvious place — a per-route server module, or the page itself — and the values travel downward as props. Shared UI components take a `user` prop; they do not fetch a session. That makes the dynamic dependency visible at the call site and keeps shared components reusable in static routes. The same rule applies to dependency selection: a library whose only server entry point reads `headers()` during render is a library that dictates your rendering mode. That is worth knowing before adoption, not after. ## Guardrail 5 — an agreed pattern for personalization Most drift is one requirement wearing different hats: "show something per user on a page that should be static". Have a house answer so each team does not improvise. The usual choices are a client-side fetch after hydration (static route, brief signed-out flash, extra request), split layouts so only the app section is dynamic (URL-stable via route groups, some duplication), moving the decision into middleware where it is a redirect or rewrite rather than a render input, or adopting Partial Prerendering where the platform and version support it. Picking one as the default and documenting when to deviate is worth more than picking the theoretically best one. ## The tradeoffs I would state plainly - **Ceremony.** Guards and conventions add friction to legitimate dynamic work; sometimes someone must delete a guard on purpose, and that should be an easy, reviewable act rather than a fight. - **Duplication.** Splitting layouts to protect a static section duplicates chrome, and duplicated chrome drifts visually. - **User-visible cost.** Client-side personalization means a flash of the signed-out state, which is fine for a greeting and unacceptable for anything that changes page structure or implies an entitlement. - **Version churn.** Caching and rendering defaults have moved across Next majors, so any convention written against a default needs re-examining at each upgrade. Write conventions against mechanisms — "does this need the incoming request?" — not against defaults. - **Not everything should be static.** The goal is that every dynamic route is dynamic *on purpose*. A team that treats dynamic rendering as failure will reach for `'force-static'`, and that is the one outcome worse than the drift.
- Why not simply mandate dynamic = 'error' on every route in the app?Because plenty of routes are legitimately dynamic, and a blanket guard makes normal work fight the tooling. Guards are cheap where the contract is real and expensive everywhere else. The broad net is the CI diff of route classification, which informs without blocking; the hard guard belongs on the short protected list.
- How does this plan survive a Next.js major upgrade that changes caching defaults?By being written against mechanisms rather than defaults. The guards assert a property of the route — it must prerender — which stays meaningful when defaults move. The CI classification diff is what actually catches the upgrade: routes that reclassify show up as a diff on the upgrade pull request rather than as a bill next month.
- A team says a shared header must show the user's name, and the marketing pages must stay static. How do you resolve that?Pick the house pattern rather than debating per case. Usually: render the header server-side in its signed-out form and hydrate the name from a client fetch, accepting the flash; or split the layout so marketing keeps a static header. Reserve Partial Prerendering for when the version and platform support it and the flash is genuinely unacceptable.
saying these in an interview costs you the question
- Treats every dynamic route as a failure to be eliminated
- Proposes force-static as the standard remediation
- Relies on code review alone to catch request-time reads
- Writes conventions against a caching default rather than a mechanism
- Ignores that a dependency can change rendering mode on its own