Partial Prerendering (PPR) is a rendering strategy in Next.js that combines static and dynamic content in a single request. The page's static shell loads instantly from the CDN, while dynamic "holes" stream in as they resolve. This gives users immediate content without sacrificing personalization.
The problem PPR solves
Traditional rendering approaches force a choice:
| Approach | Trade-off |
|---|---|
| Static (SSG) | Fast but no dynamic content |
| Server (SSR) | Dynamic but slower TTFB |
| ISR | Periodic updates, entire page regenerates |
| Client-side | Fast shell but layout shift, SEO concerns |
Many pages are mostly static with small dynamic sections: a marketing page with a personalized header, a product page with dynamic pricing, a dashboard with a static sidebar and live data.
PPR lets you serve the static parts instantly while streaming the dynamic parts.
How PPR works
When you build with PPR enabled:
- Next.js identifies static content (outside Suspense)
- Static shell is prerendered at build time
- Suspense boundaries mark dynamic holes
- At request time, static shell serves immediately
- Dynamic content streams as it resolves
Build time:
┌─────────────────────────────┐
│ Static Header │ ← Prerendered
├─────────────────────────────┤
│ [Dynamic User Widget] │ ← Hole (placeholder)
├─────────────────────────────┤
│ Static Product Info │ ← Prerendered
├─────────────────────────────┤
│ [Dynamic Recommendations] │ ← Hole (placeholder)
├─────────────────────────────┤
│ Static Footer │ ← Prerendered
└─────────────────────────────┘
Request time:
1. Static shell sent immediately
2. Dynamic widgets stream as they resolve
Enabling PPR
Configure in next.config.js:
// next.config.js
module.exports = {
experimental: {
ppr: true,
},
};
PPR is experimental. Check the Next.js changelog for current status and known issues.
Creating dynamic holes with Suspense
Wrap dynamic content in Suspense boundaries:
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
import { UserWidget } from './UserWidget';
import { ActivityFeed } from './ActivityFeed';
import { Skeleton } from '@/components/ui/Skeleton';
export default function DashboardPage() {
return (
<div className="flex">
{/* Static: prerendered */}
<Sidebar />
<main className="flex-1">
{/* Static: prerendered */}
<Header />
{/* Dynamic: streams */}
<Suspense fallback={<Skeleton className="h-32" />}>
<UserWidget />
</Suspense>
{/* Static: prerendered */}
<h2>Recent Activity</h2>
{/* Dynamic: streams */}
<Suspense fallback={<Skeleton className="h-64" />}>
<ActivityFeed />
</Suspense>
</main>
</div>
);
}
The sidebar, header, and section titles render instantly. User widget and activity feed stream in.
What makes content dynamic
Content becomes dynamic when it:
| Trigger | Example |
|---|---|
| Reads cookies | cookies() from next/headers |
| Reads headers | headers() from next/headers |
| Uses searchParams | Accessing URL query parameters |
Uses noStore() |
Opting out of caching |
| Dynamic data fetching | fetch without cache config |
// This component is dynamic
async function UserWidget() {
const session = await getSession(); // reads cookies
const user = await getUser(session.userId);
return <div>Welcome, {user.name}</div>;
}
Fallback UI design
The fallback shows while dynamic content loads. Design it to:
- Match the final layout dimensions (prevents CLS)
- Provide visual feedback (skeletons, spinners)
- Feel fast (animation, not static placeholder)
function ActivityFeedSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-3 bg-gray-200 rounded w-1/2 mt-2" />
</div>
))}
</div>
);
}
<Suspense fallback={<ActivityFeedSkeleton />}>
<ActivityFeed />
</Suspense>
Nested Suspense boundaries
You can nest Suspense for granular streaming:
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard>
<Suspense fallback={<WidgetSkeleton />}>
<Widget1 />
</Suspense>
<Suspense fallback={<WidgetSkeleton />}>
<Widget2 />
</Suspense>
</Dashboard>
</Suspense>
Each widget streams independently. Fast widgets appear first.
Combining with loading.tsx
loading.tsx creates an implicit Suspense boundary for the page:
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />;
}
With PPR, loading.tsx becomes the fallback for the entire page's dynamic content. For more granular control, use explicit Suspense boundaries.
Error handling
Add error.tsx alongside dynamic content:
// app/dashboard/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="p-4 bg-red-50 rounded">
<h2>Failed to load dashboard</h2>
<button onClick={reset}>Retry</button>
</div>
);
}
Errors in dynamic holes don't break the static shell.
Use cases for PPR
| Page type | Static | Dynamic |
|---|---|---|
| Marketing + personalization | Hero, features, footer | User greeting, recommendations |
| E-commerce | Product description, images | Price, inventory, reviews |
| Dashboard | Navigation, layout | Widgets, notifications, user data |
| Blog | Content, layout | View count, related posts, comments |
| News | Article content | Trending sidebar, personalized ads |
Performance benefits
| Metric | Without PPR | With PPR |
|---|---|---|
| TTFB | Waits for all data | Instant static shell |
| FCP | Delayed by dynamic content | Immediate from cache |
| LCP | Depends on slowest query | Static LCP elements load fast |
| CLS | Risk if content sizes unknown | Skeletons reserve space |
PPR optimizes for perceived performance by showing content progressively.
Limitations and considerations
| Limitation | Workaround |
|---|---|
| Experimental API | Monitor Next.js releases for changes |
| Complex data dependencies | Split into independent Suspense boundaries |
| SEO for dynamic content | Search engines see streamed content |
| Testing | Test both static shell and streaming |
Comparing rendering strategies
| Strategy | When to use |
|---|---|
| Static (SSG) | Fully static content, no user data |
| ISR | Content updates periodically, entire page |
| SSR | Fully dynamic, can't separate static/dynamic |
| PPR | Mixed static/dynamic, want instant shell |
| Client-side | Data depends on client state (auth, etc.) |
Debugging PPR
Check build output to see what's static vs dynamic:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 92 kB
├ ◐ /dashboard 8.1 kB 95 kB
└ ○ /about 3.1 kB 90 kB
○ (Static) prerendered as static content
◐ (Partial) prerendered with dynamic holes
The ◐ symbol indicates PPR pages.
Summary
Partial Prerendering serves a static shell instantly while streaming dynamic content. Enable it with experimental.ppr, wrap dynamic sections in Suspense, and design fallbacks that match final layout. PPR shines for pages mixing static marketing content with personalized data. It improves perceived performance without sacrificing dynamic features. Check Next.js docs for current experimental status and best practices.
