#Static and dynamic rendering
AddedRendering a route means running its Server Components to produce two things: the HTML for a first visit and the RSC payload the router uses for client-side navigations. The only real question is when that happens. Static (prerendered) output is made once, during next build or later in the background, stored, and handed to every visitor. Dynamic output is made for each request, because it depends on something only that request knows: a cookie, a header, the query string, or data that must be fresh.
You don't pick static or dynamic with a switch. Next.js decides from what your code touches. During next build it renders every route, and anything that can finish without a request (JSX, imports, cached data) ends up in the build output. Anything that needs the request (or uncached I/O) can't finish, and what happens next depends on the caching model. With Cache Components (cacheComponents: true, the default for new apps in Next.js 16) the decision is made per component: the build keeps everything it could render as a static shell and leaves a hole wherever a <Suspense> boundary wraps request-time work; at request time the holes are rendered and streamed into the same response. That's Partial Prerendering, covered from the caching side in Partial Prerendering. In the previous model (every version before 16, and 16 without Cache Components) the decision is per route: one cookies() call anywhere and the whole route is rendered per request.
A mental model that works for both: think of a printed form. The build prints as much of the form as it can without knowing who will fill it in. Cache Components print the form with blanks left for the personal parts and fill them in at the counter, in front of the visitor. The previous model has only two options: hand out a fully printed copy, or write the whole form out by hand for each visitor.
What decides it, per kind of code. The left column is the model this site teaches; the right column is what you'll meet in apps built before Next.js 16 (or with cacheComponents off), checked against the "previous model" guide in the bundled docs.
| What the code does | Cache Components (Next.js 16 default) | Previous model (13–15, or 16 without Cache Components) |
|---|---|---|
| Plain JSX, imports, synchronous work | In the static shell | Static |
A 'use cache' function or component | In the static shell (unless its cacheLife is very short) | Not available; fetch cache or unstable_cache instead |
An uncached fetch or database call | A hole: must sit inside <Suspense>, or the build fails | Runs once at build and is baked in (unless fetch uses no-store) |
cookies(), headers(), searchParams | A hole: must sit inside <Suspense> | The whole route becomes dynamic |
await connection() | A hole: everything after it runs per request | The whole route becomes dynamic |
params | In the shell for values generateStaticParams returned, a hole otherwise | ● for listed values, others rendered on first request |
new Date(), Math.random() | Build error unless after connection() or inside 'use cache' | Evaluated once at build and frozen |
export const dynamic = 'force-dynamic' | Not allowed (build error) | Forces the whole route dynamic |
The route table that next build prints is the quickest way to see the result. With Cache Components the symbols are ○ (Static: the whole page prerendered), ◐ (Partial Prerender: a static shell plus streamed holes) and ƒ (Dynamic: an empty shell, everything rendered per request). The previous model uses ○, ● (SSG: prerendered from generateStaticParams) and ƒ.
Here is one small café app with one route of each kind. lib/db.ts stands in for a database: each call takes 300 ms.
// A stand-in for a database: every call takes 300 ms, like a real query would.
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function getMenu() {
await wait(300);
return ["Flat white", "Cortado", "Cold brew"];
}
export async function getStock(item: string) {
await wait(300);
return { item, left: Math.floor(Math.random() * 20) };
}export default function About() {
return <h1>About our café</h1>;
}import { cacheLife } from "next/cache";
import { getMenu } from "@/lib/db";
export default async function Menu() {
"use cache";
cacheLife("hours");
const items = await getMenu();
return (
<ul>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}import { Suspense } from "react";
import { cookies } from "next/headers";
async function Greeting() {
const name = (await cookies()).get("name")?.value ?? "guest";
return <p>Welcome back, {name}</p>;
}
export default function Account() {
return (
<main>
<h1>Your account</h1>
<Suspense fallback={<p>Loading your details…</p>}>
<Greeting />
</Suspense>
</main>
);
}import { Suspense } from "react";
import { getStock } from "@/lib/db";
async function Stock() {
const { item, left } = await getStock("Cold brew");
return <p>{item}: {left} left</p>;
}
export default function StockPage() {
return (
<main>
<h1>Today’s stock</h1>
<Suspense fallback={<p>Checking stock…</p>}>
<Stock />
</Suspense>
</main>
);
}import { cookies } from "next/headers";
import { getStock } from "@/lib/db";
export const instant = false;
export default async function Blocking() {
const name = (await cookies()).get("name")?.value ?? "guest";
const { left } = await getStock("Cold brew");
return <h1>Hello {name}, {left} cold brews left</h1>;
}Route (app) Revalidate Expire
┌ ○ /_not-found
├ ○ /about
├ ◐ /account
├ ƒ /blocking
├ ○ /menu 1h 1d
└ ◐ /stock
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demand/abouttouches nothing but JSX, so the build renders it completely:○. Nothing about it can differ between visitors, so there is nothing to do at request time./menuawaits a 300 ms query, but the page is a'use cache'component withcacheLife("hours"), so the build waits for the query, stores the result and puts it in the HTML:○again, now withRevalidate 1handExpire 1dcolumns. Those come from thehoursprofile (revalidate after an hour, expire after a day) and turn the static page into ISR, the subject of Incremental Static Regeneration./accountreads a cookie. The build can't know any visitor's cookie, soGreetingcan't finish; it is wrapped in<Suspense>, so the build keeps<h1>Your account</h1>and the fallback paragraph as the static shell and leavesGreetingas a hole:◐./stockdoesn't read the request at all, but its query is uncached. With Cache Components uncached I/O is treated like request data (it has to be fresh), so it becomes a hole too:◐. Removing the<Suspense>would fail the build; wrappinggetStockin'use cache'would make the page○instead./blockingawaits a cookie and the query at the top of the page, outside any<Suspense>. That would normally fail the build (see below);export const instant = falsetells Next.js this route is allowed to block, so the build accepts an empty shell and printsƒ./_not-foundis the built-in 404 page and appears in every table. What this shows: one app, one config, and the symbol of each route follows from the code inside it, not from a setting.
The symbols are a promise about what a visitor gets. To check that promise, run the app (next build && next start -p 4506) and look at the responses. observe.mjs is a 25-line script (shown in Streaming SSR in practice) that prints the status, the caching headers, and each chunk of the body as it arrives with the visible HTML in it. Chunks with no visible HTML (scripts carrying the RSC payload) are left out below. The ~30 ms before the first byte is mostly the script connecting; compare the numbers with each other.
/about
200 after 41 ms
cache-control: s-maxage=31536000
x-nextjs-cache: HIT
x-nextjs-prerender: 1, 1
chunk 0 +42 ms 6705 B <h1>About our café</h1>
done +42 ms
/menu
200 after 29 ms
cache-control: s-maxage=3600, stale-while-revalidate=82800
x-nextjs-cache: HIT
x-nextjs-prerender: 1, 1
chunk 0 +29 ms 6959 B <li>Flat white</li> | <li>Cortado</li> | <li>Cold brew</li>
done +30 ms
/account
200 after 41 ms
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-nextjs-prerender: 1
x-nextjs-postponed: 1
chunk 0 +42 ms 1552 B <h1>Your account</h1> | template id="B:0" | <p>Loading your details…</p>
chunk 3 +62 ms 923 B hidden id="S:0" | <p>Welcome back, <!-- -->Rohit</p>
done +64 ms
/stock
200 after 30 ms
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-nextjs-prerender: 1
x-nextjs-postponed: 1
chunk 0 +31 ms 1549 B <h1>Today’s stock</h1> | template id="B:0" | <p>Checking stock…</p>
chunk 11 +330 ms 937 B hidden id="S:0" | <p>Cold brew<!-- -->: <!-- -->0<!-- --> left</p>
done +330 ms
/blocking
200 after 31 ms
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-nextjs-prerender: 1
x-nextjs-postponed: 1
chunk 0 +334 ms 1437 B <h1>Hello <!-- -->Rohit<!-- -->, <!-- -->2<!-- --> cold brews left</h1>
done +336 ms- The two
○routes come straight from the cache:x-nextjs-cache: HIT,x-nextjs-prerender: 1, and the whole page in one chunk. TheirCache-Controltells a CDN how long it may keep them: a year for/about(s-maxage=31536000), an hour plus up to 23 hours of serving stale for/menu(s-maxage=3600, stale-while-revalidate=82800, thehoursprofile again). - The
◐routes answer withx-nextjs-postponed: 1: the prerendered shell was sent and the rest of the render was postponed to this request. TheirCache-Controlisprivate, no-storebecause the full response contains per-visitor content that a shared cache must not keep. - On
/account, chunk 0 is the shell: the heading, a<template id="B:0">placeholder and the fallback. 20 ms later the greeting arrives as<div hidden id="S:0">with the cookie's value in it, plus a small script that swaps it intoB:0. Same response, same status, two moments. - On
/stockthe shell arrives at 31 ms and the stock line at 330 ms: the 300 ms query ran after the shell had been sent, so the visitor saw the page frame immediately. Run it again and the number changes (it printed 19, 1 and 0 on three requests): it really is per request. /blockingalso sent its headers at 31 ms, but its first byte of HTML only at 334 ms: with no fallback to show, the visitor stares at a blank tab until the query finishes. That's whatƒcosts, and it's the trade-offinstant = falsemakes explicit.
What the build says when a page can't be prerendered
With Cache Components, request-time work outside <Suspense> isn't silently made dynamic; the build stops and tells you to choose. This page reads a cookie with no boundary around it (built in a scratch copy of the template, not one of the chapter's apps):
import { cookies } from "next/headers";
export default async function Home() {
const name = (await cookies()).get("name")?.value ?? "guest";
return <h1>Hello {name}</h1>;
}Error: Route "/": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic
at body (<anonymous>)
at html (<anonymous>)- The prerender reaches
await cookies()with no request, so it can't continue, and it walks up the tree looking for a<Suspense>boundary to use as the hole. There is none: the whole page would have to wait for the request. - The three fixes map to the three outcomes in the café app:
[stream]is/account(◐),[cache]is/menu(○, only for data that's the same for everyone, so not for a cookie),[block]is/blocking(ƒ). - The stack only names
bodyandhtml. To find the line, open the route innext dev(the overlay points at it) or runnext build --debug-prerender.
Unstable values get the same treatment. new Date() in a page body fails with Next.js encountered the unstable value `new Date()` while prerendering and offers [dynamic] (call await connection() first), [cache] ('use cache' freezes one value for everyone until revalidation), [client] (render it in the browser) and [measure] (performance.now() for telemetry). In the previous model the same line runs once at build time and every visitor sees the build's timestamp, which is exactly the kind of silent surprise the new errors exist to prevent.
Turn Cache Components off and the same ideas give very different results. This app is what an App Router project looked like on Next.js 13 to 15:
import type { NextConfig } from "next";
// The previous model: what every app had before Next.js 16, and what 16 does without Cache Components.
const nextConfig: NextConfig = {
cacheComponents: false,
};
export default nextConfig;const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function getStock(item: string) {
await wait(300);
return { item, left: Math.floor(Math.random() * 20) };
}export default function About() {
return <h1>About our café</h1>;
}import { getStock } from "@/lib/db";
export default async function StockPage() {
const { item, left } = await getStock("Cold brew");
return <p>{item}: {left} left</p>;
}import { cookies } from "next/headers";
export default async function Account() {
const name = (await cookies()).get("name")?.value ?? "guest";
return <h1>Welcome back, {name}</h1>;
}import { connection } from "next/server";
import { getStock } from "@/lib/db";
export default async function LiveStock() {
await connection();
const { item, left } = await getStock("Cold brew");
return <p>{item}: {left} left</p>;
}Route (app)
┌ ○ /_not-found
├ ○ /about
├ ƒ /account
├ ƒ /live-stock
└ ○ /stock
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand/stock 200 after 27 ms cache-control: s-maxage=31536000 x-nextjs-cache: HIT <p>Cold brew: 12 left</p>
/stock 200 after 25 ms cache-control: s-maxage=31536000 x-nextjs-cache: HIT <p>Cold brew: 12 left</p>
/account 200 after 64 ms cache-control: private, no-cache, no-store, max-age=0, must-revalidate <h1>Welcome back, Rohit</h1>
/live-stock 200 after 343 ms cache-control: private, no-cache, no-store, max-age=0, must-revalidate <p>Cold brew: 4 left</p>
/live-stock 200 after 345 ms cache-control: private, no-cache, no-store, max-age=0, must-revalidate <p>Cold brew: 6 left</p>/stockhas no request API in it, so the previous model calls it○and renders it once, during the build: the random stock number (12 here) is baked into the HTML and every visitor sees it for a year (s-maxage=31536000). The 300 ms "query" never runs again. This is the classic bug of the old model: a database call in a page that looks dynamic but isn't./accountcallscookies(), and that one call makes the whole routeƒ: there is no shell to send early, the page is rendered top to bottom for each request./live-stockfixes the frozen number the old way, withawait connection()(in older code:export const dynamic = 'force-dynamic',export const revalidate = 0,cache: 'no-store'on a fetch, orunstable_noStore()). It works, but the whole response now waits for the query: 343 ms before the first byte, on every request.- Compare with the Cache Components app: there
/stockstreams a shell in 31 ms and the number at 330 ms, and you couldn't have shipped the frozen version by accident because the build would have refused it.
next dev always renders on demandAn app on Next.js 15 (no Cache Components) has the /stock page from the previous-model app above. Visitor A loads it at 10:00, visitor B at 10:01. Each time getStock would pick a new random number. What does each visitor see, and what does the route table say?
Show answer
Both see the same number (12 in the run above), and the route table says ○ /stock.
- The page uses no request API (
cookies(),headers(),searchParams,connection()), so the previous model treats it as static and prerenders it duringnext build. - During that prerender
getStockruns once, waits 300 ms and returns a random number; the HTML with that number is written to the build output. - At 10:00 and 10:01
next startserves that stored HTML (x-nextjs-cache: HIT);getStockdoesn't run at all. With norevalidate, the page never changes until the next deploy. - To make it per request: add
await connection()(whole routeƒ), or addexport const revalidate = 60to refresh it at most once a minute (ISR). In Next.js 16 with Cache Components the build would have stopped and asked you to choose.