Partial Prerendering (PPR)
PPR is the most groundbreaking rendering mode in Next.js 16—it allows a single page to combine the lightning-fast responsiveness of a static shell with real-time content in dynamic sections.
1. What You'll Learn
- The PPR Concept: One Page = Static Shell + Dynamic Streaming Content
- How to Configure PPR
- Suspense: The Core Principle of Dynamic Boundaries
- Performance Comparison of PPR, ISR, and SSR
- Practical PPR Design for Dashboard Scenarios
2. A True Story of an Architect
(1) Pain Point: The dashboard is either entirely static (outdated data) or entirely dynamic (slow)
Diana is an architect on the TaskFlow team. The company’s SaaS dashboard faces a dilemma:
- SSG Approach: The entire page is generated during build time—the navigation bar, sidebar, and user information are all out of date. "Welcome back, Alice!" displays data from 3 days ago.
- SSR Approach: Renders anew with every request—the page takes 4 seconds to load because database queries and API calls are executed sequentially
What she wants is for the navigation bar/sidebar/layout to be static (generated at build time and cached via CDN), and for user data/notifications to be dynamic (fetched in real time). However, traditional SSG or SSR only allow one or the other—a single page can only have one rendering mode.
(2) Solution to the PPR Problem
Use PPR to split the Dashboard into a static shell (Layout + Navigation) and a dynamic area (Suspense boundary).
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { NavBar } from '@/components/NavBar'
import { UserGreeting } from '@/components/UserGreeting'
import { NotificationList } from '@/components/NotificationList'
import { Skeleton } from '@/components/Skeleton'
export default function DashboardPage() {
return (
<div>
<NavBar /> {/* Static Enclosure:Generated during build */}
<Suspense fallback={<Skeleton />}> {/* Dynamic Boundary */}
<UserGreeting />
</Suspense>
<Suspense fallback={<Skeleton />}>
<NotificationList />
</Suspense>
</div>
)
}
(3) Revenue
| Dimension | Pure SSR | Pure SSG | PPR |
|---|---|---|---|
| Time to First Byte (TTFB) | 4 seconds | 50 ms | 50 ms |
| Data Timeliness | ✅ Latest | ❌ Snapshot at build time | ✅ Real-time for dynamic regions |
| Server CPU | 85% | < 5% | < 15% |
| CDN Caching | ❌ Not Supported | ✅ Full Page | ✅ Static Shell |
| Implementation Complexity | Low | Low | Low (just add Suspense) |
3. PPR Concepts and Configuration
The core concept of PPR (Partial Prerendering) is that a page can contain both a pre-rendered static shell and dynamic streaming regions. The static parts are generated during build time, while the dynamic parts are rendered on demand.
(1) Static Shell + Dynamic Boundary
graph TB
subgraph "PPR Page"
A[Static Enclosure<br/>Layout + Navigation]
B[Suspense Boundary 1<br/>User Information - News]
C[Suspense Boundary 2<br/>List of Notices - News]
D[Suspense Boundary 3<br/>Real-Time Charts - News]
end
A --> B
A --> C
A --> D
style A fill:#d4edda
style B fill:#cce5ff
style C fill:#cce5ff
style D fill:#cce5ff
| Component Type | Rendering Timing | Caching Strategy | Typical Components |
|---|---|---|---|
| Static Shell | During build | CDN cache | Layout, NavBar, Footer, Sidebar, Logo |
| Dynamic Boundary | On request | Not cached (or short-lived cache) | UserGreeting, NotificationList, real-time charts, search |
(2) Enable PPR
PPR is disabled by default in Next.js 16 and must be enabled in next.config.ts:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: true // Open PPR
}
}
export default nextConfig
# Start the development server after installation
npm run dev
dynamic = 'force-dynamic' or cache: 'no-store' will automatically benefit from PPR optimization by default. You will see the following log during the build: ✓ PPR enabled for /dashboard
▶ Example: PPR vs. Non-PPR Comparison (Difficulty: ⭐)
Output:
The React component renders the described UI in the browser.
// app/ppr-compare/page.tsx — Open PPR Takes effect automatically afterward
import { Suspense } from 'react'
// Static Housing Section(Generated during build)
export default function PprComparePage() {
return (
<div>
<header style={{ background: '#f0f0f0', padding: 16 }}>
<h1>PPR Demo</h1>
<nav><a href="/">Home</a> | <a href="/about">About</a></nav>
</header>
{/* Dynamic Boundary:Retrieve it anew with every request */}
<Suspense fallback={<div style={{ padding: 16 }}>Loading user...</div>}>
<RealtimeUser />
</Suspense>
{/* Static Boundary:Rendered during build */}
<footer style={{ borderTop: '1px solid #ddd', padding: 16 }}>
<p>Built at: {new Date().toISOString()}</p>
</footer>
</div>
)
}
async function RealtimeUser() {
const user = await fetch('https://api.example.com/me', { cache: 'no-store' })
.then(r => r.json())
return <div style={{ padding: 16 }}>Welcome, {user.name}</div>
}
Output:
Renders: Dashboard with static sidebar + dynamic user profile and project stats in Suspense boundaries.
4. Suspense: Boundaries as Dynamic Boundaries
The core principle of PPR: The content of every <Suspense> wrapper is a dynamically rendered boundary. Sections not wrapped by Suspense are static shells that are pre-rendered at build time.
(1) Boundary Rules
graph LR
A[Page Components] --> B[Static Content<br/>No Suspense]
A --> C[Suspense boundary]
C --> D[Dynamic Child Components<br/>Render anew on every request]
A --> E[Another one Suspense]
E --> F[Separate Dynamic Zones]
| Package Status | PPR Behavior | Example |
|---|---|---|
✅ Wrapped in <Suspense> |
Dynamic — Rendered on request, real-time content | User information, inventory data |
❌ No <Suspense> |
Static — Rendered during build, cached by CDN | Navigation bar, footer, logo |
(2) Avoid Unnecessary Suspense
If a component doesn't need real-time data, don't wrap it in Suspense—that way, it will become part of the static shell.
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<div>
{/* ✅ Static:The sidebar has always been there,Real-time updates are not required */}
<Sidebar />
{/* ✅ News:Need to retrieve in real time */}
<Suspense fallback={<LoadingSpinner />}>
<RealtimeData />
</Suspense>
{/* ❌ Unnecessary Suspense:This component has no dynamic data. */}
<Suspense fallback={<LoadingSpinner />}>
<StaticAboutSection /> {/* No package required */}
</Suspense>
</div>
)
}
▶ Example: Loading Sequence for Multiple Suspense Boundaries (Difficulty: ⭐⭐)
Output:
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: }>
Visible text: }> | }>
// app/ppr-timing/page.tsx
import { Suspense } from 'react'
export default function PprTimingPage() {
return (
<div>
<h1>PPR Timing Demo</h1>
{/* Static Enclosure:Show Now */}
<p>This appears instantly (static shell)</p>
{/* Dynamic Boundary 1:2 Display after seconds */}
<Suspense fallback={<div>⏳ Loading section 1...</div>}>
<DelayedSection label="Section 1" delay={2000} />
</Suspense>
{/* Dynamic Boundary 2:4 Display after seconds,Independent of the Boundary 1 */}
<Suspense fallback={<div>⏳ Loading section 2...</div>}>
<DelayedSection label="Section 2" delay={4000} />
</Suspense>
</div>
)
}
async function DelayedSection({ label, delay }: { label: string; delay: number }) {
await new Promise(resolve => setTimeout(resolve, delay))
return <div>✅ {label} loaded after {delay}ms</div>
}
Output:
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: ⏳ Loading section 1...
Visible text: PPR Timing Demo | This appears instantly (static shell) | ⏳ Loading section 1... | }>
5. PPR vs. ISR vs. SSR Performance Comparison
Each of the three rendering modes has its own use cases, and PPR fills the gap for "partially static + partially dynamic" scenarios.
| Dimension | SSR | ISR | PPR |
|---|---|---|---|
| Rendering Timing | On Every Request | Build + On-Demand in the Background | Build (Static Shell) + Request (Dynamic Content) |
| Caching Policy | Not Cached by CDN | Full-Page CDN Caching | Static Shell CDN + Dynamic Areas Not Cached |
| Real-time | ✅ Latest | ⚠️ Maximum delay = revalidate | ✅ Real-time for dynamic regions |
| Server Load | High | Low | Low (renders only dynamic parts) |
| Use Cases | Personalized and Authentication Pages | Content Sites and Blogs | Dashboards and Hybrid Pages |
| Time to First View | Slow (waiting for server-side rendering) | Fast (cached) | Fast (static shell, instant) |
(1) Selection Recommendations
graph TB
A[This page requires?] --> B{Real-time Data?}
B -->|The entire page is updated in real time| C[SSR]
B -->|Real-time in some areas| D[PPR]
B -->|No real-time data| E{Update Frequency?}
E -->|Frequent| F[ISR]
E -->|Virtually unchanged| G[SSG]
style D fill:#d4edda
▶ Example: PPR Dashboard Component Design (Difficulty: ⭐⭐⭐)
Output:
Diagram of rendering strategy: static shell + dynamic Suspense boundaries.
// app/dashboard-ppr/page.tsx — PPR Practical Design
import { Suspense } from 'react'
// ======== Static Housing Assembly ========
function DashboardHeader() {
return (
<header style={{ background: '#1a1a2e', color: 'white', padding: '16px 24px' }}>
<h1 style={{ margin: 0 }}>TaskFlow Dashboard</h1>
</header>
)
}
function Sidebar() {
return (
<nav style={{ width: 240, background: '#f5f5f5', padding: 16, minHeight: 'calc(100vh - 64px)' }}>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li><a href="/">🏠 Home</a></li>
<li><a href="/projects">📁 Projects</a></li>
<li><a href="/tasks">✅ Tasks</a></li>
<li><a href="/analytics">📊 Analytics</a></li>
<li><a href="/settings">⚙️ Settings</a></li>
</ul>
</nav>
)
}
// ======== Dynamic Boundary Component ========
async function UserProfile() {
const user = await fetch('https://api.example.com/me', { cache: 'no-store' }).then(r => r.json())
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<img src={user.avatar} alt="" style={{ borderRadius: '50%', width: 40, height: 40 }} />
<div>
<strong>{user.name}</strong>
<p style={{ margin: 0, fontSize: 12, color: '#666' }}>{user.role}</p>
</div>
</div>
)
}
async function ProjectStats() {
const stats = await fetch('https://api.example.com/stats', { next: { revalidate: 60 } }).then(r => r.json())
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}>
<StatCard label="Active Projects" value={stats.activeProjects} color="#4caf50" />
<StatCard label="Pending Tasks" value={stats.pendingTasks} color="#ff9800" />
<StatCard label="Completed" value={stats.completed} color="#2196f3" />
</div>
)
}
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div style={{ border: `1px solid ${color}`, borderRadius: 8, padding: 16, textAlign: 'center' }}>
<p style={{ fontSize: 28, fontWeight: 'bold', color, margin: 0 }}>{value}</p>
<p style={{ margin: 0, color: '#666', fontSize: 14 }}>{label}</p>
</div>
)
}
export default function DashboardPprPage() {
return (
<div style={{ display: 'flex' }}>
<Sidebar />
<main style={{ flex: 1, padding: 24 }}>
<DashboardHeader />
<Suspense fallback={<div>Loading profile...</div>}>
<UserProfile />
</Suspense>
<div style={{ height: 24 }} />
<Suspense fallback={<div>Loading stats...</div>}>
<ProjectStats />
</Suspense>
</main>
</div>
)
}
Output:
Fetches data server-side and renders the result in the page.
Visible content: TaskFlow Dashboard
6. Complete Example: PPR E-commerce Dashboard
// app/ppr-ecommerce/page.tsx — PPR E-commerce Dashboard
import { Suspense } from 'react'
import { cookies } from 'next/headers'
// ======== Static Enclosure ========
function StoreHeader() {
return (
<header style={{ background: '#2c3e50', color: 'white', padding: '12px 24px', display: 'flex', justifyContent: 'space-between' }}>
<strong>Store Dashboard</strong>
<span>Built: {new Date().toISOString().split('T')[0]}</span>
</header>
)
}
function Navigation() {
return (
<nav style={{ background: '#34495e', padding: '8px 24px', display: 'flex', gap: 24, color: 'white' }}>
<a href="/ppr-ecommerce" style={{ color: 'white' }}>Overview</a>
<a href="/ppr-ecommerce/orders" style={{ color: 'white' }}>Orders</a>
<a href="/ppr-ecommerce/products" style={{ color: 'white' }}>Products</a>
</nav>
)
}
// ======== Dynamic Components ========
async function LiveOrderFeed() {
const orders = await fetch('https://api.example.com/orders/recent', {
cache: 'no-store'
}).then(r => r.json())
return (
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
<h2>Live Orders ({orders.length})</h2>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Total</th></tr></thead>
<tbody>
{orders.map((o: any) => (
<tr key={o.id}>
<td>#{o.id}</td>
<td>{o.customer}</td>
<td><StatusBadge status={o.status} /></td>
<td>${o.total}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
pending: '#ff9800', shipped: '#2196f3', delivered: '#4caf50', cancelled: '#f44336'
}
return <span style={{ background: colors[status] ?? '#ccc', color: 'white', padding: '2px 8px', borderRadius: 12, fontSize: 12 }}>{status}</span>
}
async function RevenueWidget() {
const revenue = await fetch('https://api.example.com/revenue/today', {
next: { revalidate: 300 }
}).then(r => r.json())
return (
<div style={{ background: 'linear-gradient(135deg, #667eea, #764ba2)', color: 'white', borderRadius: 8, padding: 24 }}>
<h2 style={{ margin: 0, fontSize: 14, opacity: 0.8 }}>Today's Revenue</h2>
<p style={{ fontSize: 36, fontWeight: 'bold', margin: '8px 0' }}>${revenue.total}</p>
<p style={{ margin: 0, fontSize: 12, opacity: 0.8 }}>↑ {revenue.growth}% vs yesterday</p>
</div>
)
}
async function TopProducts() {
const products = await fetch('https://api.example.com/products/top', {
next: { tags: ['top-products'] }
}).then(r => r.json())
return (
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
<h2>Top Products</h2>
<ol>{products.slice(0, 5).map((p: any) => (
<li key={p.id}>{p.name} — {p.sold} sold</li>
))}</ol>
</div>
)
}
// ======== Page(PPR Mixed) ========
export default function PprEcommercePage() {
return (
<div>
<StoreHeader />
<Navigation />
<main style={{ padding: 24, display: 'grid', gap: 24, gridTemplateColumns: '2fr 1fr' }}>
<div>
<Suspense fallback={<div>Loading orders...</div>}>
<LiveOrderFeed />
</Suspense>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<Suspense fallback={<div>Loading revenue...</div>}>
<RevenueWidget />
</Suspense>
<Suspense fallback={<div>Loading products...</div>}>
<TopProducts />
</Suspense>
</div>
</main>
</div>
)
}
▶ Example: Build Artifact Verification (Difficulty: ⭐)
# View the static shell output after building
npm run build
# In .next/server/app/ppr-ecommerce, scroll down to view
ls .next/server/app/ppr-ecommerce/
Output:
page.html ← Static Enclosure HTML(Navigation Bar、Layout)
page.rsc ← RSC Payload(Static Section)
page_stream.html ← Flow-Through Partial Injection Point
▶ Example: PPR Static Shell + Dynamic Data Validation (Difficulty: ⭐⭐)
Output:
The page renders as described above, with the UI updating based on the described behavior.
// app/ppr-verify/page.tsx
import { Suspense } from 'react'
function StaticHeader() {
return (
<header style={{ borderBottom: '2px solid #333', padding: 16, marginBottom: 16 }}>
<h1>PPR Verification Page</h1>
<p>Build timestamp: {new Date().toISOString()}</p>
<nav><a href="/">Home</a> | <a href="/ppr-verify">Refresh</a></nav>
</header>
)
}
async function DynamicContent() {
const res = await fetch('http://worldtimeapi.org/api/timezone/Etc/UTC', {
cache: 'no-store'
}).then(r => r.json())
return (
<div style={{ background: '#e3f2fd', padding: 16, borderRadius: 8 }}>
<h2>Live Server Time</h2>
<p style={{ fontSize: 24 }}>{res.datetime}</p>
</div>
)
}
export default function PprVerifyPage() {
return (
<div>
<StaticHeader />
<Suspense fallback={<div style={{ padding: 16 }}>⏳ Loading live time...</div>}>
<DynamicContent />
</Suspense>
<footer style={{ marginTop: 32, color: '#666' }}>
<p>Header is static shell (cached). Dynamic content updates per request.</p>
</footer>
</div>
)
}
Output:
Fetches data server-side and renders the result in the page.
Visible content: PPR Verification Page
❓ FAQ
dynamic = 'force-dynamic' will use PPR. However, only pages containing the <Suspense> boundary will have the "static shell + dynamic region" effect. Pages without Suspense will remain purely static (SSG behavior).npm run build && npm run start to test production mode.📖 Summary
- PPR splits a page into a static shell (generated at build time) and a dynamic boundary (rendered at request time)
<Suspense>The boundary is a dynamic boundary—content without Suspense is a static shell- Set
experimental.ppr = trueinnext.config.tsto enable PPR - PPR loads the first screen 10 to 100 times faster than SSR (static shell cached via CDN), while dynamic sections remain real-time
- Static containers are suitable for: navigation bars, sidebars, footers, logos, and static text
- Dynamic borders are suitable for: user information, notifications, real-time charts, search, and personalized content
- PPR is the best compromise between SSG and SSR and is suitable for most pages with mixed content.
📝 Exercises
-
Basic Exercise (⭐): Enable PPR in
next.config.ts, create aapp/ppr-basic/page.tsxthat contains a static<header>section and a dynamic<Suspense>section (call the API to retrieve the current time), and verify that the build output includes static HTML. -
Advanced Problem (⭐⭐): Create a
app/ppr-dashboard/page.tsxthat contains at least 3 Suspense boundaries (user information, notification list, real-time statistics), each with its own fallback. Ensure that the navigation bar and sidebar are static wrappers. After building, verify thatpage.htmlincludes the navigation bar but does not contain any dynamic content. -
Challenge (⭐⭐⭐): Build a PPR multi-page dashboard—
app/ppr-portal/—with three subpages: Overview, Orders, and Analytics. These pages share a static layout (top navigation + sidebar), and each page contains 2–4 dynamic Suspense boundaries. Add Server Actions to allow users to submit data within the dynamic areas, triggeringrevalidateTag()to refresh the corresponding dynamic areas.



