Partial Prerendering (PPR)
Partial Prerendering is an experimental rendering strategy that combines static and dynamic content in a single page. The static shell loads instantly while dynamic content streams in.
The Problem PPR Solves
Traditional pages are either fully static OR fully dynamic:
// This entire page is dynamic because of cookies()
export default async function ProductPage() {
const user = await cookies() // Makes everything dynamic!
return (
<div>
<ProductDetails /> {/* Could be static */}
<ProductReviews /> {/* Could be static */}
<AddToCartButton /> {/* Could be static */}
<UserRecommendations /> {/* Needs to be dynamic */}
</div>
)
}
With PPR, static parts render at build time while dynamic parts stream in.
How PPR Works
- Build time: Static shell is pre-rendered
- Request time: Dynamic parts render and stream in
- User sees: Instant static content + progressive dynamic content
import { Suspense } from 'react'
import { cookies } from 'next/headers'
// Static shell renders at build time
export default function ProductPage() {
return (
<div>
<ProductDetails /> {/* Static */}
<ProductReviews /> {/* Static */}
<AddToCartButton /> {/* Static */}
<Suspense fallback={<RecommendationsSkeleton />}>
<UserRecommendations /> {/* Dynamic - streams in */}
</Suspense>
</div>
)
}
// This component is dynamic
async function UserRecommendations() {
const cookieStore = await cookies()
const userId = cookieStore.get('userId')
const recs = await getRecommendations(userId)
return <RecommendationsList items={recs} />
}
Enabling PPR
PPR is experimental. Enable it in your config:
// next.config.ts
const nextConfig = {
experimental: {
ppr: true,
},
}
export default nextConfig
Then opt in per-route:
// app/products/[id]/page.tsx
export const experimental_ppr = true
export default function ProductPage() {
return (
<div>
<StaticContent />
<Suspense fallback={<Loading />}>
<DynamicContent />
</Suspense>
</div>
)
}
The Key: Suspense Boundaries
Suspense boundaries define where static ends and dynamic begins:
export default function Page() {
return (
<>
{/* Everything outside Suspense is static */}
<Header />
<Navigation />
<main>
<StaticHero />
{/* This boundary marks dynamic content */}
<Suspense fallback={<CartSkeleton />}>
<CartStatus /> {/* Dynamic - uses cookies */}
</Suspense>
<StaticProductGrid />
<Suspense fallback={<RecsSkeleton />}>
<PersonalizedRecs /> {/* Dynamic */}
</Suspense>
</main>
<Footer /> {/* Static */}
</>
)
}
PPR vs Other Strategies
| Strategy | Static Content | Dynamic Content | TTFB |
|---|---|---|---|
| SSG | Pre-rendered | None | Instant |
| SSR | None | All at request | Slower |
| ISR | Pre-rendered | Revalidates | Instant |
| PPR | Pre-rendered | Streams in | Instant |
Best Practices
- Wrap only what's truly dynamic
// ✅ Narrow dynamic boundary
<Suspense fallback={<Small />}>
<SmallDynamicPart />
</Suspense>
// ❌ Wide dynamic boundary
<Suspense fallback={<Huge />}>
<StaticPart />
<AnotherStaticPart />
<SmallDynamicPart />
</Suspense>
- Push dynamic content down the tree
// ✅ Dynamic component is a leaf
<ProductPage>
<StaticDetails />
<Suspense fallback={<Skeleton />}>
<DynamicReviews />
</Suspense>
</ProductPage>
Summary
- PPR combines static and dynamic content in one page
- Static shell is pre-rendered and served instantly
- Dynamic content streams in via Suspense boundaries
- Currently experimental - enable in next.config
- Best for pages with mostly static content + small dynamic parts

