The Router Decides for You: Every Implicit Switch to Dynamic
The App Router's default policy is optimistic: prerender unless something in the route says it cannot. That policy has a precise trigger list, and knowing it is the difference between predicting your build output and being surprised by it. Reading any request-time API anywhere in a route's tree switches that route to dynamic rendering, with no opt-in syntax required.
The full list of implicit triggers:
| API | Source | Why it forces per-request rendering |
|---|---|---|
cookies() |
next/headers |
values are per-user; no build-time answer exists |
headers() |
next/headers |
same — User-Agent, authorization, locale all vary per request |
awaited searchParams |
page props | the route's output depends on the query string |
fetch(..., { cache: "no-store" }) |
data layer | explicitly uncached data cannot be baked into a build artifact |
fetch(..., { next: { revalidate: 0 } }) |
data layer | the same opt-out, spelled per-fetch |
connection() |
next/server |
explicit "this needs the live connection" signal for code that reads none of the above |
The propagation rule is where the surprises live: an implicit trigger in a layout makes every page under that layout dynamic, because the layout's markup is part of each page's response and must be rebuilt per request. A trigger in a page affects only that page; a trigger deep in a leaf component affects only routes that render that component. The auth-layout pattern is the canonical accident — reading the session cookie in a root or group layout to render the avatar dynamizes the entire authenticated section, marketing pages included. Splitting the session-dependent UI into a client component, or accepting dynamic rendering as a deliberate choice, are the two honest fixes.
Each trigger has a wrong fix people reach for under interview pressure. Removing await cookies() from the page and moving the session lookup into a useEffect client fetch "re-statics" the route but reintroduces the waterfall the App Router deleted — a round trip for data that was already on the server. The correct framing: the router dynamizes because correctness demands it. A cached page showing another user's data is a security incident; the framework chooses per-request rendering every time the output could vary, and your job is to confirm that choice is what you wanted, not to defeat it with clever indirection.
Verification is mechanical. Run next build and read the route table: ƒ marks the dynamic routes. If a route you expected to be ○ shows ƒ, grep its tree for the triggers above — the layout cookie read, the one no-store fetch buried in a shared data helper. There is no runtime log that explains the decision after the fact; the build table plus a search for the request APIs is the complete debug loop. Note the version wrinkle: Next 14's fetch was cache-by-default, so an uncached fetch had to be requested explicitly, while Next 15 made fetch uncached by default — the same codebase can therefore land on opposite sides of the static/dynamic line across a version bump, which is why the build table, not memory, is ground truth. Pages built on the Pages Router reached the same place through getServerSideProps; in the App Router the decision is per segment, not per page, which is what the rest of this topic and the next one are about.
Saying It Out Loud: the Segment Config Values and What Each One Costs
Implicit dynamization protects correctness. The segment config exists for the opposite case: you know the route's regime and want to declare it, either to force dynamic rendering the API scan cannot infer, or to forbid it entirely. The export:
export const dynamic = "force-dynamic";
Every value, and the build consequence of each:
| Value | Behavior | Use when |
|---|---|---|
"auto" (default) |
dynamic if any trigger fires, static otherwise | you trust the implicit policy |
"force-dynamic" |
always render per request, triggers or not | output depends on non-request state the router cannot see (a direct Prisma query reading now(), an on-disk config re-read per request) |
"force-static" |
force prerendering; request APIs resolve to empty at build | the route uses cookies()/searchParams syntactically but the prerendered shell is valid for everyone |
"error" |
fail the build if any dynamic trigger fires | guarding a route that must never silently dynamize |
"force-dynamic" is the blunt instrument, and its real value appears when data access bypasses fetch. A page querying Postgres through Drizzle touches no request API and no fetch cache — the router sees nothing and would happily prerender it at build with whatever the database said at 2 am. For a live-seat-availability page that is wrong in a way no user would report as a rendering bug; force-dynamic states the intent the type system cannot. The cost is the full per-request ledger from Server Rendering per Request: every visit pays fetch + render, nothing serves from the static cache, and the origin is on the critical path for every request.
"error" deserves more use than it gets. Add it to routes whose static guarantee is load-bearing — the build-time-rendered legal pages, the sitemap-driven index — and any future teammate's innocent headers() call fails the build with a message naming the route, instead of shipping a silently dynamic page that doubles the server bill. It converts a runtime-economic regression into a build-time compile error, which is the cheapest possible place to catch it.
The data layer has its own, independent switch — fetchCache — and the two knobs compose:
export const dynamic = "force-dynamic"; // route renders per request
export const fetchCache = "force-no-store"; // AND every fetch in this segment skips the data cache
fetchCache value |
Effect on fetches in the segment |
|---|---|
"auto" (default) |
follow each fetch's own cache option |
"default-cache" |
use Next's default caching, ignoring per-fetch hints |
"only-cache" |
every fetch must be cacheable; no-store fails the build |
"force-cache" |
every fetch caches, ignoring no-store |
"force-no-store" |
no fetch caches, ignoring revalidate |
This split matters because the two layers answer different questions: dynamic controls when the HTML is built, fetchCache controls whether the data is reused. A route can prerender its shell while its fetches revalidate every 60 seconds, or render per request while happily reusing a five-minute data cache (per-request render with cached data is a legitimate "fresh shell, tolerant data" design). Inherited like all segment config, a child overriding its parent — which is what makes mixed trees possible.
One caution on force-static before interviews: it does not conjure per-user data. cookies() inside a force-static segment returns empty at build time, and the prerendered page goes out to everyone as-is. It is correct for a marketing page whose code happens to share a layout that reads cookies; it is a data-leak-shaped bug the moment real session values are expected to appear in the static output.
Mixed Trees: One Application, Three Regimes, Verified at Build Time
The mental model to outgrow: "this app is static" or "this app is dynamic." The App Router renders segments, and a single deployment routinely contains all the regimes at once. A typical e-commerce site, by segment:
app/
layout.tsx ○ static shell (fonts, providers, nav)
page.tsx ○ home, ISR-revalidated hourly
product/[slug]/
page.tsx ● prerendered for known products, on-demand otherwise
availability/route.tsx ƒ reads seat table per request
account/
layout.tsx ƒ reads session cookie → dynamizes everything below
orders/page.tsx ƒ per-user data
search/page.tsx ƒ reads searchParams
The build output is the map, and it changes as the tree changes — which is the operational discipline: after any structural edit, read the route table and check that each segment's regime is still the one you chose. A teammate adding a personalized banner to the root layout converts the whole site from ○/● to ƒ in one commit, with no error and no warning beyond the flags.
The mechanism that makes mixing work is that the dynamic opt-in and the segment config both apply per segment, and children can override inherited values. A layout may be dynamic while a page beneath it opts back to force-static; a parent's revalidate can be overridden by a child's own export. This is also where the router's composition model meets caching: a URL renders as its page inside every ancestor layout, so the composed response is dynamic if any participating segment is — a static page under a dynamic layout ships as a dynamic response. The one-directional rule to memorize: dynamic wins in composition; static requires every participating segment to consent.
What a dynamic response looks like on the wire is worth checking once, because it bounds what your infrastructure can do. Dynamic HTML is delivered with cache-control headers that keep CDNs and browsers from serving it as a shared cached artifact (exact values shift across releases — verify on your version with devtools), and it bypasses the static manifest entirely: no file, no CDN hit, no ISR. Your origin renders it, every time. If you need per-user output and CDN-scale serving, the lever is not a caching header but a decomposition: ship a static shell and fill the per-user parts client-side or via streamed dynamic boundaries — precisely the design Streaming with Suspense Boundaries formalizes.
Partial Prerendering is the framework's name for making that decomposition first-class: a static shell with holes that dynamic content streams into, served as one route. Depending on your Next.js release it is experimental or preview-flagged (ppr), but the interview question it answers is timeless and now you can answer it two ways: (1) without PPR, by nesting a Suspense boundary around the dynamic part so the static shell flushes first and the hole fills per request; (2) with PPR, by declaring the shell static and the holes dynamic in one route. Both rest on the same segment-scoped regime model from this topic.
The complete decision flow for a new route, in the order you should actually run it: does the output vary per user or per query? → dynamic (implicit, by reading the API that makes it vary). Does it vary only by data the router cannot see? → force-dynamic. Is it visitor-identical and refreshable in the background? → SSG/ISR with revalidate. Is it visitor-identical and frozen until deploy? → pure static, and consider dynamic = "error" to keep it that way. Four questions, one route table to verify the answers — that is the entire skill this phase exists to build.
Quiz
1. A page is fully static, but its parent layout calls headers() to read the Accept-Language header. What regime does the page ship as?
2. A live seat-availability page queries the database via Drizzle (no fetch API, no cookies/headers) and the build prerenders it. What is the correct fix?
3. What does export const dynamic = 'error' do?
4. A route exports dynamic = 'force-dynamic' and fetchCache = 'force-no-store'. What is the combined behavior?
Flashcards
Question
The implicit dynamic-rendering triggers in the App Router.
Click to reveal answer
Answer
cookies(), headers(), awaited searchParams, fetch with cache: 'no-store' or next: { revalidate: 0 }, and connection() — anywhere in the route's tree. A trigger in a layout dynamizes every page beneath it; the build route table (ƒ) is how you verify the outcome.
Question
export const dynamic — each value's behavior.
Click to reveal answer
Answer
'auto' (default): dynamic only if a trigger fires. 'force-dynamic': always per-request (for non-fetch data the router cannot see). 'force-static': prerender; request APIs resolve empty at build (never for real per-user data). 'error': build fails if any dynamic trigger fires — a guard for load-bearing static routes.
Question
dynamic vs fetchCache — which layer does each control?
Click to reveal answer
Answer
dynamic controls when the route's HTML is built (build/cached vs per request). fetchCache controls whether fetches in the segment use the data cache ('force-no-store', 'force-cache', 'only-cache', 'default-cache', 'auto'). A prerendered shell with 60 s data revalidation is a legal, deliberate mix.
Question
The composition rule for rendering regimes in one sentence.
Click to reveal answer
Answer
Dynamic wins in composition: a URL's response is dynamic if the page OR any ancestor layout is dynamic, while static requires every participating segment's consent — which is why one cookie read in a shared layout silently dynamizes an entire section.
Question
How do static and dynamic content coexist in one route?
Click to reveal answer
Answer
By segment: nest the dynamic part inside a Suspense boundary so the static shell flushes first and the hole fills per request (the manual design), or declare shell/holes via Partial Prerendering where available. Verify each segment's regime in the next build route table after every structural change.
Revision Notes
Key Takeaways
- 1. Request APIs opt routes into dynamic rendering automatically; the opt-in propagates from any segment up through composition
- 2. force-dynamic covers data access the implicit scan cannot see; fetchCache controls the data cache independently of when HTML is built
- 3. dynamic = 'error' converts a silent economic regression into a build failure — the cheapest guard for load-bearing static routes
- 4. One deployment contains many regimes; the build route table after every structural change is your verification step
- 5. force-static makes request APIs return empty at build — safe for shared-layout syntax, a data bug the moment real session values are expected
Interview Tips
- • "How does Next.js decide to render dynamically?" — name the trigger list and the propagation rule (layout ⇒ subtree), then say how you verify it (route table), and add the non-fetch blind spot that needs force-dynamic
- • "Our static route became slow and shows ƒ" — walk the debug loop: a request API crept into the page or a shared layout; the fix is relocating it to a leaf or accepting dynamic with the capacity math from the SSR topic
- • "force-dynamic vs no-store — same thing?" — no: one controls HTML build timing, the other the fetch data cache; a route can be per-request with cached data or prerendered with fresh data
- • "How would you keep a page mostly static but personalized?" — static shell + Suspense-wrapped dynamic island (client or streamed server), never a layout-level cookie read; mention PPR as the framework's formalized version of that design
Cheat Sheet
Forcing Dynamic Rendering
Implicit (no syntax needed): cookies() · headers() · awaited searchParams · fetch(..., { cache: "no-store" }) / next: { revalidate: 0 } · connection(). Applies per segment; inherits upward through composition; a layout trigger dynamizes its whole subtree.
Explicit segment config:
export const dynamic = "force-dynamic"; // per-request, triggers or not
export const fetchCache = "force-no-store"; // data layer: never reuse fetches
export const revalidate = false; // static forever (overrides inheritance)
dynamic |
Meaning |
|---|---|
"auto" |
default: dynamic iff a trigger fires |
"force-dynamic" |
always request-render — covers non-fetch data (direct ORM queries) the scan cannot see |
"force-static" |
prerender; request APIs return empty at build — never for per-user data |
"error" |
build fails on any dynamic trigger — guard load-bearing static routes |
Layers are independent: dynamic = when HTML builds · fetchCache/fetch revalidate = data reuse · CDN/edge TTL = outer serving cache. Verify with the build table: ○ static, ƒ dynamic, ● partial. Next 14 fetch was cache-by-default; Next 15 is not — trust the table, not memory.
Mixed trees: dynamic wins in composition; static needs consent from every segment. Keep the shell static and isolate dynamic parts behind Suspense (or PPR holes) so one slow, per-user fetch does not dynamize or block the page.
Debug loop: unexpected ƒ → grep the tree for triggers (usually a layout cookie read or a shared helper's no-store fetch) → move it to a leaf, accept dynamic, or force-static with open eyes.