The complete decision flowchart: every API that forces dynamic rendering (cookies, headers, searchParams, noStore, connection), the root layout footgun that opts your entire app out of static, and how to read next build output to verify render mode before you ship.
F-9 — The Rendering Decision: Why Next.js Chooses Static, Dynamic, or Streaming for Every Route
Who this is for: Developers who completed F-1 through F-8 and can build a working Next.js application. This module is the missing mental model. It answers the question you will eventually get paged about at 3am: why is this page that was static yesterday suddenly hammering the database on every request? Read this before you write another line of production code.
What Next.js Actually Decides at Build Time
Before a single user hits your application, Next.js runs next build and makes a rendering decision for every route in your app/ directory. It's not a simple binary. There are three modes, and the difference between them is measured in infrastructure dollars, p99 latency, and the number of Datadog alerts you'll receive on a Sunday.
Static
The route is rendered to HTML exactly once — at build time — and that HTML is committed to disk. The build output becomes a collection of .html files, .json files for client navigation, and the associated JavaScript chunks. When a user requests the route, the response comes from a CDN or a static file server. Your origin server is not involved. Your database is not involved. The request never touches your application code.
This is the mode you want for as many routes as possible. Not because of ideology, but because of operational reality. A static route has:
- Zero origin hits per user request
- p99 latency measured in single-digit milliseconds (CDN edge)
- No database connection pool pressure
- No Node.js process involved in serving the request
- Cost that doesn't scale with traffic
If a route serves the same content to every user and that content doesn't change second-to-second, it should be static.
Dynamic
The route is rendered per-request at the origin server. Every user who hits the route causes Next.js to spin up a Server Component render, execute your data fetching code, and produce fresh HTML. The origin server processes every request. Your database query runs every time.
Dynamic rendering is not inherently bad — it's the right mode when content is personalized (user-specific dashboards), real-time (live stock prices), or sensitive (admin panels with row-level access control). But it's very bad when it's accidental. When a route you expected to be static is silently dynamic, you've traded CDN cache hits for full origin renders without getting any of the benefits that justify dynamic rendering.
The operational cost: every user request is now a full render cycle. Connection pool hits per second equals active users per second. Scale your traffic by 10x and your database queries scale by 10x.
Streaming
Streaming is the nuanced middle ground, and it's what makes the App Router genuinely powerful when you use it correctly. A streaming route has two parts:
The static shell — everything above and around your <Suspense> boundaries — is generated at build time and served from the CDN. The user sees the shell immediately. No origin involvement for the outer structure.
The dynamic Suspense slots — components wrapped in <Suspense> — are rendered per-request. As each slot resolves, its HTML is flushed to the client via chunked transfer encoding. The browser paints each slot as it arrives.
The result is a page that feels instant (the shell loads from the CDN) but contains fresh data (the slots arrive dynamically). The origin only processes the dynamic slots, not the full page. A page with a static layout and three data-heavy Suspense slots serves the layout from CDN and runs three server renders — but those three renders stream independently and the user sees content as fast as each resolves, not as slow as the slowest one.
This is the architecture you want for pages that are mostly static but have user-specific or real-time components. The marketing header and footer come from the CDN. The personalized recommendation widget streams in 200ms later.
The Static/Dynamic Decision Flowchart
Next.js determines rendering mode at build time by statically analyzing your route segment and every module it imports. The question it's asking is: "does this route touch anything that is inherently request-specific?"
If the answer is yes at any point in the call chain, the entire route becomes dynamic. Not just the component that made the dynamic call — the entire route.
Let's go through each trigger:
cookies()
Calling cookies() from next/headers in any Server Component — or in any function that a Server Component calls — opts the entire route into dynamic rendering. The rationale is correct: cookies are per-request. You can't pre-render a page whose content depends on which user's cookies are present because at build time there are no users.
The product data is static. The theme preference is dynamic. Because they're in the same render, the whole route is dynamic.
headers()
Same behavior as cookies(). Calling headers() from next/headers makes the route dynamic. Common culprits: reading x-forwarded-for for geo-targeting, reading accept-language for locale detection, reading authorization headers directly rather than through a session library.
searchParams prop
The searchParams prop on a page component contains the query string of the current URL. Because the query string is request-specific, accessing searchParams in a page opts it into dynamic rendering. This one catches people out because the effect is subtler than the others.
Important: search pages should be dynamic. The problem is when searchParams is passed to a page that doesn't meaningfully use it, or when it's passed down to child components that don't use it either — the page is dynamic for no reason.
The rule: if you accept searchParams in a page's props signature and access it (including passing it to a child), the route is dynamic.
unstable_noStore()
This is an explicit opt-out of caching and static rendering. There is no stable, un-prefixed noStore() export — unstable_noStore (still carrying the unstable_ prefix) remains the function name. Calling it in a Server Component signals to Next.js: "do not cache this, do not pre-render this."
connection() (below) is the documented modern replacement for reaching for this — reach for unstable_noStore() mainly in existing code that already uses it.
connection()
connection() from next/server is the intentional, readable way to say "this route is dynamic and I mean it." It's the preferred API in Next.js 15+ over the legacy unstable_noStore hack. Calling await connection() blocks until the request connection is available and permanently opts the route into dynamic rendering.
Before connection() existed, developers would call headers() or cookies() as a side effect specifically to trigger dynamic rendering — a code smell that confused future readers. connection() makes the intent self-documenting.
fetch() with cache: 'no-store' or cache: 'no-cache'
A fetch() call with the cache option set to 'no-store' or 'no-cache' opts the route into dynamic rendering. The logic: if the data can't be cached, it must be fetched on every request, so the route must render on every request.
If you need to fetch uncached external data but want the rest of the page to be static, move the uncached fetch into a Suspense-wrapped component (covered below).
The Math.random() / Date.now() trap — and why it's the opposite of what you'd expect
Next.js's static/dynamic decision is based on a fixed list of Dynamic APIs — the ones in the flowchart above. It does not scan your code for generic non-determinism like Math.random() or Date.now(). That means calling either of these in an otherwise-static Server Component does not make the route dynamic — the route stays static, and the "random" or "current time" value gets baked into the HTML at build time and then never changes again on subsequent requests, until the next rebuild/revalidation.
This is the more dangerous failure mode of the two: a route that silently becomes dynamic at least behaves correctly (just slower). A route that silently stays static while you assumed it was computing something fresh per request serves stale, wrong-looking data to everyone — and nothing in the build output flags it, since as far as Next.js is concerned nothing dynamic happened. If you need a fresh random value or timestamp on every request, you need one of the actual Dynamic APIs above (connection() is the direct way to say "render this per request").
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion