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.
Route evaluation starts
│
▼
Does the route import / call any of these?
│
├─── cookies() ──► DYNAMIC
├─── headers() ──► DYNAMIC
├─── searchParams prop
│ (accessed) ──► DYNAMIC
├─── connection() ──► DYNAMIC
├─── noStore() /
│ unstable_noStore()──► DYNAMIC
├─── fetch() with
│ cache:'no-store'
│ or cache:'no-cache'──► DYNAMIC
├─── Math.random() ──► DYNAMIC
└─── Date.now() ──► DYNAMIC
│
▼
None of the above found
│
▼
Does generateStaticParams() cover all params? ──── No ──► DYNAMIC (or 404)
│
Yes
▼
STATIC ──► HTML at build time
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.
tsx// ❌ This entire route is now dynamic import { cookies } from 'next/headers'; async function getThemePreference() { const cookieStore = await cookies(); return cookieStore.get('theme')?.value ?? 'dark'; } export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const product = await getProduct(id); // static data const theme = await getThemePreference(); // this one call makes the whole route dynamic return <Product product={product} theme={theme} />; }
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.
tsx// ❌ Dynamic because of headers() import { headers } from 'next/headers'; export default async function HomePage() { const headerStore = await headers(); const country = headerStore.get('x-vercel-ip-country') ?? 'US'; const content = await getLocalizedContent(country); return <HeroSection content={content} />; }
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.
tsx// ❌ Dynamic — searchParams is accessed interface PageProps { searchParams: Promise<{ q?: string; page?: string }>; } export default async function SearchPage({ searchParams }: PageProps) { const { q, page } = await searchParams; const results = await search(q, Number(page ?? 1)); return <SearchResults results={results} />; }
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.
tsx// ❌ 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.
noStore() / unstable_noStore()
These are explicit opt-outs of caching and static rendering. unstable_noStore was the original API (deprecated in Next.js 15); noStore is its replacement. Calling either in a Server Component signals to Next.js: "do not cache this, do not pre-render this."
tsximport { noStore } from 'next/cache'; // Next.js 15+ // import { unstable_noStore } from 'next/cache'; // deprecated export async function getLivePrice(ticker: string) { noStore(); // ← explicit dynamic opt-in const response = await fetch(`https://api.prices.com/${ticker}`); return response.json(); }
Use these when you have data that genuinely must be fresh on every request but don't want to call connection() (which has slightly different semantics).
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.
tsximport { connection } from 'next/server'; export default async function LiveDashboard() { await connection(); // clear, readable, intentional const metrics = await getLiveMetrics(); return <Dashboard metrics={metrics} />; }
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.
tsx// ❌ 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).
Math.random() and Date.now()
Non-deterministic values at render time make static generation impossible — you can't generate static HTML for output that's different every time. Calling Math.random() or Date.now() (or new Date()) in a Server Component makes the route dynamic.
tsx// ❌ Dynamic — non-deterministic value export default async function Page() { const id = Math.random().toString(36); // ← dynamic return <div>Session: {id}</div>; }
This is most commonly triggered accidentally by ORMs or library code that generates timestamps or random IDs at module evaluation time.
The Root Layout Footgun That Wrecked Our Entire App
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.
How Layout Inheritance Works
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.
The War Story
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.
tsx// 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() return ( <html> <body> <Nav user={session?.user} /> {children} </body> </html> ); }
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.
Why This Happens
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 Fix: Push Dynamic Calls Down
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.
tsx// ✅ 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> ); }
tsx// ✅ 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.
The Suspense Escape Hatch
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:
tsx// 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.
Reading the next build Output
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.
The Symbols
○ = Static — pre-rendered HTML, served from CDN
ƒ = Dynamic — server-rendered per request (some versions show λ)
◐ = Partial Prerendering (PPR) — static shell + dynamic Suspense slots
A Real Build Output, Annotated
Route (app) Size First Load JS
┌ ○ / 3.2 kB 87.4 kB ✓ Homepage static
├ ○ /about 1.1 kB 85.3 kB ✓ Static
├ ○ /blog 2.8 kB 86.9 kB ✓ Blog index static
├ ● /blog/[slug] 1.4 kB 85.6 kB ✓ Static params
│ └ /blog/getting-started ✓ Pre-rendered
│ └ /blog/advanced-patterns ✓ Pre-rendered
│ └ /blog/deployment-guide ✓ Pre-rendered
├ ƒ /dashboard 4.2 kB 88.1 kB ⚠ Dynamic (expected)
├ ƒ /dashboard/settings 2.1 kB 86.2 kB ⚠ Dynamic (expected)
├ ○ /pricing 1.8 kB 85.9 kB ✓ Static
└ ○ /contact 1.2 kB 85.4 kB ✓ Static
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.
Spotting Accidental Dynamic Routes
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 = 'force-static' to the page temporarily. If the build throws an error, the error message will identify which dynamic API is being called.
The Partial Rendering Escape Hatch — Suspense as a Rendering Boundary
<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.
tsx// 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 interface PageProps { params: Promise<{ id: string }>; } export async function generateStaticParams() { const products = await getAllProducts(); return products.map(p => ({ id: p.id })); } 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.
The Five Most Common Accidental Dynamic Routes
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.
1. cookies() buried three layers deep in a shared utility
tsx// lib/analytics.ts import { cookies } from 'next/headers'; export async function trackPageView(page: string) { const cookieStore = await cookies(); const sessionId = cookieStore.get('session_id')?.value; // ← dynamic call await db.pageViews.create({ data: { page, sessionId } }); }
tsx// lib/content.ts import { trackPageView } from './analytics'; export async function getPageContent(slug: string) { const content = await db.content.findUnique({ where: { slug } }); await trackPageView(slug); // ← calls cookies() internally — invisible here return content; }
tsx// 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:
tsx// lib/content.ts import { after } from 'next/server'; // Next.js 15 export async function getPageContent(slug: string) { const content = await db.content.findUnique({ where: { slug } }); after(async () => { // Runs after the response, doesn't affect rendering mode await trackPageView(slug); }); return content; }
2. headers() in the root layout for session reading
This is the war story from earlier, formalized as a pattern. The specific shape:
tsx// app/layout.tsx import { headers } from 'next/headers'; export default async function RootLayout({ children }) { const headerStore = await headers(); const locale = headerStore.get('accept-language')?.split(',')[0] ?? 'en'; // ← ALL 200 PAGES ARE NOW DYNAMIC return ( <html lang={locale}> <body>{children}</body> </html> ); }
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.
tsx// middleware.ts — runs before routing, doesn't affect rendering mode import { NextRequest, NextResponse } from 'next/server'; export function middleware(request: NextRequest) { const locale = request.headers.get('accept-language')?.split(',')[0] ?? 'en'; const response = NextResponse.next(); response.cookies.set('locale', locale); return response; }
3. Passing searchParams to a child that doesn't use it in the parent
tsx// app/products/page.tsx // ❌ Dynamic even though THIS component doesn't use searchParams interface PageProps { params: Promise<{ category: string }>; searchParams: Promise<{ sort?: string }>; } 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>.
tsx// ✅ Static page with dynamic search slot export default async function ProductsPage({ params }: { params: Promise<{ category: string }> }) { const { category } = await params; const products = await getProducts(category); return ( <div> <Suspense fallback={<ProductGridSkeleton />}> <SortableProductGrid products={products} /> {/* SortableProductGrid is a Client Component that reads useSearchParams() */} </Suspense> </div> ); }
4. unstable_cache wrapping a function that internally calls headers()
This one is subtle and it destroys the cache.
tsx// lib/pricing.ts import { unstable_cache } from 'next/cache'; import { headers } from 'next/headers'; export const getCachedPricing = unstable_cache( async () => { const headerStore = await headers(); // ← INSIDE the cached function const currency = headerStore.get('x-user-currency') ?? 'USD'; return db.pricing.findMany({ where: { currency } }); }, ['pricing'], { revalidate: 3600 } );
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:
tsx// ✅ Correct pattern import { unstable_cache } from 'next/cache'; const getPricingByCurrency = unstable_cache( async (currency: string) => { return db.pricing.findMany({ where: { currency } }); }, ['pricing'], { revalidate: 3600, tags: ['pricing'] } ); // 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.
5. An ORM calling Date.now() at module evaluation time
tsx// lib/db.ts import { drizzle } from 'drizzle-orm/...'; import * as schema from './schema'; // This runs when the module is first imported const queryLogger = { startedAt: Date.now(), // ← non-deterministic at module level log: (query: string) => console.log(`[${Date.now() - queryLogger.startedAt}ms] ${query}`), }; export const db = drizzle(pool, { schema, logger: queryLogger });
tsx// Any page that imports from lib/db.ts import { db } from '@/lib/db'; // ← pulls in queryLogger with Date.now() export default async function AnyPage() { const data = await db.select().from(schema.posts); // This page is dynamic because Date.now() runs at module evaluation return <Posts posts={data} />; }
Why it's dynamic: Date.now() in module-level code runs when the module is first evaluated, which happens at render time. Next.js detects this as a non-deterministic value and opts the route into dynamic rendering. This is especially sneaky because you're not calling Date.now() in your page — it's in a dependency's initialization code.
The fix: move the timing call inside the function that uses it, or lazily initialize the logger:
tsx// ✅ Date.now() inside the function, not at module level const queryLogger = { log: (query: string) => { const start = Date.now(); // captured per-call return () => console.log(`[${Date.now() - start}ms] ${query}`); }, };
If it's a third-party ORM you can't modify, use next.config.ts to externalize the module from the bundle analysis, or file an issue.
Forcing Static or Dynamic Explicitly
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.
export const dynamic = 'force-static'
Force the route to be static. If any dynamic API is called, the build throws an error.
tsx// app/about/page.tsx export const dynamic = 'force-static'; // ← throw if anything is dynamic export default async function AboutPage() { const content = await getAboutContent(); return <About content={content} />; }
Use this as a safety net on pages that should never be dynamic. The build error is your sentinel — it alerts you the moment someone adds a dynamic call. Preferred over relying on build output inspection.
When to use it: any marketing page, any static content page, any route where dynamic rendering would be a regression.
export const dynamic = 'force-dynamic'
Force the route to always be dynamic, regardless of whether any dynamic APIs are called.
tsx// app/admin/audit-log/page.tsx export const dynamic = 'force-dynamic'; // ← always fresh export default async function AuditLogPage() { const logs = await getRecentLogs(); return <AuditLog logs={logs} />; }
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.
export const revalidate = 0
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.
tsxexport const revalidate = 0; // revalidate on every request export default async function StockPage({ params }) { const { ticker } = await params; const price = await getStockPrice(ticker); return <StockCard price={price} />; }
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.
export const revalidate = 3600
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.
tsxexport const revalidate = 3600; // regenerate at most once per hour export default async function BlogIndexPage() { const posts = await getPublishedPosts(); return <BlogIndex posts={posts} />; }
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.
What This Means for Your Architecture
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-5 cover mutations, auth patterns, and middleware — all of which interact with the rendering model you've just learned. P-6 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.