React 112 ⚛️ Partial Prerender (PPR) Concepts
Partial Prerendering is the rendering model that dissolves the boundary between static and dynamic. In the Next.js App Router, a route is no longer forced to choose one or the other: the server can ship a fully static shell immediately, leaving holes for the personalized or request-dependent sections, and stream those holes in as they resolve . The result is a page that loads like a static site but behaves like a server-rendered one for the parts that need it.
This chapter covers PPR as a concept and as it exists in the Next.js implementation. It explains the mechanics of the static shell and the dynamic holes, the role of Suspense as the boundary marker, and the way the framework distinguishes cacheable content from request-time content. It then addresses the configuration models—the experimental ppr flag of Next.js 15 and the cacheComponents model of Next.js 16—and the failure modes that produce an empty shell or a fully dynamic route. The chapter closes with the cases where PPR is the wrong strategy.
Key point: PPR does not make a route half-static and half-dynamic in some vague sense. It builds a static shell at build time, records the exact state needed to resume the dynamic portions, and streams those portions into the shell at request time. The shell and the resumed state must be stored together and updated atomically.
Why PPR exists
The all-or-nothing problem. Before PPR, a route was either static or dynamic. If a single component read cookies(), the entire route opted into dynamic rendering, and the visitor paid the full server-render cost even though 95% of the page was identical for every user . Conversely, a fully static page could not personalize anything without a client-side fetch that delayed the content and degraded the initial load. PPR breaks the all-or-nothing constraint by making the boundary granular: the shell is static, and the dynamic parts are isolated behind Suspense boundaries.
The initial-load cost. A fully dynamic page sends no HTML until the server has rendered everything, including the slowest data source. The visitor stares at a blank screen until the last query completes. PPR sends the static shell immediately, so the layout, navigation, and static content appear at edge latency, and the dynamic holes fill in as their data resolves . The perceived performance improves even when the total server work is the same.
The streaming infrastructure. PPR depends on streaming, which React and Next.js already support. The shell is the first chunk of the response, and the dynamic holes are subsequent chunks sent over the same HTTP connection . The client hydrates the shell while the dynamic content is still arriving. This is not a new transport; it is the existing streaming model applied to a precomputed shell.
The single response requirement. A naive implementation might fetch the shell from a CDN and then make a second request for the dynamic content, doubling the round trips. PPR avoids this by concatenating the shell and the resumed dynamic stream into a single HTTP response . The visitor’s browser sees one response that arrives in parts, not two separate requests.
The cache coherence constraint. The static shell and the postponedState blob—the serialized data needed to resume rendering—must be stored together. If a revalidation updates the shell but leaves an old postponedState, the dynamic content will be rendered against the wrong shell, producing incorrect output . This is the implementation hazard that platforms and adapters must handle, and it is why PPR is not simply “static HTML plus a server fetch.”
a. The static shell and the dynamic holes
The static shell is the part of the route that can be prerendered at build time. It includes the layout, navigation, and any component that does not read request-time data. In Next.js 15, a component is dynamic if it uses cookies(), headers(), connection(), draftMode(), searchParams, or fetch with { cache: 'no-store' } . When one of these APIs is used outside a Suspense boundary, the build fails with a special React error that tells Next.js the component cannot be statically rendered. The error is not a bug; it is the mechanism that forces the developer to mark the boundary explicitly.
// app/page.tsx — Next.js 15 PPR example
import { Suspense } from 'react';
import { cookies } from 'next/headers';
export const experimental_ppr = true;
export default function Page() {
return (
<main>
{/* Static shell: no request-time data, prerendered at build. */}
<h1>Product Catalog</h1>
<nav>...</nav>
{/* Dynamic hole: cookies() forces this component into request time. */}
<Suspense fallback={<p>Loading greeting...</p>}>
<PersonalGreeting />
</Suspense>
</main>
);
}
async function PersonalGreeting() {
const name = (await cookies()).get('name')?.value;
return <p>Welcome back, {name}</p>;
}
The PersonalGreeting component reads cookies(), which makes it dynamic. Wrapping it in <Suspense> tells Next.js to defer that component to request time and leave the fallback in the static shell . The fallback is what the visitor sees first; the greeting streams in when the cookie is read. The <h1> and <nav> are part of the shell and arrive immediately.
Suspense does not make a component dynamic. The API usage does. Suspense is the boundary that tells the framework where the static content ends and the dynamic content begins . A component that reads no request-time data and is wrapped in Suspense will still be prerendered into the shell; the Suspense boundary is inert for it. This distinction matters because wrapping a static component in Suspense does not create a hole, and failing to wrap a dynamic component in Suspense prevents the shell from being built at all.
b. The postponed state and the resume protocol
The static shell alone is not enough to render the dynamic holes. The server also needs the serialized state that tells it where the Suspense boundaries were, what data was already resolved, and what remains to be rendered. This is the postponedState blob. It is opaque to the application and to the platform: it must be passed through without parsing or modification .
At build time, Next.js produces two artifacts for each PPR route: the static HTML shell (with the fallback UI in place of the dynamic holes) and the postponedState . At request time, the server sends the shell, then resumes rendering the deferred boundaries using the postponed state, and streams the result into the same response. The client sees the shell immediately and the dynamic content as it resolves.
The resume protocol differs by deployment architecture. In the simplest origin-only implementation—next start—the server handles both the shell and the dynamic render in a single pass . In a CDN-to-origin architecture, the CDN serves the cached shell and sends a separate POST request to the origin with the next-resume: 1 header and the postponedState as the body. The origin renders only the dynamic portions and streams them back, and the CDN concatenates the shell and the dynamic stream . In an adapter-based deployment, the handler is invoked directly with the postponed state passed as a parameter, bypassing the HTTP layer .
The coherence requirement is strict. When a PPR route is revalidated, the shell and the postponed state regenerate together. Serving a new shell with an old postponed state, or the reverse, produces incorrect dynamic output . Platforms that cache PPR artifacts must update both atomically, and the requestMeta.onCacheEntryV2 callback in the adapter API exists for this purpose .
c. Configuration models: Next.js 15 and Next.js 16
PPR entered Next.js as an experimental feature with an opt-in flag. In Next.js 15, the configuration has two levels: experimental.ppr = 'incremental' in next.config.ts enables the feature for the application, and export const experimental_ppr = true at the top of a layout or page opts that specific route into PPR . Routes without the export default to false and render in the traditional all-static or all-dynamic mode. The experimental_ppr flag applies to all children of the route segment, including nested layouts and pages, and can be disabled in a child segment by setting it to false .
// next.config.ts — Next.js 15 incremental PPR
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental',
},
};
export default nextConfig;
Next.js 16 changes the model. PPR is no longer a separate experimental flag; it is the default rendering behavior under the Cache Components model. The application opts into Cache Components with cacheComponents: true in next.config.ts . In this model, everything is dynamic by default, and caching is opt-in through the 'use cache' directive. A component marked with 'use cache' is prerendered into the static shell; a component that reads request-time data without a cache directive must be wrapped in Suspense, or the build fails .
// Next.js 16 Cache Components model
import { Suspense } from 'react';
import { cookies } from 'next/headers';
async function Posts() {
'use cache';
// This fetch is cached and becomes part of the static shell.
const posts = await fetch('https://api.example.com/posts').then((r) => r.json());
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
async function Greeting() {
// No cache directive: reads cookies(), so it is dynamic.
const name = (await cookies()).get('name')?.value;
return <p>Welcome, {name}</p>;
}
export default function Page() {
return (
<main>
<Posts /> {/* static shell */}
<Suspense fallback={<p>Loading...</p>}>
<Greeting /> {/* dynamic hole */}
</Suspense>
</main>
);
}
The mental model inversion is significant. In Next.js 15, a route is static unless it uses a dynamic API, and PPR is opted into per route. In Next.js 16, a route is dynamic unless data is explicitly cached, and the static shell is whatever remains outside the dynamic boundaries. The experimental_ppr export is removed or superseded, and the Suspense boundary remains the only mechanism for marking a dynamic hole .
Complete Example Session
// ============================================
// PART 1: THE ROUTE CONFIGURATION
// ============================================
// Next.js 15 incremental PPR requires both the
// config flag and the route-level export.
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: { ppr: 'incremental' },
};
export default nextConfig;
// ============================================
// PART 2: THE STATIC SHELL
// ============================================
// Components with no request-time data are
// prerendered into the shell.
// app/product/[id]/page.tsx
export const experimental_ppr = true;
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<main>
<ProductInfo id={params.id} /> {/* static: fetched at build */}
<Suspense fallback={<CartSkeleton />}>
<CartWidget /> {/* dynamic: reads cookies */}
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations /> {/* dynamic: personalized */}
</Suspense>
</main>
);
}
// ============================================
// PART 3: THE STATIC COMPONENT
// ============================================
// No request-time APIs. Part of the shell.
async function ProductInfo({ id }: { id: string }) {
const product = await fetch(`https://api.example.com/products/${id}`).then(r => r.json());
return (
<article>
<h1>{product.name}</h1>
<p>${product.price}</p>
</article>
);
}
// ============================================
// PART 4: THE DYNAMIC HOLE
// ============================================
// cookies() forces request-time rendering.
// Suspense defers it so the shell can ship first.
import { cookies } from 'next/headers';
async function CartWidget() {
const session = (await cookies()).get('session')?.value;
const count = session ? await getCartCount(session) : 0;
return <p>Cart: {count} items</p>;
}
// ============================================
// PART 5: THE PARALLEL BOUNDARIES
// ============================================
// Each Suspense streams independently.
// The cart and recommendations do not block each other.
function Recommendations() {
return (
<Suspense fallback={<p>Loading recommendations...</p>}>
<PersonalizedList />
</Suspense>
);
}
async function PersonalizedList() {
const session = (await cookies()).get('session')?.value;
const recs = await fetch(`https://api.example.com/recs?session=${session}`).then(r => r.json());
return <ul>{recs.map((r: string) => <li key={r}>{r}</li>)}</ul>;
}
// ============================================
// PART 6: THE NEXT.JS 16 EQUIVALENT
// ============================================
// Cache Components model: dynamic by default,
// opt into caching with 'use cache'.
// next.config.ts
const nextConfig16: NextConfig = {
cacheComponents: true,
};
// app/product/[id]/page.tsx (no experimental_ppr)
async function CachedProductInfo({ id }: { id: string }) {
'use cache';
const product = await fetch(`https://api.example.com/products/${id}`).then(r => r.json());
return <h1>{product.name}</h1>;
}
// ============================================
// PART 7: THE BUILD FAILURE
// ============================================
// Reading cookies() outside Suspense fails the build.
// The error is intentional: it forces the boundary.
// export default function Page() {
// const name = cookies().get('name')?.value; // outside Suspense
// return <p>{name}</p>; // BUILD ERROR
// }
// ============================================
// PART 8: THE EMPTY SHELL FAILURE
// ============================================
// An empty Suspense boundary above <body>
// opts the entire route out of the static shell.
// export default function Layout({ children }) {
// return (
// <Suspense fallback={null}>
// <body>{children}</body> // nothing prerendered
// </Suspense>
// );
// }
// ============================================
// PART 9: THE PARAMS ISOLATION
// ============================================
// In the App Router, awaiting params inside
// a Suspense boundary without Partial Prefetching
// prevents URL-specific ISR entries.
// Prefer: await params outside Suspense
// Or: enable Partial Prefetching
// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// PPR is a build-time shell plus a request-time
// resume. The Suspense boundary is the seam.
export default function Page() {
return (
<main>
<CachedProductInfo id="1" />
<Suspense fallback={<p>Loading...</p>}>
<CartWidget />
</Suspense>
</main>
);
}
The ten parts build a product page with a static shell and two independent dynamic holes, then show the Next.js 16 equivalent under Cache Components. The build-failure and empty-shell examples illustrate the two most common configuration mistakes: reading request-time data outside a boundary, and wrapping too much of the tree in a boundary so that nothing prerenders.
Quick Reference
PPR Artifacts
| Artifact | Produced At | Purpose |
|---|---|---|
| Static HTML shell | Build time | Immediate initial response |
postponedState | Build time | Resume dynamic rendering |
| RSC payload | Build time | Static portions of the page |
Dynamic APIs (Next.js 15)
| API | Forces Dynamic |
|---|---|
cookies() | Yes |
headers() | Yes |
connection() | Yes |
draftMode() | Yes |
searchParams | Yes |
fetch(..., { cache: 'no-store' }) | Yes |
Configuration Models
| Version | Enable | Opt In |
|---|---|---|
| Next.js 15 | experimental.ppr = 'incremental' | export const experimental_ppr = true |
| Next.js 16 | cacheComponents: true | 'use cache' directive |
PPR Request States
| State | Meaning |
|---|---|
| Static | Entire response from shell, no dynamic resume |
| Partial | Shell plus resumed dynamic content |
| Dynamic | Empty shell, entire response from function |
Best Practices
✅ Do This:
// Wrap only the dynamic leaf in Suspense.
<Suspense fallback={<Skeleton />}> // ✅
<PersonalizedWidget />
</Suspense>
// Use parallel Suspense boundaries for independent data.
<Suspense fallback={<A />}><WidgetA /></Suspense> // ✅
<Suspense fallback={<B />}><WidgetB /></Suspense> // ✅
// Keep params outside Suspense for ISR compatibility.
export default async function Page({ params }) { // ✅
const { id } = await params;
return <Suspense><Dynamic id={id} /></Suspense>;
}
// Use 'use cache' for content that should be in the shell.
async function Posts() { 'use cache'; ... } // ✅
❌ Don’t Do This:
// Don't read cookies() outside Suspense.
const name = cookies().get('name'); // build error // ❌
// Don't wrap the entire page in one Suspense.
<Suspense fallback={null}><Page /></Suspense> // ❌ empty shell
// Don't use an empty fallback that hides the layout.
<Suspense fallback={null}> // ❌ CLS risk
<SlowContent />
</Suspense>
// Don't expect PPR without a server runtime.
// Static export has no shell to resume. // ❌
// Don't nest slow content inside fast boundaries.
<Suspense><Fast /><Slow /></Suspense> // Slow blocks Fast // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
Build fails on cookies() | Dynamic API outside Suspense | Wrap the component in <Suspense> |
| Empty static shell | Suspense boundary above <body> | Move boundary below the static layout |
| Route fully dynamic | dynamic = 'force-dynamic' conflicts with PPR | Remove the route-level dynamic export |
| Dynamic content never streams | Unsupported platform or adapter | Use a runtime with PPR support |
| Stale dynamic content | Shell and postponedState updated separately | Store and update artifacts atomically |
| Params inside Suspense breaks ISR | await params inside boundary | Await outside Suspense or enable Partial Prefetching |
Real-World Examples
1. Static Shell with Greeting
<Suspense fallback={<p>Loading...</p>}>
<PersonalGreeting />
</Suspense>
2. Cache Component (Next.js 16)
async function Posts() {
'use cache';
return <List posts={await fetchPosts()} />;
}
3. Dynamic Cart
async function Cart() {
const session = (await cookies()).get('session')?.value;
return <CartCount session={session} />;
}
4. Parallel Boundaries
<Suspense fallback={<A />}><WidgetA /></Suspense>
<Suspense fallback={<B />}><WidgetB /></Suspense>
5. Enable Incremental PPR
experimental: { ppr: 'incremental' }
6. Route Opt-In
export const experimental_ppr = true;
7. Enable Cache Components
cacheComponents: true
8. Dynamic Escape
export const dynamic = 'force-dynamic';
9. Params Outside Suspense
const { id } = await params;
return <Suspense><Dynamic id={id} /></Suspense>;
10. Meaningful Skeleton
<Suspense fallback={<CardSkeleton columns={3} />}>
Visual
The PPR Request Flow
┌─────────────────────────────────────────────────────────┐
│ BUILD TIME │
│ │
│ Route compiled → static shell + postponedState │
│ Shell contains: layout, nav, static content, │
│ fallbacks for dynamic holes │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ REQUEST TIME │
│ │
│ 1. Server sends static shell immediately │
│ [layout][nav][static content][fallback] │
│ │
│ 2. Server resumes dynamic holes from postponedState │
│ [dynamic content A] [dynamic content B] │
│ │
│ 3. Both streams concatenated in one HTTP response │
│ │
│ Client sees shell instantly, holes fill as they resolve│
└─────────────────────────────────────────────────────────┘
The Suspense Boundary as Seam
┌─────────────────────────────────────────────────────────┐
│ <main> │
│ <h1>Static title</h1> ← shell │
│ <nav>...</nav> ← shell │
│ │
│ <Suspense fallback={<Skeleton />}> │
│ <PersonalGreeting /> ← dynamic hole │
│ </Suspense> │
│ │
│ <Suspense fallback={<CartSkeleton />}> │
│ <CartWidget /> ← dynamic hole │
│ </Suspense> │
│ </main> │
│ │
│ The shell stops at each Suspense boundary. │
│ The fallback is in the shell. The content streams. │
└─────────────────────────────────────────────────────────┘
Next.js 15 vs 16 Mental Model
┌─────────────────────────────────────────────────────────┐
│ NEXT.JS 15 (experimental PPR) │
│ │
│ Default: static unless dynamic API used │
│ PPR: opt-in per route │
│ Cache: implicit for static content │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ NEXT.JS 16 (Cache Components) │
│ │
│ Default: dynamic unless 'use cache' │
│ PPR: default rendering model │
│ Cache: explicit with 'use cache' │
│ │
│ The boundary is the same. The default flips. │
└─────────────────────────────────────────────────────────┘
The Empty Shell Failure
┌─────────────────────────────────────────────────────────┐
│ ROOT LAYOUT │
│ │
│ <Suspense fallback={null}> │
│ <body>{children}</body> │
│ </Suspense> │
│ │
│ Nothing above the boundary. │
│ The boundary wraps everything. │
│ │
│ RESULT: no static shell. │
│ Every request waits on the function. │
│ The visitor sees a blank screen. │
│ │
│ FIX: Move the boundary below the static layout. │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Full name | Partial Prerendering |
| Core model | Static shell + streamed dynamic holes |
| Boundary marker | <Suspense> |
| Build artifacts | HTML shell + postponedState |
| Next.js 15 config | experimental.ppr = 'incremental' |
| Next.js 15 opt-in | experimental_ppr = true |
| Next.js 16 config | cacheComponents: true |
| Next.js 16 cache opt-in | 'use cache' |
| Dynamic APIs | cookies, headers, searchParams |
| Request states | Static, Partial, Dynamic |
Key takeaways:
- PPR ships a static shell immediately and streams dynamic holes. The visitor sees the layout and static content at edge latency, not after the slowest query .
- Suspense is the boundary, not the mechanism. A component is dynamic because it reads request-time data, not because it is wrapped in Suspense. The boundary tells the framework where to split.
- The
postponedStatemust accompany the shell. It contains the serialized data needed to resume rendering. Storing a new shell with an old state produces incorrect output . - The configuration model changed between versions. Next.js 15 uses an experimental flag and per-route opt-in. Next.js 16 uses Cache Components, where everything is dynamic by default and caching is explicit .
- Reading dynamic APIs outside Suspense fails the build. This is intentional: it forces the developer to mark the boundary and prevents the entire route from silently opting into dynamic rendering .
- An empty shell means the boundary is too high. Wrapping the root layout in Suspense leaves nothing to prerender. Move the boundary below the static content.
- Parallel boundaries stream independently. Each Suspense hole resolves on its own schedule. Nesting a slow component inside a fast boundary blocks the fast one .
- PPR requires a server runtime. Static export cannot support it. Platforms differ in their CDN and origin architecture, but the origin-only model works wherever streaming is supported .
- PPR is for mostly-static pages with isolated dynamic parts. A fully dynamic dashboard has no shell to prerender. A fully static page has no holes. PPR is the middle case.
- The mental model is composition, not configuration. The developer marks static content and dynamic content with the same tools—components, Suspense, cache directives—and the framework builds the shell and streams the rest.
Remember: Partial Prerendering is not a switch that makes a page “partially static.” It is a build-time and request-time contract: the build produces a shell and the state needed to resume, and the request serves the shell, resumes the holes, and streams both in one response. The Suspense boundary is the seam between the two, and the dynamic API usage—not the boundary itself—determines which side a component falls on. Next.js 15 and 16 differ in how the shell is declared, but the underlying mechanism is the same. Use PPR when the page has a shared frame and a few personalized sections. Do not use it when everything is personalized or everything is static; in those cases, the shell is either empty or the whole page, and the strategy has nothing to contribute.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!