After a stale-data fix, a Next.js App Router app that used to prerender most of its routes now builds with almost every route rendered on demand, and TTFB and origin CPU are up. How do you find what turned prerendering off, and how do you get it back?
answer
- read the build's route table
- who renders inside everything?
- root layout is in every route
- one suspect, one rebuild, one diff
- re-fix lower, do not just revert
basics
~20 sRead the build output to see exactly which routes went dynamic, then look for an opt-out placed too high: force-dynamic or revalidate = 0 in a shared or root layout, an awaited connection() in a shared component, or a no-store buried in a helper every page imports. Push it down to the one place that needs it.
solid answer
~50 sStart from the `next build` output, which labels every route as prerendered or rendered on demand — that tells you whether the change hit one subtree or the entire app. App-wide almost always means the opt-out landed in something every route renders: `export const dynamic = 'force-dynamic'` or `export const revalidate = 0` in the root or a shared `layout.tsx`, an `await connection()` in a shared header, or a `cache: 'no-store'` added inside a data helper that every page imports. A root layout renders inside every route, so making it request-time makes the whole app request-time; that is the classic version of this incident. Confirm by reverting the suspect line and rebuilding, or by bisecting the commit. Then re-fix at the right scope: mark only the one read uncached, or isolate the genuinely fresh component so it alone is request-time work while the rest of the route keeps its prerendered output.
code
tsx · 14 lines// app/layout.tsx — the anti-pattern: app-wide reach from one line
export const dynamic = 'force-dynamic'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}go deeper
Know where to look first: the build output lists each route as prerendered or rendered on demand, and that table is how you tell which routes changed.
Explain why a segment config or an opt-out in a shared layout reaches every route beneath it, and name the three usual carriers: the root layout, a shared component, and a shared data helper.
Show the full loop — bisect to one line, confirm with a rebuild and a table diff, then re-land the original fix at the narrowest scope and state the recovered cost in TTFB and origin CPU.
Own the prevention: a CI assertion on the route table, review gating for opt-outs in shared files, and a team rule that no correctness fix ships without naming the scope it applies at.
## Step 1 — get the facts from the build, not from intuition `next build` prints a route table that says, per route, whether it was prerendered or will be rendered on demand. This is the artifact to reason from, because it reflects what actually shipped rather than what the code appears to say. Two shapes of damage: - **A whole subtree went dynamic** → suspect a `layout.tsx` in that subtree. - **Essentially everything went dynamic** → suspect the root layout, or a module imported by every route. Compare against the previous build's table if you keep them in CI. If you do not, this incident is the argument for starting: a diff of that table is the cheapest possible regression detector for exactly this class of change. ## Step 2 — the usual suspects, in order of likelihood **A segment config in a layout.** The root layout is rendered inside every route in the app. If it declares itself request-time, nothing beneath it can be served as a prerendered document — which is every page you have. ```tsx // app/layout.tsx — a one-line change with app-wide reach export const dynamic = 'force-dynamic' ``` The same is true of `export const revalidate = 0` there. Both express "do not reuse a stored render of this segment," and the root segment is inside everything. **A shared component that awaits `connection()`.** A header, a nav, or an experiment wrapper rendered by the root layout has the same reach as the layout itself. This one is harder to spot because the marker is in a component file rather than in a route file. **A `no-store` inside a shared data helper.** Someone needed one caller to be fresh and edited the helper instead of the call site. Now every page that imports it re-reads that data. This may not by itself flip routes to dynamic, but it does explain a spike in upstream traffic and origin CPU that arrives alongside the rendering change — and the two edits usually ship in the same commit. **Reading request data in something shared.** Pulling cookies or headers into a shared layout ties every route to a request. Whether that was the deliberate fix or collateral from a refactor, the effect is the same as the config export. ## Step 3 — confirm before you fix Bisect. Revert the single suspect line, rebuild, and diff the route table. This matters because the plausible causes stack: teams under staleness pressure frequently ship two or three of them at once, and reverting one while another remains looks like "the fix did nothing," which sends people down the wrong path. One change, one build, one table diff. ## Step 4 — re-fix at the right scope The original stale-data bug was real; do not simply revert and leave it broken. Re-implement the fix as low as it will go: 1. If it was one read, mark that read uncached at its call site — and if it lives in a shared helper, pass the caching decision in as a parameter so each caller states its own need instead of inheriting someone else's. 2. If a component must genuinely execute per request, put `await connection()` in that component rather than in a layout above it. 3. Only if the entire route is per-request should the segment config appear, and it belongs in that route's own `page.tsx`, not in a layout other routes share. Then split the page so the request-time region is its own component and streams in, leaving the document itself prerenderable. That is the shape that gives you both correctness and a fast first byte. ## Step 5 — make the regression visible next time The reason this incident is expensive is that nothing failed. Tests passed, the page was correct, and the only symptom was a slower, more expensive site. Two cheap guards: - Assert on the build's route table in CI — fail the build if a route that is expected to be prerendered is not. - Treat any segment config export in a `layout.tsx`, especially the root, as a reviewed change with a written justification. Opt-outs in shared files are the only ones that scale badly, so they are the ones worth gating. ## What the interviewer is listening for They want the reflex of *placement over flags*: the candidate who immediately asks "where was that put?" rather than "which flag was used?", who reads the build output rather than guessing, and who can name the cost in the currency of the incident — TTFB, origin CPU, upstream request volume — instead of describing caching in the abstract.
- Why does an opt-out in the root layout reach routes that never import it?Because every route renders inside it. The root layout is part of the component tree of each page, so if that segment must be produced per request, no page containing it can be served from a prerendered document. Reach here follows the render tree, not the import graph — which is why the blast radius surprises people.
- The team wants to revert the fix outright. What do you argue?That the staleness bug was real and reverting reintroduces it. The defect is the scope, not the intent. Re-land the same fix at the narrowest level — the one read, or the one component — and keep the rest of the app prerendered. Reverting trades a correctness incident for a cost incident and back again.
- How would you stop this from recurring silently?Make the build's route table a checked artifact: assert in CI that routes expected to be prerendered still are, and fail the build otherwise. Pair it with review attention on any segment config export added to a shared or root layout. Nothing here throws an error, so the only defence is making the change visible.
saying these in an interview costs you the question
- Blames the CDN before reading the build output
- Reverts the fix and leaves the staleness bug open
- Assumes a layout only affects routes that import it
- Adds caching back at random until the numbers improve
- Thinks a passing test suite means nothing regressed