Rendering Strategies - A Decision Map
How to choose between SSG, ISR, SSR, PPR, CSR and a full SPA in Next.js 16, and how the build decides on its own whether a route is static, partial or dynamic.
- Published
- Reading time
- 13 min read
Next.js does not ask you to pick a rendering mode per route. It tries to prerender everything at build time and watches what your code touches. Every request API (cookies(), headers(), searchParams, connection(), uncached fetch) checks which kind of render it is running in by reading the current work unit store. In the Cache Components world (cacheComponents: true, the Next 16 recommendation) those APIs return promises that never resolve during the prerender. The render is aborted after a few tasks, and whatever is still pending inside a <Suspense> becomes a hole in a static shell. That is PPR, and it is the default. In the legacy world (no cacheComponents) the same APIs throw a DynamicServerError. The whole route then becomes dynamic (SSR), and revalidate / generateStaticParams give you SSG and ISR. CSR and SPA are choices you make in your own code: Client Components that fetch, or output: 'export'. This part is the map. Parts 21 to 28 are the territory.
Two worlds, one frameworkCopy link to section:
Everything in the Patterns track depends on one line in next.config.ts. Read this section first. The rest of the notes keep saying "with Cache Components" and "legacy".
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true, // the Next 16 model: PPR, 'use cache', cacheLife
partialPrefetching: true, // set it explicitly; leaving it unset logs a warning
}
export default nextConfigThe two config sets do not mix. With Cache Components on, the SWC transform rejects dynamicParams, dynamic, fetchCache, revalidate and experimental_ppr exports with "Route segment config "…" is not compatible with nextConfig.cacheComponents. Please remove it."1 Going the other way, instant, prefetch and ensureStatic fail the build without cacheComponents.2 For what partialPrefetching changes on the client, see How much a link prefetches.
experimental.ppris gone. Setting it throws a hard deprecation error: "experimental.pprhas been merged intocacheComponents".3 Theexperimental_pprsegment export was removed in 16.0.0.- With Cache Components, the build turns PPR on for every App Router page. A page supports partial prerendering "when it is an app page and Cache Components is enabled".4 There is no per-route opt-in any more.
ensureStatic(16.4.0) andinstantare new route segment configs that only exist in the Cache Components world.
The six strategies at a glanceCopy link to section:
Two more patterns sit across these: server-prefetched client caches (26) and the two auth styles (27, 28). Auth is a rendering decision too. Reading a session cookie on the server makes that subtree request-time.
The decision flowchartCopy link to section:
Start from the content, not from the acronym. The questions are about who the content is for and when it changes.
Diagram source:
flowchart TD
A[New page] --> B{Needs a Node server<br/>at runtime?}
B -- "No: static host only" --> SPA["SPA / static export<br/>(25)"]
B -- Yes --> C{Same output<br/>for every visitor?}
C -- Yes --> D{Changes after<br/>deploy?}
D -- No --> SSG["SSG<br/>(21)"]
D -- "Yes: on a schedule<br/>or on publish" --> ISR["ISR<br/>(21)"]
C -- "No: parts depend on<br/>cookies, headers, query" --> E{Is there a useful<br/>shared shell?}
E -- Yes --> PPR["PPR: shell + Suspense holes<br/>(23)"]
E -- "No: whole page<br/>is per request" --> SSR["SSR<br/>(22)"]
PPR --> F{Part only makes sense<br/>in the browser?}
SSR --> F
F -- Yes --> CSR["CSR island<br/>(24)"]Three questions settle almost every case:
- Same for everyone? If yes, the page is static, and the only question left is how often it changes (SSG vs ISR).
- Is there a shell? Most "dynamic" pages have a header, navigation, product details or an article body that is the same for everyone. With Cache Components that part is prerendered and only the personal bits stream. Pure SSR is right only when nothing useful can be shown before the request data is known.
- Does it need the browser? Geolocation,
localStorage, a WebSocket, a token that only the browser holds. That part is CSR whatever you chose for the rest.
One route can be all of these at once. Picture a product page with a prerendered description (SSG), a price cached with cacheLife('minutes') (ISR), a cart badge that reads a cookie (a PPR hole) and a live stock ticker in a Client Component (CSR). The flowchart is for each region of a page, not for the route.
How Next decides: the prerender is the testCopy link to section:
There is no static analysis that marks a page "dynamic". At build time Next runs your components in a prerender and records what happens. The mechanism is two AsyncLocalStorage stores that every request API reads.
workStore and workUnitStoreCopy link to section:
- The
WorkStoredescribes the route being rendered. It has the route name, the incremental cache, and the legacy switchesforceDynamic,forceStaticanddynamicShouldError. It also hasdynamicUsageDescriptionanddynamicUsageStack, which become the "why is this route dynamic" message.5 - The
WorkUnitStoredescribes what kind of render is running right now. It is a tagged union:request, thecachestores ('use cache',unstable_cache), the prerender stores, andbuild-time-generator(used whilegenerateStaticParamsruns).6
The prerender stores are the important ones here:
The modern server store also carries the renderSignal and the abort controller. Its comment calls the controller "the boundary between Prerender and dynamic", and says it is aborted "after one task, or when you call a sync API which requires the prerender to end immediately".7
cookies() is the clearest example. It reads both stores and switches on the work unit type:8
// packages/next/src/server/request/cookies.ts (simplified)
export function cookies(): Promise<ReadonlyRequestCookies> {
const workStore = workAsyncStorage.getStore()
const workUnitStore = workUnitAsyncStorage.getStore()
if (workStore.forceStatic) return makeUntrackedCookies(createEmptyCookies()) // dynamic = 'force-static'
if (workStore.dynamicShouldError) throw new StaticGenBailoutError(/* dynamic = "error" */)
switch (workUnitStore.type) {
case 'cache': throw createCookiesInUseCacheError(route) // not allowed in 'use cache'
case 'build-time-generator': throw new Error(/* not inside generateStaticParams */)
case 'prerender': return makeHangingCookies(workStore, workUnitStore) // Cache Components
case 'prerender-legacy': return throwToInterruptStaticGeneration('cookies', workStore, workUnitStore)
case 'prerender-runtime': return stagedRendering.delayUntilStage(/* sessionData */)
case 'request': return /* the real cookies */
}
}The two prerender branches are the two worlds:
- Legacy:
throwToInterruptStaticGenerationbuilds aDynamicServerError, setsprerenderStore.revalidate = 0, records the expression on the work store, and throws.9 The build's export step catches dynamic-usage errors and records the route withcacheControl: { revalidate: 0 }. That is how a route becomesƒ.10 - Cache Components:
makeHangingCookiesreturnsmakeSessionDataHangingPromise(...). That is a promise that never resolves. It rejects only when the render signal aborts, and it records that session data was read.1112 Nothing throws. The component simply never finishes during the prerender.
searchParams, headers(), connection() and a fetch with cache: 'no-store' follow the same pattern. Each one is a hanging promise in 'prerender' and an interrupt in 'prerender-legacy'.131415
Static vs partial vs dynamic in the build outputCopy link to section:
After the prerender, the build prints one symbol per route. The function that chooses it is short and worth reading:16
// packages/next/src/build/utils.ts (simplified)
if (pageInfo.isRoutePPREnabled) { // Cache Components
if (pageInfo.hasEmptyStaticShell ||
(pageInfo.isDynamicAppRoute && !pageInfo.hasPostponed)) return 'ƒ'
if (!pageInfo.hasPostponed) return '○' // fully static
return '◐' // static shell + holes
}
if (pageInfo.isStatic) return '○'
if (pageInfo.isSSG) return '●' // generateStaticParams paths
return 'ƒ'The legend reads ○ (Static), ● (SSG), ◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content and ƒ (Dynamic) server-rendered on demand.17 With Cache Components you will mostly see ○ and ◐. A ƒ there means the prerender produced an empty shell, which is the SSR case in part 22.
The Cache Components prerender in one pictureCopy link to section:
With Cache Components the prerender runs twice. That is what makes "abort after one task" safe for code that uses caches.
Diagram source:
sequenceDiagram
participant B as next build
participant R as Prospective render
participant C as Caches ('use cache')
participant F as Final render
B->>R: render with store type 'prerender'
R->>C: fill every reachable cache
C-->>R: cacheSignal.cacheReady()
R-->>B: discard output
B->>F: render again, caches are warm
F->>F: advance stages in sequential tasks
F-->>B: abort, pending Suspense = holes
B->>B: prelude HTML + postponed state- Prospective render. React's prerender runs until every cache read has settled. Next waits on
cacheSignal.cacheReady()before it aborts this pass.18 The code comment explains why: "we can't synchronously abort the render when a dynamic API is used during the initial render because we need to ensure all caches can be filled".19 - Final render. Now every
'use cache'result is in memory and resolves at once. Next renders again insiderunInSequentialTasks, moves through the render stages one task at a time, and then aborts the React controller.2021 If the stream is still pending at that point, the result is marked partial: "we have blocked for longer than a Task and we assume there is something unfinished".22
That timing rule is how a plain database call becomes dynamic even though Next never patched your ORM. Uncached I/O cannot finish inside a few synchronous tasks. Cached reads can. Anything still waiting is a hole. Part 23 follows the holes into the postponed state and the resume.
Every route must produce a shellCopy link to section:
The Cache Components model adds a rule the legacy one never had. A pending hole must sit inside a <Suspense> boundary. During validation each hole is matched against its component stack. A hole under a Suspense boundary counts as allowed dynamic. A hole with no boundary above it becomes an error ("blocking route" in the dev overlay).23 After the render, throwIfDisallowedDynamic fails the build if the prelude is not complete and errors were collected. The two opt-outs are checked first: allowEmptyStaticShell and a Suspense boundary above <body>.24 allowEmptyStaticShell is set when a segment exports instant = false.2526
So with Cache Components every uncached or request-time access needs one of three fixes:
What counts as dynamicCopy link to section:
The legacy row for plain fetch comes from the previous-model docs: a fetch without a cache option "is fetched once during next build if it is reachable before any Request-time APIs".30 The Date.now() row is one of the biggest behaviour changes. In legacy mode a timestamp silently froze into the HTML. With Cache Components the dev overlay flags it and asks you to choose (part 05).
The same page in both worldsCopy link to section:
A product page: shared details, a personalised "in your cart" badge.
// app/products/[slug]/page.tsx (Cache Components)
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, cacheTag } from 'next/cache'
export async function generateStaticParams() {
return [{ slug: 'coffee-grinder' }] // at least one, or the build fails
}
export default function Page({ params }: PageProps<'/products/[slug]'>) {
return (
<>
<Suspense fallback={<h1>Loading…</h1>}>
<Details params={params} />
</Suspense>
<Suspense fallback={<span>Cart</span>}>
<CartBadge params={params} />
</Suspense>
</>
)
}
async function Details({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const product = await getProduct(slug)
return <h1>{product.name}</h1>
}
async function getProduct(slug: string) {
'use cache'
cacheLife('hours')
cacheTag(`product:${slug}`)
return db.product.findUniqueOrThrow({ where: { slug } })
}
async function CartBadge({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const cart = (await cookies()).get('cart')?.value?.split(',') ?? []
return <span>{cart.includes(slug) ? 'In your cart' : 'Cart'}</span>
}Build result: ◐. The details are in the shell, the badge streams.
// app/products/[slug]/page.tsx (legacy, no cacheComponents)
import { cookies } from 'next/headers'
export const revalidate = 3600
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const product = await fetch(`https://api.example.com/products/${slug}`, {
next: { tags: [`product:${slug}`] },
}).then((r) => r.json())
const cart = (await cookies()).get('cart')?.value // makes the WHOLE route dynamic
return <h1>{product.name} {cart?.includes(slug) && '(in cart)'}</h1>
}Build result: ƒ. One cookie read turned the route into SSR, and revalidate = 3600 now only governs the fetch cache. To keep the page static in legacy mode you would move the badge into a Client Component that reads the cart through an API: CSR (part 24).
Common misconceptionsCopy link to section:
<Suspense> makes a component dynamic"No. Suspense only gives a hole somewhere to stream. A component that only does synchronous or cached work completes during the prerender whether or not it is wrapped. The docs say so directly: Suspense "does not itself opt a component into dynamic rendering".
dynamic = 'force-dynamic' is how you do SSR in Next 16"Only in the legacy model. With Cache Components the export is a compile error.1 The Cache Components equivalent is instant = false (no shell needed) or connection() inside a Suspense boundary (one region per request; see connection() and io()).
next start serves PPR on its own. It sends the cached shell and resumes the dynamic render into the same response. CDNs that serve the shell at the edge use the resume protocol (part 23). Without streaming, the response is buffered but still correct. HTML-limited bots skip the shell and get a blocking render (Bots get a blocking render), and once the shell is sent the status code is fixed (how a thrown notFound() travels).
SummaryCopy link to section:
Next.js decides static vs dynamic by running your code in a prerender and listening to the request APIs. Each API reads the workUnitStore. In the legacy model a dynamic access throws DynamicServerError and the route becomes ƒ. With Cache Components the API returns a hanging promise. The final prerender is aborted after a few tasks, and every pending Suspense boundary becomes a hole in a static shell. That is why PPR is the default there, and why a route must always produce a shell unless it opts out with instant = false. Choose a strategy per region of the page: same for everyone means SSG or ISR, per request with a useful shell means PPR, no shell means SSR, browser-only means CSR, and no server at all means a static export. The next parts walk through each one, starting with SSG and ISR.
Key takeawaysCopy link to section:
- Two worlds:
cacheComponents: truebrings component-level PPR,'use cache'andinstant/ensureStatic. The legacydynamic/revalidate/fetchCache/dynamicParamsexports are a compile error there. - PPR is not a flag any more: with Cache Components every App Router page is PPR-enabled, and
experimental.pprthrows. workUnitStore.type: every request API switches on it.'prerender'gives a hanging promise,'prerender-legacy'throws,'request'gives real data.- Legacy detection:
throwToInterruptStaticGenerationsetsrevalidate = 0and the export step records the route as dynamic. - Cache Components detection: a prospective render fills caches, then a final render is aborted after sequential tasks. Anything still pending is a hole.
- Shell rule: holes must be inside
<Suspense>. The opt-outs areinstant = falseor Suspense above<body>. - Short caches are holes:
'use cache'withrevalidate: 0orexpireunder 5 minutes is not prerendered. - Build symbols:
○static,●SSG (legacy),◐partial prerender,ƒdynamic or empty shell. - Decide per region: same for everyone (SSG/ISR), useful shell (PPR), no shell (SSR), browser-only (CSR), no server (SPA).
← Configuration · Index · SSG & ISR →
Footnotes
-
react_server_componentsinvalid segment config under Cache Components — crates/next-custom-transforms/src/transforms/react_server_components.rs#L958-L971 (opens in a new tab); error text — #L383 (opens in a new tab) ↩ ↩2 -
instant/prefetch/ensureStaticrequirecacheComponents— packages/next/src/build/analysis/get-page-static-info.ts#L748-L762 (opens in a new tab) ↩ -
experimental.pprhard deprecation — packages/next/src/server/config.ts#L674-L678 (opens in a new tab) ↩ -
isRoutePPREnabled= app page andcacheComponents— packages/next/src/build/utils.ts#L971-L974 (opens in a new tab) ↩ -
WorkStore(forceDynamic,forceStatic,dynamicShouldError,dynamicUsageDescription) — packages/next/src/server/app-render/work-async-storage.external.ts#L18-L49 (opens in a new tab) ↩ -
WorkUnitStoreunion — packages/next/src/server/app-render/work-unit-async-storage.external.ts#L427-L431 (opens in a new tab); prerender store types — #L149-L219 (opens in a new tab);PrerenderStoreLegacy— #L302-L307 (opens in a new tab) ↩ -
PrerenderStoreModernCommon(renderSignal,controller,cacheSignal) — packages/next/src/server/app-render/work-unit-async-storage.external.ts#L229-L292 (opens in a new tab) ↩ -
throwToInterruptStaticGeneration— packages/next/src/server/app-render/dynamic-rendering.ts#L159-L175 (opens in a new tab) ↩ -
exportAppPagedynamic-usage catch — packages/next/src/export/routes/app-page.ts#L287-L312 (opens in a new tab);revalidate === 0result — #L137-L154 (opens in a new tab) ↩ -
makeHangingCookies— packages/next/src/server/request/cookies.ts#L159-L177 (opens in a new tab);makeSessionDataHangingPromise— packages/next/src/server/dynamic-rendering-utils.ts#L126-L140 (opens in a new tab) ↩ -
makeHangingPromiseWithError— packages/next/src/server/dynamic-rendering-utils.ts#L605-L637 (opens in a new tab) ↩ -
createStaticPrerenderSearchParams— packages/next/src/server/request/search-params.ts#L212-L235 (opens in a new tab) ↩ -
connection()prerender branches — packages/next/src/server/request/connection.ts#L79-L104 (opens in a new tab) ↩ -
patched
fetch,no-storeandrevalidate: 0branches — packages/next/src/server/lib/patch-fetch.ts#L1186-L1279 (opens in a new tab) ↩ -
getTreeViewSymbol— packages/next/src/build/utils.ts#L236-L277 (opens in a new tab) ↩ -
build output legend — packages/next/src/build/utils.ts#L572-L591 (opens in a new tab) ↩
-
prospective render waits for
cacheSignal.cacheReady()— packages/next/src/server/app-render/app-render.tsx#L9366-L9370 (opens in a new tab) ↩ -
Cache Components prerender strategy comment — packages/next/src/server/app-render/app-render.tsx#L9117-L9150 (opens in a new tab) ↩
-
final server prerender in sequential tasks — packages/next/src/server/app-render/app-render.tsx#L9686-L9798 (opens in a new tab) ↩
-
runInSequentialTasks— packages/next/src/server/app-render/app-render-render-utils.ts#L18-L87 (opens in a new tab) ↩ -
resultIsPartialwhen the stream is still pending — packages/next/src/server/app-render/app-render.tsx#L9788-L9797 (opens in a new tab) ↩ -
trackDynamicHoleInStaticShell— packages/next/src/server/app-render/dynamic-rendering-validation.ts#L718-L784 (opens in a new tab) ↩ -
throwIfDisallowedDynamic— packages/next/src/server/app-render/dynamic-rendering-validation.ts#L838-L907 (opens in a new tab) ↩ -
isPageAllowedToBlock(instant = false) — packages/next/src/server/app-render/instant-validation/instant-config.tsx#L80-L110 (opens in a new tab) ↩ -
allowEmptyStaticShellinprerenderToStream— packages/next/src/server/app-render/app-render.tsx#L8929-L8931 (opens in a new tab) ↩ -
MIN_PRERENDERABLE_EXPIRE,MIN_PREFETCHABLE_STALE,MIN_SHELL_STALE— packages/next/src/server/use-cache/constants.ts#L3-L5 (opens in a new tab) ↩ -
short-lived
'use cache'entry omitted from the prerender — packages/next/src/server/use-cache/use-cache-wrapper.ts#L2540-L2565 (opens in a new tab) ↩ -
default
cacheLifeprofiles — packages/next/src/server/config-shared.ts#L2346-L2382 (opens in a new tab) ↩ -
Caching and Revalidating (Previous Model),
fetchCache— docs/01-app/02-guides/caching-without-cache-components.mdx (opens in a new tab); see also nextjs.org/docs/app/guides/caching-without-cache-components (opens in a new tab) ↩