The Render Decision Tree — Static, Dynamic, and Streaming31 min read
Module F-9·31 min read
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.
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.
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.
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.
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 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.
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.
Route evaluation starts │ ▼ Does the route import / call any of these? │ ├─── cookies() ──► DYNAMIC ├─── headers() ──► DYNAMIC ├─── searchParams prop │ (accessed) ──► DYNAMIC ├─── connection() ──► DYNAMIC ├─── unstable_noStore() ──► DYNAMIC └─── fetch() with cache:'no-store' or cache:'no-cache'──► DYNAMIC │ ▼ None of the above found │ ▼ Does generateStaticParams() cover all params? ──── No ──► DYNAMIC (or 404) │ Yes ▼ STATIC ──► HTML at build time
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.
// ❌ This entire route is now dynamic import { cookies } from 'next/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.
// ❌ Dynamic because of headers() import { headers } from 'next/headers';
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.
// ❌ Also dynamic — searchParams passed but not used in this component export default async function BlogPage({ searchParams }: PageProps) { const posts = await getPosts(); // no dynamic data return <PostList posts={posts} searchParams={searchParams} />; // ← passing searchParams down opts the ROUTE in, not just the child }
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.
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() 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.
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.
// ❌ Dynamic const data = await fetch('https://api.example.com/live-data', { cache: 'no-store', // ← makes the entire route dynamic });
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).
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.
// ⚠️ Still static! This value is computed once, at build time, // and served identically to every visitor until the next rebuild. export default async function Page() { const id = Math.random().toString(36); return <div>Session: {id}</div>; }
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").
Read this section twice. Then go check your app/layout.tsx. This is the single most consequential rendering mistake you can make in a Next.js application, and it's nearly invisible until it's too late.
Next.js layouts wrap every page under their path segment. The root layout at app/layout.tsx wraps every single page in your entire application. That's not a metaphor — when your root layout renders, it executes for every route. If the root layout makes a dynamic API call, that call propagates to every page the layout wraps.
If app/layout.tsx calls cookies(), headers(), or any other dynamic API, every page in your entire application becomes dynamic. All of them. No exceptions.
Your carefully crafted static blog posts. Dynamic. Your marketing homepage. Dynamic. Your /about page. Dynamic. Your /pricing page. Dynamic. Everything.
Our application had 200 static pages generating fine at build time. The build output was mostly ○ symbols — a fast, CDN-served application. Then a junior developer added const session = await getServerSession() to the root layout to add a "logged in" indicator to the nav.
// app/layout.tsx — the change that broke everything import { getServerSession } from 'next-auth';
export default async function RootLayout({ children }: { children: React.ReactNode }) { const session = await getServerSession(); // ← internally calls cookies() and headers()
The code review looked fine. getServerSession is a normal call. The nav needed to know if the user was logged in. Reasonable.
Three days later we got a Datadog alert that origin request volume had increased 4,000%. Every page, including the marketing homepage, was now dynamic. The CDN hit rate had collapsed from 98% to near zero. Every request was hitting the origin. Every request was running a database query via getServerSession. Our database connection pool was saturated.
The build output had flipped from 200 ○ symbols to 200 ƒ symbols. We hadn't noticed because we didn't look at the build output after that change.
getServerSession (and similar auth libraries) internally calls cookies() and headers() to read the session cookie and parse authorization headers. These calls are inside the library code, not in your layout directly — but they don't need to be in your code to trigger the effect. The dynamic call happens anywhere in the render tree, and the root layout is at the root of every render tree in your application.
The rule is simple: never call dynamic APIs in the root layout. Push auth checks, locale detection, and any user-specific logic to the individual pages or nested layouts that actually need them.
// ✅ Root layout — no dynamic calls, stays static export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html> <body> <Nav /> {/* Static nav — no user data */} {children} </body> </html> ); }
// ✅ Dashboard layout — only wraps routes that need auth // app/dashboard/layout.tsx import { getServerSession } from 'next-auth';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) { const session = await getServerSession(); // Only affects /dashboard/* routes if (!session) redirect('/login'); return <>{children}</>; }
The marketing pages, blog posts, and public routes remain static. Only /dashboard/* routes are dynamic — which is correct, because they require authentication.
If you genuinely need user-specific content in the root layout (a logged-in indicator in the global nav, for example), use <Suspense> to isolate the dynamic part:
// app/layout.tsx — static root layout with dynamic nav slot import { Suspense } from 'react'; import StaticNav from '@/components/StaticNav'; import UserNav from '@/components/UserNav'; // This component calls cookies() internally
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html> <body> {/* StaticNav renders from CDN. UserNav fetches user session per-request. Only the UserNav slot is dynamic — the rest of every page stays static. */} <header> <StaticNav /> <Suspense fallback={<UserNavSkeleton />}> <UserNav /> </Suspense> </header> {children} </body> </html> ); }
With this structure, the page shells are static (served from CDN), and only the UserNav slot is dynamic (streams in per-request). This is the architecture that makes streaming powerful: you don't have to choose between a static page and a personalized page — you can have both.
Note: this requires PPR (Partial Prerendering) to be enabled in Next.js 15 for the full benefit. Without PPR, a Suspense boundary in the root layout may still make the whole route dynamic depending on the Next.js version. The A-4 module covers PPR in depth.
You should be reading the build output on every deploy. It takes 10 seconds to scan and it will catch problems that no test suite will catch. Make it part of your CI pipeline — log it, diff it, alert on unexpected changes.
○ = Static — pre-rendered HTML, served from CDN ƒ = Dynamic — server-rendered per request (`λ` is the older Pages Router-era symbol for the same idea, not used in current App Router build output) ◐ = Partial Prerendering (PPR) — static shell + dynamic Suspense slots
The ● symbol on /blog/[slug] means the route has static params generated — the individual slugs listed below it are pre-rendered. ƒ on the dashboard routes is expected because they require per-user authentication.
You're looking for ƒ on routes you expected to be ○. If you see this:
├ ƒ / 3.2 kB 87.4 kB ← Should be ○ ├ ƒ /about 1.1 kB 85.3 kB ← Should be ○ ├ ƒ /blog 2.8 kB 86.9 kB ← Should be ○ ├ ƒ /pricing 1.8 kB 85.9 kB ← Should be ○
Every page is dynamic. That is the root layout footgun. Go check app/layout.tsx immediately.
If only one unexpected page is dynamic, work backwards through its import tree to find the dynamic API call. The Next.js build output doesn't tell you why a route is dynamic — you have to trace the imports yourself. A useful strategy: add export const dynamic = 'error' to the page temporarily. The build will throw, and the error message will identify which dynamic API is being called.
<Suspense> does two things: it provides a loading fallback UI, and it creates a rendering boundary. The second function is the one that unlocks real architectural power.
When you wrap a dynamic component in <Suspense>, the static shell of the page can be generated at build time while the contents of the Suspense boundary are rendered per-request. The browser receives the static HTML immediately, then the dynamic content streams in as it resolves.
// app/product/[id]/page.tsx // This page is ○ in the build output — static shell, dynamic reviews slot
import { Suspense } from 'react'; import { getProduct } from '@/lib/products'; import ProductDetail from '@/components/ProductDetail'; import CustomerReviews from '@/components/CustomerReviews'; // calls cookies() for user's review
export default async function ProductPage({ params }: PageProps) { const { id } = await params; const product = await getProduct(id); // static — pre-rendered at build time
return ( <div> {/* This renders at build time, served from CDN */} <ProductDetail product={product} />
{/* This renders per-request, streams in ~200ms after the shell */} <Suspense fallback={<ReviewsSkeleton />}> <CustomerReviews productId={id} /> </Suspense> </div> ); }
Without the <Suspense> wrapper, CustomerReviews calling cookies() would make the entire page dynamic. With the wrapper, Next.js can see that the dynamic call is isolated and keep the outer page static.
The build output for this pattern looks like ○ /product/[id] — static — even though part of the page renders dynamically. The static shell is what gets pre-rendered and cached at the CDN. The Suspense slot streams in per-request via chunked transfer.
This is the bridge to Partial Prerendering (PPR), covered in A-4, which formalizes this pattern at the framework level. With PPR enabled, Next.js automatically identifies Suspense boundaries as dynamic holes and optimizes the build accordingly. Understanding this Suspense-as-boundary mental model now means PPR will click immediately when you encounter it.
These are the bugs I've debugged in production applications. All of them have a common shape: a dynamic API call invisible at the point where you'd think to look.
// app/blog/[slug]/page.tsx import { getPageContent } from '@/lib/content';
export default async function BlogPost({ params }) { const { slug } = await params; const content = await getPageContent(slug); // ← three layers from cookies() // This entire page is now dynamic. The build output shows ƒ. return <Article content={content} />; }
Why it's dynamic:getPageContent looks static. getPageContent calling trackPageView looks fine. But trackPageView calls cookies(), and that makes the route dynamic regardless of how deep the call is.
The fix: move analytics tracking out of the render path entirely. Use after() from next/server to run side effects after the response is sent, without affecting rendering mode:
// lib/content.ts import { after } from 'next/server'; // Next.js 15
The fix: determine locale in middleware instead, set it as a cookie or a custom header that's available statically, or use a nested layout that only wraps routes that need locale-specific server rendering.
// middleware.ts — runs before routing, doesn't affect rendering mode import { NextRequest, NextResponse } from 'next/server';
export default async function ProductsPage({ params, searchParams }: PageProps) { const { category } = await params; const products = await getProducts(category); // no search params used here
return ( <div> <ProductGrid products={products} searchParams={searchParams} /> {/* ↑ Passing searchParams here — even if ProductGrid doesn't use it — opts this ROUTE into dynamic because searchParams was accessed in the props */} </div> ); }
Why it's dynamic: accepting searchParams in the props type and destructuring it (even to pass it forward) counts as accessing it. The route is dynamic.
The fix: if you need sort/filter functionality, use a Client Component for the interaction and let URL changes drive re-renders on the client side. Or, only access searchParams in the specific component that actually needs it, and only if that component is wrapped in <Suspense>.
Why it's broken:unstable_cache wraps the function to persist results across requests. But headers() inside the cached function makes the wrapped function dynamic. The cache can't be populated at build time and the whole route goes dynamic. Worse, because the currency comes from headers, the same cache key ['pricing'] would store the wrong currency for subsequent users if the cache did work.
The fix: extract the dynamic value, pass it as a parameter, and include it in the cache key:
// ✅ Correct pattern import { unstable_cache } from 'next/cache';
// In the component that has access to request context: export async function CurrencyPricing() { const headerStore = await headers(); // ← outside the cache boundary const currency = headerStore.get('x-user-currency') ?? 'USD'; const pricing = await getPricingByCurrency(currency); return <PricingTable pricing={pricing} />; }
The dynamic call happens outside the cache boundary. The cache stores results per-currency. The route is still dynamic (because of the headers() call), but the cache functions correctly.
// lib/current-user.ts import { cookies } from 'next/headers';
// Every module that imports this pulls in a Dynamic API call — // even a page that never calls cookies() itself export async function getCurrentUser() { const sessionCookie = (await cookies()).get('session'); if (!sessionCookie) return null; return decodeSession(sessionCookie.value); }
// Any page that imports getCurrentUser import { getCurrentUser } from '@/lib/current-user';
export default async function AnyPage() { const user = await getCurrentUser(); // ← pulls in cookies() transitively return <Dashboard user={user} />; }
Why it's dynamic:cookies() is one of the documented Dynamic APIs, and calling it anywhere in a route's render path — including inside a shared helper several imports deep — opts the whole route into dynamic rendering. This is especially sneaky because the page itself never visibly calls cookies(), headers(), or any other Dynamic API; it's buried in a dependency.
The fix: if a given page genuinely doesn't need per-user data, don't route it through a helper that unconditionally reads cookies — pass the already-resolved user down as a prop from somewhere that's already dynamic for a legitimate reason, or split the helper so the cookie-reading part is only imported by routes that actually need it.
When Next.js gets the rendering mode wrong — or when you want to be explicit for future readers — you can override the decision with route segment config exports.
Force the route to be static — but not by throwing an error. If a Dynamic API is called anyway, Next.js doesn't fail the build; it makes cookies(), headers(), and searchParamssilently return empty values so the route can still render statically. That's a real footgun if you're not expecting it: code that reads searchParams under force-static will just always see an empty object, with no warning.
// app/about/page.tsx export const dynamic = 'force-static'; // ← forces static; dynamic APIs go silently empty, no error
Use this when you're confident a route should never depend on per-request data and you're fine with dynamic APIs silently no-oping if one sneaks in. If you actually want a build failure the moment someone introduces a dynamic call — a proper guardrail, not a silent no-op — use export const dynamic = 'error' instead (see below).
When to use it: any marketing page, any static content page, any route where dynamic rendering would be a regression and you don't need a hard build-time guarantee.
This is the actual build-time guardrail: if any Dynamic API is called anywhere in the route's render path, the build throws an error naming the offending call, instead of silently changing behavior. Use this specifically when you want the build itself to catch a teammate accidentally introducing a dynamic call on a route that must stay static.
Use this when correctness requires freshness — admin pages, audit logs, real-time dashboards — and you want to make the intent explicit rather than relying on an accidental dynamic call to enforce it. Also useful during debugging: if a page should be dynamic but you're not sure which API call is making it so, force-dynamic guarantees it while you investigate.
When to use it: routes where stale data is harmful, routes you know will always be user-specific, routes under access control.
Sets ISR with a zero TTL. Effectively dynamic — Next.js generates fresh HTML on every request — but with the ISR infrastructure wrapper. The distinction from force-dynamic: the CDN can serve stale content while the revalidation happens in the background (stale-while-revalidate semantics), whereas force-dynamic bypasses the CDN entirely.
export const revalidate = 0; // revalidate on every request
When to use it: data that needs to be as fresh as possible but where serving momentarily stale content while regenerating is acceptable. Stock prices are a good example — a few seconds of staleness is fine; you're not building a high-frequency trading platform.
The operational difference from force-dynamic: with revalidate = 0, the CDN may still serve a cached response while a background revalidation is in progress. With force-dynamic, every request hits the origin.
ISR with a one-hour TTL. The page is static for one hour after it's last generated. After the TTL expires, the next request triggers a background regeneration, and that user (plus the next user while regeneration runs) gets the previous version.
export const revalidate = 3600; // regenerate at most once per hour
When to use it: pages where content changes but not frequently, and where serving content up to N minutes stale is acceptable. Blog indexes, product listings, documentation pages, pricing tables updated by hand.
The trade-off: ISR means your CDN always has something to serve. Even if your database is down, the last generated version serves. But it means users might see data that's up to N seconds/minutes/hours old. For content that must be immediately consistent after a mutation, combine ISR with revalidateTag() in the mutation path.
The rendering mode decision is not a technical detail — it's an architectural constraint that should be resolved before you write the first line of a feature. Get it wrong and you'll get it right at 3am under production load.
The practical decision framework is short:
Same content for all users, doesn't change often → static. Generate at build time, serve from CDN, no origin involvement. Defend it with export const dynamic = 'force-static'.
Personalized content — user-specific data, session-dependent → dynamic with caching at the data layer. Fetch from cache when possible (unstable_cache), run queries against the database only on cache misses. Accept that every request hits the origin, but make sure those origin hits are fast.
Mostly static with one or two personalized components → streaming with Suspense. Serve the shell from the CDN, stream the personalized slots per-request. This is the architecture that makes the App Router's rendering model worth understanding — it unlocks pages that are both fast and fresh.
Must show real-time data — live metrics, financial data, operational dashboards → force-dynamic or connection(). No caching, no CDN, every request hits the origin. Accept the infrastructure cost; this is the right trade-off for genuinely real-time requirements.
Now that you have this model, the Practitioner phase makes sense from first principles. P-1 is about building a data access layer that maximizes cache hit rates for dynamic routes — because once you accept that some routes must be dynamic, the question shifts to minimizing the cost of each dynamic render. P-2 through P-6 cover Server Actions, optimistic UI, auth, database integration, and middleware — all of which interact with the rendering model you've just learned. P-7 covers the use cache directive, which is Next.js 15's evolution of unstable_cache and integrates caching directly into the component tree. And the Advanced modules — particularly A-4 on Partial Prerendering — formalize the Suspense-as-boundary pattern into a first-class framework feature. Everything in those modules lands harder when you understand why: because controlling the rendering decision is how you build applications that are both correct and fast.
Knowledge Check
A page never calls cookies(), headers(), or any other Dynamic API directly, but it imports a shared helper function that does call cookies() internally. Why does this make the page dynamic?
What is the primary difference between export const dynamic = 'force-dynamic' and export const revalidate = 0?
If you want to ensure a marketing page remains static and throw a build error if any dynamic API is accidentally introduced, which configuration should you use?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.