Partial Prerendering
PPR combines a static shell with streamed dynamic content in a single route.
Partial Prerendering (PPR) lets one page be mostly static while still having dynamic, per-request parts. Next serves the static shell instantly from the edge, then streams in the dynamic sections wrapped in <Suspense>. This removes the old all-or-nothing choice between static and dynamic.
How it works
- Everything outside a Suspense boundary is prerendered as the static shell.
- Anything inside a Suspense boundary that reads request data becomes a dynamic hole, streamed in.
Example
import { Suspense } from 'react';
export default function ProductPage() {
return (
<main>
<StaticHeader /> {/* prerendered shell */}
<Suspense fallback={<CartSkeleton />}>
<Cart /> {/* dynamic, per-user, streamed */}
</Suspense>
</main>
);
}When to use it
- A product page statically renders the header, navigation, and price while the personalised recommendations stream in dynamically.
- A news homepage serves its static layout from the CDN edge instantly while live headlines load via dynamic Suspense slots.
- A checkout page pre-renders the static form shell and streams the user's saved addresses once the session resolves.
More examples
Enable PPR in next.config.ts
PPR is opt-in via the experimental.ppr flag in next.config.ts; enable it before using the feature.
// next.config.ts
import type { NextConfig } from 'next';
export default {
experimental: {
ppr: true,
},
} satisfies NextConfig;Static shell with dynamic slot
PPR pre-renders everything outside Suspense boundaries and streams the dynamic slots when the server resolves them.
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import Recommendations from './Recommendations';
export const experimental_ppr = true;
export default function ProductPage() {
return (
<>
<h1>Product Detail</h1> {/* static — prerendered */}
<Suspense fallback={<p>Loading recs...</p>}>
<Recommendations /> {/* dynamic — streamed */}
</Suspense>
</>
);
}Dynamic slot component
Reading cookies() inside a Suspense-wrapped component marks it as dynamic, letting PPR stream it separately.
// app/product/[id]/Recommendations.tsx
import { cookies } from 'next/headers';
export default async function Recommendations() {
const cookieStore = await cookies();
const userId = cookieStore.get('user-id')?.value;
const recs = await fetchRecommendations(userId);
return <ul>{recs.map((r: any) => <li key={r.id}>{r.name}</li>)}</ul>;
}
Discussion