skip to content

After a routine dependency upgrade, a Next.js App Router route that used to be listed as Static in the build output is now listed as Dynamic, and origin traffic has jumped. How do you track down what made it dynamic, and what do you verify before shipping the fix?

level: seniorimportance: should knowfreq 40%

answer

  1. scope it from the build summary first
  2. the shape tells you which layer
  3. make the build name the call
  4. look in node_modules, not your diff
  5. never fix it with force-static

basics

~20 s

Scope it from the build output, then find the request-time read: set export const dynamic = 'error' on the segment so the build fails and names the call, since the culprit is usually cookies() or headers() inside an upgraded shared package rather than in your own files.

solid answer

~50 s

Start with the build output to see exactly which routes flipped — that tells you whether it is one page or everything under a shared layout, which usually identifies the layer. Then make the build tell you the cause instead of guessing: temporarily add `export const dynamic = 'error'` to the segment, and the build fails pointing at the Dynamic API call. After a dependency upgrade the call is almost always inside the package — an analytics, auth or flag SDK that started reading `cookies()` or `headers()` during render — not in your own diff, which is why reading the changelog and bisecting the import graph beats re-reading your pages. Fix it by moving that read out of the render path or out of the shared layout, then verify by rebuilding and confirming the route is Static again, and that the signed-in path still behaves.

go deeper

for a junior

Know where to look first: the build output lists each route as Static or Dynamic, and a route becomes dynamic because something in it read the request.

for a middle

Explain that any module executing during the render counts, including one inside a dependency, and that dynamic = 'error' turns the build into a diagnostic that names the offending call.

for a senior

Drive the investigation from the shape of the regression — one route, one subtree, or the whole app — pick a fix that preserves correctness, and verify with a rebuilt route summary plus the signed-in path, not just the metric.

for a principal

Treat rendering mode as something CI should protect: diff the route classification between builds and guard the load-bearing static routes, so the next upgrade fails a check instead of quietly changing the cost profile.

## Why this is a real incident and not a trivia question Nothing broke. The pages render, the tests pass, and the only signal is a graph: origin requests, function invocations and TTFB all step up on the same deploy. A route that was being served from prerendered output is now executing server work per request. On a high-traffic page this is a cost and latency event, and because the cause is a classification rather than an error, no exception ever gets thrown. ## Step 1 — scope it from the build output `next build` prints a per-route summary marking each route Static or Dynamic. Compare it against the previous build. The *shape* of the change tells you where to look: - One route flipped → the cause is in that page or something only it imports. - Every route under one layout flipped → the cause is in that layout's subtree. - The whole app flipped → the cause is in the root layout, or in a module the root layout imports. This is the cheapest step and it eliminates most of the search space. It is also why keeping the build summary in CI logs, or diffing it between builds, pays for itself. ## Step 2 — make the build name the culprit Rather than reading code, force the framework to answer. Add to the affected segment: ```tsx export const dynamic = 'error' ``` That value forces static rendering and errors out on any Dynamic API or uncached read in the route, identifying the call. This is far faster than bisecting by commenting out subtrees, and it works even when the call is several imports deep inside `node_modules`. If you must bisect manually, do it by import rather than by JSX: remove the suspicious import and rebuild. The offending call runs during render, so it does not have to appear anywhere in your own files. ## Step 3 — expect the cause to be in the dependency Given the framing, the likely culprits, in rough order: 1. **A package that started reading the request.** Analytics, auth, feature-flag and experimentation SDKs commonly add `cookies()` or `headers()` reads to their server helper in a minor version. Read the changelog for exactly that. 2. **A config export that arrived with a template or codemod** — `export const dynamic = 'force-dynamic'` or `export const revalidate = 0` added to a layout. 3. **A change in how a data helper fetches.** Caching defaults for data fetching have moved between Next majors, so a framework upgrade bundled into the same deploy can change classification on its own. Establish whether the Next version moved before blaming the library. The general principle worth stating out loud: rendering mode is decided by what *executes* during the render, so any module in the graph can change it, and the blast radius is the whole route. ## Step 4 — choose a fix that matches the requirement - If the request-time read is not actually needed on that route, drop the call or the import. - If it is needed but only for part of the UI, move it to a Client Component that fetches after hydration, or out of the render entirely into middleware or a route handler. - If it is needed only in one section of the app, split the layout so the static section stops sharing it. - If the library gives no server-safe entry point, isolate it behind a client-only component, or pin the version while you raise it upstream. What to refuse: `export const dynamic = 'force-static'`. It restores the Static label by making `cookies()` and `headers()` return empty values, so the metric goes green while the page silently treats every visitor as anonymous. That is a worse outcome than the regression. ## Step 5 — verify before shipping Three checks, in order: 1. **Rebuild and diff the route summary.** The route is listed Static again, and nothing else moved in the other direction. 2. **Exercise the request-dependent path.** If the code previously read a cookie, confirm signed-in behaviour is still correct — this is what catches an accidental `force-static`-shaped fix. 3. **Watch the deploy.** Origin invocations and TTFB should return to the prior baseline; if they do not, the classification changed but something else in the same deploy did too. ## The follow-through The durable fix is not this one route. It is making the regression detectable: keep the build's route classification in CI output, and put `dynamic = 'error'` permanently on the handful of routes whose static rendering is load-bearing. Then the next dependency upgrade fails the build instead of quietly failing the bill.

  • Why is export const dynamic = 'force-static' the wrong way to make the route Static again?
    Because it does not remove the request-time read, it empties it. `cookies()` and `headers()` return empty values, so the route prerenders and every visitor is rendered as signed out. You have converted a visible cost regression into an invisible correctness bug, and the build stays green either way.
  • How would you make this class of regression fail loudly next time?
    Two cheap guards. Keep the build's per-route Static/Dynamic summary in CI output and diff it between builds so a flip shows up in the pull request. And put `dynamic = 'error'` permanently on the routes whose static rendering is load-bearing, so an accidental request-time read fails the build rather than the budget.
  • The build output shows every route flipped at once. What does that tell you before you read any code?
    That the cause is in the root layout or a module it imports, because that is the only code common to every route. It rules out the pages entirely and usually points at a globally-installed provider or SDK wrapper added around `children`.

saying these in an interview costs you the question

  • Restores the Static label with force-static and calls it fixed
  • Only greps their own diff, never the upgraded dependency
  • Assumes rendering mode is decided per component, so bisects the wrong way
  • Blames the CDN or platform before checking the build output
  • Ships without re-verifying the signed-in path still works

context