Partial Prerendering
The idea in one sentence
Section titled “The idea in one sentence”Partial Prerendering serves a prerendered static shell instantly and streams the dynamic “holes” — each wrapped in Suspense — into that shell at request time, giving one route the speed of static and the freshness of dynamic.
One route, static shell plus dynamic holes
Section titled “One route, static shell plus dynamic holes”Before PPR you picked one mode per route: fully static (fast, but no per-request data) or fully dynamic (fresh, but nothing prerendered). PPR removes the either/or. The parts of the page that never change are prerendered into a static shell; the parts that need request-time data become dynamic holes, each behind a <Suspense> boundary, streamed in as they resolve.
import { Suspense } from 'react';import { cookies } from 'next/headers';
async function LivePrice() { const store = await cookies(); const currency = store.get('currency')?.value ?? 'USD'; const price = await fetch('https://api.example.com/price', { cache: 'no-store', }).then((r) => r.json()); return <strong>{price.amount} {currency}</strong>;}
export default function ProductPage() { return ( <main> <h1>Wireless Headphones</h1> <p>This description is prerendered into the static shell.</p> <Suspense fallback={<span>Loading price…</span>}> <LivePrice /> </Suspense> </main> );}The heading and description ship instantly as static HTML; LivePrice is the dynamic hole that streams in per request.
The Suspense fallback becomes part of the prerendered shell
Section titled “The Suspense fallback becomes part of the prerendered shell”The static shell isn’t just the content outside <Suspense> — it includes the fallback itself. At build time, Next.js renders the fallback UI and bakes it into the static HTML in place of the hole; only when a real request comes in does the actual dynamic content replace it. Building the product page above and reading the real next build legend confirms the route is neither fully static nor fully dynamic:
Route (app)┌ ○ /├ ○ /_not-found└ ◐ /product/[id]
○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content◐ means exactly this: everything outside <Suspense>, plus every fallback, is in the static HTML that ships instantly; everything inside a boundary streams in afterward. This is why the fallback content matters for real — visitors on a slow connection may sit on it for a moment, and search crawlers that can’t wait for streamed content see the fallback as the final state unless they re-render dynamically.
Forcing request-time rendering: connection() and io()
Section titled “Forcing request-time rendering: connection() and io()”Some code has no Dynamic API to reach for, but still needs to run per-request rather than once at build time — Math.random(), crypto.randomUUID(), or a synchronous database driver. Two functions from next/server / next/cache opt code like that out of the static shell:
import { connection } from 'next/server';
// Stands in for any synchronous data source (a sync DB driver, an in-memory// counter) that would otherwise complete during prerendering with no// Dynamic API of its own to stop it.declare function readVisitorCountSync(): number;
export async function getVisitorCount() { await connection(); // prerendering stops here — the rest only runs at request time return readVisitorCountSync();}import { Suspense } from 'react';import { io } from 'next/cache';
export default function CurrentTimeSection() { return ( <Suspense fallback={<p>Loading…</p>}> <CurrentTime /> </Suspense> );}
async function CurrentTime() { await io(); // suspends during prerendering, so the fallback ships in the shell return <p>{new Date().toISOString()}</p>;}connection() is the older, broader tool: it waits for a real incoming request before continuing, full stop — it replaced unstable_noStore. io() is the Cache Components-specific alternative, and the docs recommend it as the default choice under cacheComponents: it works the same way but the value it excludes can also be cached or prefetched, since io() inside a 'use cache' scope is a no-op — the value is captured into the static shell there instead, no <Suspense> boundary required. Reach for connection() only when rendering must wait for an actual user request; reach for io() everywhere else under Cache Components.
Prefetching the static shell: partialPrefetching
Section titled “Prefetching the static shell: partialPrefetching”The static shell isn’t just what ships on first load — it’s also what <Link> prefetches ahead of a click. In Next.js 16.3, this is a distinct, separate config flag: partialPrefetching, which requires cacheComponents to already be true.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true,};
export default nextConfig;Without it, Next.js prefetches one full route per visible <Link> — a page with many links can trigger many separate prefetch requests. With partialPrefetching: true, Next.js instead prefetches one reusable App Shell per route, shared by every link that points there, and resolves URL-specific content (params, searchParams) only after the actual navigation. A segment can override the app-wide default with its own prefetch export ('auto' the default, 'partial', or 'force-disabled'):
export const prefetch = 'partial'; // opt this segment in without flipping the flag app-widepartialPrefetching shipped in 16.3.0 — it did not exist in earlier 16.x releases, and it is a separate flag from cacheComponents itself, not a value passed to it.
Contrast with all-static and all-dynamic
Section titled “Contrast with all-static and all-dynamic”The three models line up along one axis — how much of the route is known at build time:
- All static — the entire route is prerendered; nothing is per-request. Fastest, but no fresh data.
- All dynamic — the entire route renders on each request. Always fresh, but nothing is prerendered, so TTFB waits on the slowest data.
- PPR — the shell (including every Suspense fallback) is prerendered like static, and only the holes are dynamic. Fast first paint and per-request data in the same route.
flowchart LR A[Request] --> B[Static shell served instantly] B --> E[Shell prerendered at build time, fallback included] B --> C[Dynamic hole in Suspense] C --> D[Streamed at request time]
Mental Model
Section titled “Mental Model”A PPR route is a printed brochure with a small live display embedded in it. The brochure — every word of it, including whatever placeholder text sits in the display’s frame — is printed once, in bulk, ahead of time. Only the little screen updates per visitor, and it updates after the brochure is already in their hands. partialPrefetching decides how eagerly that brochure gets handed out before someone even asks for it: one shared brochure design per destination, rather than reprinting one per person who might walk by.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
PPR (cacheComponents: true) | Static-speed shell plus per-request holes, one route | Every dynamic read must sit inside <Suspense> or 'use cache' — no implicit whole-route dynamic mode |
connection() | Simple, unconditional “wait for a real request” | Excludes the value from the static shell entirely — never cacheable, never prefetchable |
io() | Same exclusion, but a no-op (and cacheable) inside 'use cache' | Cache Components-only; not a drop-in replacement outside it |
partialPrefetching: true | One shared App Shell prefetch per route instead of one per link | Requires cacheComponents already on; a 16.3+ feature, not available in earlier 16.x |
Common Misconceptions
Section titled “Common Misconceptions”- The Suspense fallback is just a loading spinner that disappears quickly. It’s baked into the static shell and served to every visitor and to any client that can’t wait for the stream (some crawlers) — treat its content as seriously as the rest of the static page.
connection()andio()are interchangeable.io()is the Cache Components-native choice: it’s a no-op (and thus cacheable) inside a'use cache'scope, whileconnection()always excludes its code from the shell, unconditionally.partialPrefetchingis just another name forcacheComponents. It’s a separate config key introduced in 16.3.0 that requirescacheComponents: trueto already be set — enablingcacheComponentsalone does not turn on the new App Shell prefetching behavior.- PPR means the whole page is dynamic if any part of it is. The opposite is the entire point: only the code inside a
<Suspense>boundary (or outside a'use cache'scope) is excluded from the static shell — everything else still prerenders.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet touches PPR or Cache Components, check for these before trusting it:
- Does a component read
cookies(),headers(),searchParams, or an uncachedfetchwithout a<Suspense>boundary or'use cache'scope around it? UndercacheComponents, that’s a build failure, not a runtime surprise. - Does the code use
Math.random(),Date.now(), or a synchronous DB call directly, assuming it will just work at request time, withoutconnection()orio()to exclude it from the static shell? - Is
partialPrefetchingset withoutcacheComponents: truealready present? The framework throws at config validation — the two flags are separate but dependent. - Does the fallback UI for a Suspense boundary contain something that shouldn’t be shown to every visitor by default (a real-looking price, a fake username) — forgetting that the fallback ships in the static shell, not just as a transient loading state?
- Does the code assume
export const experimental_ppr = trueorexperimental.pprinnext.configstill does anything in Next.js 16? Both were removed — PPR is enabled solely throughcacheComponents: true.
🐛 Spot the bug
// app/dashboard/page.tsx — next.config.ts has cacheComponents: trueimport { cookies } from 'next/headers'
export default async function Dashboard() {const store = await cookies()const theme = store.get('theme')?.value ?? 'light'return ( <main> <h1>Dashboard</h1> <p>Active theme: {theme}</p> </main>)}Show the answer
Under cacheComponents: true, cookies() no longer flips the whole route to dynamic the way it does in the previous model — it now requires an explicit <Suspense> boundary around the code that reads it. This exact page fails next build with: 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.... The fix is to move the cookies() read into a child component and wrap that child in <Suspense fallback={...}>, so the heading prerenders into the static shell and only the theme-dependent paragraph becomes a dynamic hole.