Partial Prerendering (PPR)
Serve a static shell instantly and stream the dynamic holes into it — one route that is both static and dynamic at once.
For years the choice was binary: a page was static (fast, but can't personalize) or dynamic (personalized, but pays render cost per request). Partial Prerendering collapses that choice. A single route ships a static shell immediately from the edge, with dynamic holes streamed in as they resolve.
How it works
At build time, Next prerenders everything it can into a static HTML shell. Anything wrapped in a <Suspense> boundary that reads request-time data becomes a hole: its fallback is baked into the shell, and the real content streams in at request time. The user gets an instant, cacheable first paint, then the personalized bits fill in — no route-level static-vs-dynamic decision required.
The rule that makes it click
The Suspense boundary is the boundary between static and dynamic. Everything outside your Suspense boundaries is prerendered; everything inside a boundary that touches cookies(), headers(), or an uncached fetch is the dynamic hole. So PPR design is really Suspense-placement design: wrap the personalized widget, leave the shell static.
Enabling it
PPR is incremental. Turn it on in next.config with experimental.ppr, then opt a route in with export const experimental_ppr = true. Roll it out route by route rather than flipping the whole app.
Example
// app/product/[id]/page.tsx
export const experimental_ppr = true;
import { Suspense } from 'react';
export default async function ProductPage({ params }: any) {
const { id } = await params;
const product = await getProduct(id); // cached -> part of static shell
return (
<main>
{/* Static shell: prerendered, cached, instant */}
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Dynamic hole: personalized, streamed in per request */}
<Suspense fallback={<PriceSkeleton />}>
<PersonalizedPrice productId={id} />
</Suspense>
<Suspense fallback={<CartSkeleton />}>
<CartStatus /> {/* reads cookies() -> dynamic */}
</Suspense>
</main>
);
}When to use it
- A product page pre-renders the product name, price, and add-to-cart form statically while streaming personalised stock and recommendations.
- A news site serves its entire page layout from the CDN edge in under 50 ms while live headlines fill in via Suspense.
- An authenticated dashboard has a static chrome pre-rendered and a dynamic @user slot streamed after the session resolves.
More examples
Enable PPR and use it in a page
experimental_ppr opts a specific route into PPR; everything outside Suspense is pre-rendered and cached.
// next.config.ts
export default { experimental: { ppr: true } };
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import StockLevel from './StockLevel'; // dynamic (reads cookies)
export const experimental_ppr = true;
export default function ProductPage() {
return (
<>
<h1>Product Name</h1> {/* static */}
<Suspense fallback={<p>Checking stock...</p>}>
<StockLevel /> {/* dynamic, streamed */}
</Suspense>
</>
);
}Dynamic slot reading user session
Reading cookies() makes this component dynamic; PPR streams it into the pre-rendered shell after the session resolves.
// app/product/[id]/StockLevel.tsx
import { cookies } from 'next/headers';
export default async function StockLevel() {
const cookieStore = await cookies();
const region = cookieStore.get('region')?.value ?? 'us';
const stock = await fetchStock(region);
return <p>In stock ({region}): {stock.count}</p>;
}PPR with parallel dynamic slots
Multiple dynamic Suspense boundaries in a PPR page stream independently, each resolving as fast as its data allows.
import { Suspense } from 'react';
import RecentOrders from './RecentOrders';
import PersonalisedBanner from './PersonalisedBanner';
export const experimental_ppr = true;
export default function Dashboard() {
return (
<div>
<Suspense fallback={<p>Loading orders...</p>}>
<RecentOrders />
</Suspense>
<Suspense fallback={<div className="skeleton h-16" />}>
<PersonalisedBanner />
</Suspense>
</div>
);
}
Discussion