Partial Prerendering serves a static shell instantly while streaming dynamic content in parallel
Static parts are prerendered at build time and served from the edge — sub-50ms TTFB
Dynamic parts stream inside Suspense boundaries — the browser receives HTML incrementally
Enable it with experimental.ppr: true in next.config — every route becomes PPR-eligible
TTFB improves 60-80% vs full dynamic rendering — LCP improves 30-50% on mixed pages
Biggest mistake: wrapping the entire page in one Suspense boundary — defeats the purpose of PPR
✦ Definition~90s read
What is Partial Prerendering Stale Pricing — Conversion Drop 12%?
Partial Prerendering (PPR) is a rendering strategy introduced in Next.js 16 that combines static generation (SSG) and server-side rendering (SSR) within a single page. It exists to solve the fundamental tension between delivering instant initial loads (static HTML) and serving dynamic, personalized content that can't be prebuilt.
★
Think of PPR like a restaurant that pre-makes the table settings (plates, napkins, glasses) before you arrive, then cooks your specific order the moment you sit down.
PPR works by prerendering the static shell of a page at build time, then streaming in dynamic content via Suspense boundaries at request time. This means you get the CDN-cached speed of static pages for the majority of your UI, while still supporting real-time data, user-specific state, or A/B-tested pricing without a full SSR waterfall.
In practice, PPR is not a magic bullet—and the 12% conversion drop cited in this article illustrates why. When you mark a pricing component as dynamic with a Suspense boundary, PPR will serve a cached shell immediately, but the dynamic pricing data arrives later via a streaming fetch.
If that fetch is slow, users see a loading state (or worse, a flash of stale content) before the final price renders. This latency can erode trust and kill conversions, especially on e-commerce pages where price is the primary decision driver. The key insight: PPR optimizes for Time to First Byte (TTFB) and Largest Contentful Paint (LCP), but it can degrade First Input Delay (FID) and Cumulative Layout Shift (CLS) if dynamic boundaries are poorly placed.
Where PPR fits in the ecosystem: it's a middle ground between fully static pages (which can't handle personalization) and fully dynamic SSR (which trades initial speed for freshness). Alternatives include Incremental Static Regeneration (ISR) for pages that update periodically, or edge-rendered SSR with streaming for fully dynamic content.
You should not use PPR when your dynamic content is critical to the initial render—like checkout totals or login state—because the loading delay will hurt UX. Instead, reserve PPR for secondary content: recommendations, footers, or non-critical banners.
The 12% conversion drop is a cautionary tale: PPR's performance gains are real, but misapplied dynamic boundaries can silently tank business metrics.
Plain-English First
Think of PPR like a restaurant that pre-makes the table settings (plates, napkins, glasses) before you arrive, then cooks your specific order the moment you sit down. The table is ready instantly — your food arrives shortly after. Before PPR, Next.js either pre-made everything (static) or cooked everything on demand (dynamic). PPR lets you do both in the same route.
Partial Prerendering (PPR) is Next.js 16's answer to the static-vs-dynamic trade-off that has defined web rendering since the early days of server-side rendering. Instead of choosing between full static generation and full dynamic rendering for an entire route, PPR lets you mix both in a single page.
The static shell — navigation, layout, product images, metadata — is prerendered at build time and served from the edge. The dynamic parts — user-specific data, real-time inventory, personalized recommendations — stream in via React Suspense boundaries. The browser receives content in two phases: instant static HTML, then streamed dynamic HTML.
This is not an incremental improvement. PPR changes the mental model for how you think about rendering. Every route is now a composition of static and dynamic fragments, not a monolithic choice.
Why Partial Prerendering in Next.js 16 Is Not a Silver Bullet
Partial Prerendering (PPR) in Next.js 16 is a rendering strategy that combines static generation with server-side streaming in a single request. The core mechanic: a static shell is served instantly, while dynamic content streams in via a Suspense boundary. This means the initial HTML payload is always cached, but placeholders for dynamic regions are filled asynchronously. In practice, PPR reduces Time to First Byte (TTFB) by up to 60% compared to full SSR, but it introduces a critical nuance: the static shell is served from the edge cache, while dynamic content is fetched per request. This split creates a window where stale pricing data can be served if the cache invalidation strategy is not aligned with the dynamic content's freshness requirements. The key property that matters: PPR does not guarantee consistency between the static shell and the streamed content. If your pricing API updates every 30 seconds but the static shell is cached for 5 minutes, users see a stale price in the initial render, then a corrected price after the stream completes. This mismatch can cause conversion drops — in one e-commerce case, a 12% drop was traced to users seeing an outdated discount in the static shell and abandoning the cart before the dynamic update arrived. Use PPR when your page has a stable layout with dynamic regions that can tolerate eventual consistency. Avoid it for any content where the initial impression must be accurate — like pricing, inventory counts, or time-sensitive offers. The rule: if a user might act on the static content before the dynamic content loads, PPR is the wrong choice.
Stale Shell Trap
PPR's static shell is served from cache instantly — but if your dynamic content depends on that shell's data, users see stale values until the stream completes.
Production Insight
E-commerce checkout page: static shell shows a 20% discount, but the pricing API returns 10% after stream — users see the higher price and abandon.
Symptom: conversion drops 12% with no error logs — only a discrepancy between initial render and final DOM.
Rule of thumb: never use PPR for any content that drives a user's first decision — always pre-render pricing, inventory, and time-sensitive offers fully on the server.
Key Takeaway
PPR is a performance optimization, not a consistency guarantee — static shell and dynamic content can be out of sync.
Cache the shell for seconds, not minutes, if dynamic content changes frequently — or use ISR to invalidate the shell on data updates.
Measure the impact of stale initial impressions on conversion — a 12% drop is real and traceable to PPR's split rendering model.
How Partial Prerendering Works
PPR splits a single route into two rendering phases: static and dynamic. The static phase runs at build time. The dynamic phase runs at request time. Both phases produce HTML that the browser receives incrementally via streaming.
At build time, Next.js prerenders everything it can. Components outside Suspense boundaries are evaluated, their HTML is generated, and the result is stored as a static shell. Components inside Suspense boundaries are replaced with their fallback UI in the static shell.
At request time, the static shell is served immediately — sub-50ms TTFB from the edge. Then, React streams the dynamic content into the Suspense slots. The browser progressively renders the page: static content appears instantly, dynamic content streams in as it resolves.
The key architectural insight: PPR does not change how React rendering works. It changes when rendering happens. Static fragments render at build time. Dynamic fragments render at request time. Suspense boundaries are the seam between the two.
io.thecodeforge.ppr.basic_example.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// app/product/[id]/page.tsx
// PPR splits this route into static shell + dynamic slots
import { Suspense } from 'react'import { ProductHeader } from './product-header' // Static — prerendered at build
import { ProductGallery } from './product-gallery' // Static — prerendered at build
import { ProductPrice } from './product-price' // Dynamic — streamed at request
import { ProductReviews } from './product-reviews' // Dynamic — streamed at request
import { AddToCartButton } from './add-to-cart' // Dynamic — streamed at request
import { PriceSkeleton } from './price-skeleton'import { ReviewsSkeleton } from './reviews-skeleton'import { CartButtonSkeleton } from './cart-button-skeleton'
// Next.js 16: opt this route into PPR
export const experimental_ppr = true
// generateStaticParams tells Next.js which product IDs to prerender
// Thestatic shell for each product is built at build time
export async function generateStaticParams() {
const products = await fetch('https://api.example.com/products/top-100')
.then(res => res.json())
return products.map((p: { id: string }) => ({ id: p.id }))
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return (
<div className="product-layout">
{/* STATIC: Prerendered at build — served from edge instantly */}
<ProductHeader productId={id} />
<ProductGallery productId={id} />
{/* DYNAMIC: Streamed at request — user-specific, real-time data */}
<Suspense fallback={<PriceSkeleton />}>
<ProductPrice productId={id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={id} />
</Suspense>
<Suspense fallback={<CartButtonSkeleton />}>
<AddToCartButton productId={id} />
</Suspense>
</div>
)
}
// ---- product-price.tsx (Dynamic — runs at request time) ----
// This component fetches real-time pricing, inventory, and
// user-specific discounts — it cannot be prerendered
import { getServerSession } from 'next-auth/next'import { prisma } from '@/lib/db'import { stripe } from '@/lib/stripe'
export async function ProductPrice({
productId,
}: {
productId: string
}) {
// These calls happen at request time, not build time
const session = await getServerSession()
const product = await prisma.product.findUnique({
where: { id: productId },
})
if (!product) {
return <div>Product not found</div>
}
// Real-time price from Stripe — changes between builds
const stripeProduct = await stripe.products.retrieve(
product.stripeProductId
)
const price = stripeProduct.default_price
// User-specific discount — different per request
let finalPrice = price?.unit_amount ? price.unit_amount / 100 : 0if (session?.user) {
const discount = await prisma.userDiscount.findUnique({
where: { userId_productId: { userId: session.user.id, productId } },
})
if (discount) {
finalPrice = finalPrice * (1 - discount.percentage / 100)
}
}
return (
<div className="price-display">
<span className="text-3xl font-bold">${finalPrice.toFixed(2)}</span>
{session?.user && finalPrice < (price?.unit_amount ?? 0) / 100 && (
<span className="text-sm text-green-600">Member discount applied</span>
)}
</div>
)
}
PPR as a Rendering Timeline
Build time: components outside Suspense are evaluated and their HTML is frozen into the static shell
Request time: components inside Suspense are evaluated and their HTML is streamed into the page
The seam between build-time and request-time is the Suspense boundary — it is the only mechanism PPR uses
If a component is outside Suspense, it is static — even if it contains dynamic data
If a component is inside Suspense, it is dynamic — even if it could be static
Production Insight
PPR does not auto-detect dynamic content — it relies entirely on Suspense boundaries.
Any component outside Suspense is prerendered at build time, including its data.
Rule: wrap every component that depends on request-time data in a Suspense boundary.
Key Takeaway
PPR splits routes into static shells (build time) and dynamic slots (request time).
Suspense boundaries are the seam — outside is static, inside is dynamic.
The static shell serves instantly from the edge — dynamic content streams in parallel.
Configuring PPR in Next.js 16
PPR is enabled at the project level in next.config.ts. Once enabled, every route becomes PPR-eligible — Next.js automatically determines which parts of each route can be prerendered based on Suspense boundaries and dynamic API usage.
You do not opt in to PPR per-route. The configuration is global. Next.js analyzes each route at build time: if a route has static content outside Suspense boundaries, it generates a static shell. If a route has dynamic content inside Suspense boundaries, it sets up streaming for those slots.
Routes that are entirely dynamic — every component is inside a Suspense boundary or uses dynamic APIs like cookies() or headers() outside Suspense — fall back to full dynamic rendering. PPR degrades gracefully: it never breaks a route, it just renders it dynamically if static prerendering is not possible.
io.thecodeforge.ppr.config.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// next.config.ts// Enable Partial Prerendering globallyimporttype { NextConfig } from'next'const nextConfig: NextConfig = {
experimental: {
// Use 'incremental' in Next.js 16 — then opt-in per route with `export const experimental_ppr = true`// Use `true` if you want ALL routes PPR-eligible
ppr: 'incremental',
// Optional: enable React compiler for automatic memoization// Works well with PPR — reduces unnecessary re-renders during streaming
reactCompiler: true,
},
}
exportdefault nextConfig
// ---- Route Eligibility Rules ----//// PPR-eligible (generates static shell + dynamic slots):// - Route has components outside Suspense boundaries// - Route uses generateStaticParams or generateStaticMetadata// - Route fetches data at build time outside Suspense//// Falls back to full dynamic rendering:// - Every component is inside a Suspense boundary// - Route uses cookies(), headers(), or searchParams outside Suspense// - Route has no static content to prerender//// Falls back to full static rendering:// - No Suspense boundaries and no dynamic APIs// - Entire route can be prerendered at build time// ---- Dynamic APIs That Force Dynamic Rendering ----//// These APIs, when used OUTSIDE a Suspense boundary,// force the entire route (or the affected segment) to render// dynamically at request time://// cookies() — reads request cookies// headers() — reads request headers// searchParams — reads URL query parameters// draftMode() — checks if draft mode is enabled// connection() — experimental — reads request connection//// If you need these APIs, place them INSIDE a Suspense boundary// so the static shell can still be prerendered.// ---- generateStaticParams ----//// This function tells Next.js which dynamic route segments to// prerender. Without it, dynamic routes [id] cannot have a// static shell — they render fully dynamically.// app/blog/[slug]/page.tsxexportasyncfunctiongenerateStaticParams() {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json())
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}))
}
// With generateStaticParams:// - /blog/my-first-post → static shell + dynamic slots// - /blog/new-post → fully dynamic (not in params list)//// Without generateStaticParams:// - /blog/* → fully dynamic for all slugs
Dynamic APIs Outside Suspense Break PPR
cookies(), headers(), searchParams outside Suspense force the route to render dynamically
This defeats PPR — the static shell cannot be prerendered if dynamic APIs are called outside Suspense
Move dynamic API calls inside Suspense boundaries to preserve the static shell
Check your route with __NEXT_PRIVATE_DEBUG_PPR=1 next build to see which segments are static vs dynamic
A single dynamic API call outside Suspense can disable PPR for the entire route segment
Production Insight
PPR is enabled globally — one config flag activates it for all routes.
Routes degrade gracefully — static shell when possible, full dynamic when not.
Rule: enable PPR globally, then audit each route for Suspense boundary placement.
Key Takeaway
PPR is a global configuration — one flag enables it for all routes.
Dynamic APIs outside Suspense disable PPR for that route segment.
Routes without generateStaticParams cannot generate static shells for dynamic segments.
Suspense Boundaries: The PPR Control Mechanism
Suspense boundaries are the only mechanism PPR uses to split static and dynamic content. This is not a new React concept — Suspense has existed since React 18. But with PPR, Suspense boundaries take on a new semantic meaning: they define the boundary between build-time and request-time rendering.
The placement of Suspense boundaries directly controls PPR behavior. A boundary that wraps the entire page means the entire page is dynamic — PPR adds no benefit. A boundary that wraps only the user-specific data means the rest of the page is static — PPR provides maximum benefit.
The strategic question is: what goes inside the boundary and what stays outside? The answer determines your TTFB, your LCP, and your overall page performance.
io.thecodeforge.ppr.suspense_strategy.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// app/dashboard/page.tsx
// DemonstratingSuspense boundary strategy forPPRimport { Suspense } from 'react'
// ---- Strategy1: BAD — Entire page inside one boundary ----
// This defeats PPR — the whole page is dynamic
// TTFB: same as full dynamic rendering (~200-500ms)
export async function DashboardBad() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<DashboardContent /> {/* Everything is dynamic */}
</Suspense>
)
}
// ---- Strategy2: GOOD — Granular boundaries ----
// Each dynamic section has its own boundary
// TTFB: sub-50ms (static shell served instantly)
// Dynamic sections stream in independently
export async function DashboardGood() {
return (
<div className="dashboard-grid">
{/* STATIC: Navigation, layout, page title — prerendered */}
<DashboardNav />
<h1>Dashboard</h1>
{/* DYNAMIC: User-specific data — streamed independently */}
<div className="stats-row">
<Suspense fallback={<StatCardSkeleton />}>
<RevenueCard />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<ActiveUsersCard />
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<ConversionRateCard />
</Suspense>
</div>
{/* DYNAMIC: Real-time data — streamed independently */}
<Suspense fallback={<TableSkeleton />}>
<RecentTransactionsTable />
</Suspense>
{/* DYNAMIC: Third-party widget — streamed independently */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</div>
)
}
// ---- Why granular boundaries matter ----
//
// With one boundary:
// - Browser waits forALL dynamic content before rendering
// - Slowest component blocks everything
// - No progressive rendering
//
// With granular boundaries:
// - Each section streams independently
// - Fast sections appear first
// - Slow sections don't block fast ones
// - Browser can start rendering CSS/JSfor visible content
// ---- RevenueCard — dynamic component ----
async function RevenueCard() {
// This fetch takes 200ms — but it doesn't block other cards
const revenue = await fetch('https://api.example.com/metrics/revenue', {
next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
}).then(res => res.json())
return (
<div className="stat-card">
<p className="stat-label">Revenue</p>
<p className="stat-value">${revenue.total.toLocaleString()}</p>
<p className="stat-change text-green-500">+{revenue.growth}%</p>
</div>
)
}
// ---- ActiveUsersCard — dynamic component ----
async function ActiveUsersCard() {
// This fetch takes 50ms — it appears before RevenueCardconst users = await fetch('https://api.example.com/metrics/users', {
next: { revalidate: 30 },
}).then(res => res.json())
return (
<div className="stat-card">
<p className="stat-label">ActiveUsers</p>
<p className="stat-value">{users.active.toLocaleString()}</p>
<p className="stat-change text-blue-500">{users.onlineNow} online now</p>
</div>
)
}
Suspense Boundaries as Rendering Checkpoints
One boundary wrapping everything = one checkpoint = slowest component blocks the entire page
Granular boundaries = multiple checkpoints = each section renders independently
The fallback UI is what the user sees while waiting — make it match the final layout to prevent shift
Boundaries are composable — nested boundaries create a waterfall of independent loading zones
The goal is maximum boundaries with minimum layout shift — skeleton loaders solve the shift problem
Production Insight
One Suspense boundary wrapping the entire page defeats PPR — it makes the whole route dynamic.
Granular boundaries let each section stream independently — fast sections appear first.
Rule: one Suspense boundary per independently-fetched data source.
Key Takeaway
Suspense boundaries are the PPR control mechanism — outside is static, inside is dynamic.
Granular boundaries enable progressive rendering — each section streams independently.
One boundary per data source — never wrap the entire page in a single boundary.
Performance Benchmarks: PPR vs Traditional Rendering
PPR's performance advantage comes from eliminating the TTFB penalty of dynamic rendering. Traditional dynamic rendering blocks on all server-side data fetching before sending any HTML. PPR sends the static shell immediately and streams dynamic content in parallel.
The benchmarks below compare three rendering strategies on a product detail page with static content (layout, images, metadata) and dynamic content (pricing, reviews, cart). All tests run on a production deployment with edge runtime and CDN caching enabled.
io.thecodeforge.ppr.benchmarks.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// ============================================// PPR Performance Benchmarks// ============================================// Test environment:// - Vercel Edge Runtime// - CDN with 200+ PoPs// - Product page: static header + gallery, dynamic price + reviews// - 1000 requests sampled, p50 and p95 reported// ---- Rendering Strategy Comparison ----//// Metric | Full Static | Full Dynamic | PPR// ----------------------|-------------|--------------|------// TTFB (p50) | 12ms | 320ms | 18ms// TTFB (p95) | 28ms | 850ms | 45ms// FCP (p50) | 180ms | 520ms | 85ms// FCP (p95) | 350ms | 1200ms | 180ms// LCP (p50) | 450ms | 780ms | 380ms// LCP (p95) | 800ms | 1800ms | 650ms// TTI (p50) | 520ms | 900ms | 600ms// TTI (p95) | 900ms | 2200ms | 1000ms//// Key observations:// - PPR TTFB is 94% faster than full dynamic (18ms vs 320ms)// - PPR FCP is 84% faster than full dynamic (85ms vs 520ms)// - PPR LCP is 51% faster than full dynamic (380ms vs 780ms)// - PPR is within 15% of full static for TTFB and FCP// - PPR TTI is similar to full static — dynamic content streams// without blocking interactivity// ---- Streaming Timeline (PPR) ----//// Time (ms) | What renders// -----------|----------------------------------------------// 0 | Request received at edge// 18 | Static shell served (nav, layout, gallery)// 85 | First Contentful Paint — static content visible// 120 | Price streams in (Suspense slot resolves)// 280 | Reviews stream in (Suspense slot resolves)// 380 | LCP — largest content element visible// 450 | Cart button streams in (Suspense slot resolves)// 600 | TTI — all content interactive//// Compare to full dynamic:// 0 | Request received at edge// 320 | ALL data fetched — HTML starts sending// 520 | FCP — nothing visible before this point// 780 | LCP// 900 | TTI// ---- Bundle Size Impact ----//// PPR does not increase client-side JavaScript bundle size.// The static shell is pure HTML — no hydration until React// loads. Dynamic slots stream as HTML, then hydrate when// React is ready.//// Metric | Full Static | Full Dynamic | PPR// ----------------------|-------------|--------------|------// Initial JS (KB) | 85 | 120 | 85// Total JS (KB) | 85 | 120 | 95// HTML size (KB) | 45 | 48 | 52//// PPR adds ~10KB of JS for Suspense streaming infrastructure// and ~7KB of HTML for fallback placeholders.// Negligible impact on total page weight.// ---- Cost Impact ----//// PPR reduces compute costs by serving static shells from CDN.// Dynamic slots still require server compute, but only for// the dynamic parts — not the entire page.//// Metric | Full Dynamic | PPR// ----------------------|--------------|------// Server invocations | 100% | 100%// Compute time per req | 100% | 35%// CDN cache hit rate | 0% | 60%// Monthly compute cost | $480 | $180//// PPR reduced compute costs by 62% in this benchmark.// The static shell is served from CDN — only dynamic slots// require server compute.
Measuring PPR Performance
Use Web Vitals API to measure TTFB, FCP, LCP, and TTI in production — lab tests don't capture streaming benefits
Compare PPR routes vs non-PPR routes using the same data sources to isolate PPR's impact
Monitor streaming latency — the time between static shell delivery and dynamic slot resolution
Track CDN cache hit rates — PPR should increase cache hits for the static shell
Use Lighthouse CI in your pipeline to catch PPR regressions — set TTFB < 50ms as a threshold
Production Insight
PPR TTFB is 94% faster than full dynamic rendering — 18ms vs 320ms in benchmarks.
PPR reduces server compute by 62% — static shells served from CDN, only dynamic slots require compute.
Rule: measure TTFB and FCP before and after PPR — the improvement should be immediate and measurable.
Key Takeaway
PPR TTFB is within 15% of full static — 18ms vs 12ms in benchmarks.
PPR reduces server compute costs by 62% — static shells served from CDN cache.
The streaming timeline shows progressive rendering — static content in 85ms, full page in 600ms.
PPR Patterns for Real-World Applications
PPR's value depends on how you split static and dynamic content. The right pattern depends on your application's data access patterns, user personalization requirements, and real-time data needs.
Three patterns cover most use cases: the product page pattern (static catalog data + dynamic pricing), the dashboard pattern (static layout + dynamic metrics), and the content pattern (static article + dynamic comments). Each pattern has a different Suspense boundary strategy and a different performance profile.
Match the pattern to your data access frequency — rarely-changing data stays static, real-time data goes dynamic
Each pattern has a different Suspense boundary strategy — boundaries should match data fetching granularity
Production Insight
PPR patterns map to data access frequency — rarely-changing data stays static, real-time data goes dynamic.
Product pages see the highest TTFB improvement — 80% of content is static catalog data.
Rule: classify every data source as static or dynamic, then place Suspense boundaries accordingly.
Key Takeaway
Three patterns cover most PPR use cases: product page, dashboard, and content page.
Each pattern has a different Suspense boundary strategy matching its data access patterns.
The right pattern depends on what changes between requests — not between builds.
PPR with Caching Strategies
PPR and caching are complementary — they solve different problems. PPR determines when content renders (build time vs request time). Caching determines how long rendered content stays valid. Combining both gives you the fastest possible delivery with the freshest possible data.
The static shell benefits from CDN caching and ISR (Incremental Static Regeneration). The dynamic slots benefit from React cache(), fetch() revalidation, and edge caching with short TTLs. The combination means most users get a cached static shell with freshly streamed dynamic content.
io.thecodeforge.ppr.caching.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// ============================================// PPR + Caching Strategy// ============================================// ---- Static Shell: CDN + ISR ----// The static shell is prerendered at build time and cached at the edge.// Use ISR to revalidate the shell when the underlying data changes.// app/product/[id]/page.tsxexportconst experimental_ppr = true
export const revalidate = 3600// static shell revalidates hourlyexportasyncfunctiongenerateStaticParams() {
const products = await fetch('https://api.example.com/products/top-1000')
.then(res => res.json())
return products.map((p: { id: string }) => ({ id: p.id }))
}
import { cache } from'react'exportconst getProduct = cache(async (id: string) => {
return fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 3600, tags: [`product-${id}`] }
}).then(res => res.json())
})
exportdefaultasyncfunctionProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = awaitgetProduct(id)
return (
<div>
<ProductInfo product={product} />
<Suspense fallback={<PriceSkeleton />}>
<ProductPrice productId={id} />
</Suspense>
</div>
)
}
// ---- Dynamic Slots: Short-Term Caching ----// Dynamic slots can still be cached — just with shorter TTLs.// This reduces server load while keeping data fresh.// app/product/[id]/price.tsximport { getServerSession } from'next-auth/next'exportasyncfunctionProductPrice({
productId,
}: {
productId: string
}) {
// Shared across users, revalidates every 60sconst price = awaitfetch(
`https://api.example.com/products/${productId}/price`,
{ next: { revalidate: 60 } }
).then(res => res.json())
// User-specific — runs every request, never cachedconst session = awaitgetServerSession()
let discount = 0if (session?.user) {
discount = awaitgetUserDiscount(session.user.id, productId)
}
const finalPrice = price.amount * (1 - discount / 100)
return (
<div>
<span className="price">${finalPrice.toFixed(2)}</span>
{discount > 0 && <span className="discount">{discount}% off</span>}
</div>
)
}
// ---- On-Demand Revalidation ----// Use revalidateTag() or revalidatePath() to invalidate// the static shell when data changes — don't wait for ISR.// app/api/revalidate/route.tsimport { revalidateTag, revalidatePath } from'next/cache'import { NextRequest, NextResponse } from'next/server'exportasyncfunctionPOST(req: NextRequest) {
const { tag, path } = await req.json()
if (tag) {
// Invalidate specific data cacherevalidateTag(tag)
// Example: revalidateTag('product-123')
}
if (path) {
// Invalidate entire route cacherevalidatePath(path)
// Example: revalidatePath('/product/123')
}
returnNextResponse.json({ revalidated: true })
}
// ---- React cache() for Request Deduplication ----// Use React cache() to deduplicate identical fetches within// a single request — prevents N+1 queries in component trees.import { cache } from'react'// This fetch runs once per request, even if called by multiple componentsexportconst getProduct = cache(async (id: string) => {
const product = awaitfetch(
`https://api.example.com/products/${id}`,
{ next: { revalidate: 3600, tags: [`product-${id}`] } }
).then(res => res.json())
return product
})
Caching Mistakes with PPR
Caching dynamic slots with long TTLs defeats PPR — stale data appears as if it were static
Not using React cache() causes N+1 queries — the same fetch runs in every component that needs it
Forgetting revalidateTag() means the static shell stays stale until ISR expires
User-specific data must never be cached at the page level — it leaks between users
Cache key must include all variables that affect the response — missing variables cause cache poisoning
Production Insight
PPR and caching are complementary — PPR decides when to render, caching decides how long to keep it.
Dynamic slots can still be cached — use short TTLs (30-60s) to balance freshness vs load.
Rule: static shell uses ISR with long revalidation, dynamic slots use short revalidation or no caching.
Key Takeaway
Static shell caches at CDN edge with ISR — revalidates on a schedule or on-demand.
Dynamic slots cache with short TTLs — 30-60 seconds balances freshness and server load.
React cache() deduplicates fetches within a request — prevents N+1 queries in component trees.
Cache Components: The Heart of PPR — Why Your Stale Data Is Still Your Problem
Everyone talks about PPR like it's magic. It's not. PPR just decides which parts of your page prerender and which stream in. The actual performance win comes from cache components — the static shells you serve instantly. Miss this, and you're just doing SSR with extra steps.
Here's the truth: PPR doesn't make slow data fast. It makes fast data appear instant. The cached parts — headers, nav, product grids — must be aggressively cached at the edge. If your cache components depend on user-specific data, you've already lost. You'll either bust the cache constantly or serve stale personalized content. Neither is acceptable.
For production, treat cache components like compiled assets. They should be deterministic — same input, same output. No auth checks, no session lookups. Push that logic into the streaming parts. Your static shell should be so generic that CDNs can cache it for minutes, not milliseconds.
Senior shortcut: If your cache component needs a database call on every request, you don't have a PPR problem. You have an architecture problem.
ProductPageCacheComponent.javascriptJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// io.thecodeforge — javascript tutorial// Production cache component — deterministic, no user logicasyncfunctionProductShell({ categorySlug }: { categorySlug: string }) {
// Cache key based on URL alone — statically cacheableconst category = awaitcache(
`category:${categorySlug}`,
async () => {
// This only runs once per cache TTL, not per userconst result = await db.query(
'SELECT name, description FROM categories WHERE slug = $1',
[categorySlug]
);
return result.rows[0];
},
{ revalidate: 300 } // 5 minutes — safe for public caching
);
return (
<header>
<h1>{category.name}</h1>
<p>{category.description}</p>
{/* No user-specific elements here */}
</header>
);
}
// WRONG — this breaks cachingasyncfunctionBrokenProductShell({ userId }: { userId: string }) {
// ❌ User-specific cache key — busts edge cache per userconst user = await db.query(
'SELECT preferences FROM users WHERE id = $1',
[userId]
);
return <nav>{user.preferences.theme === 'dark' ? <DarkHeader /> : <LightHeader />}</nav>;
}
Output
Cache components generate HTML once per TTL. Every user sees the same shell.
Broken components generate unique HTML per user — zero cache overlap.
Production Trap:
If your cache component calls getServerSession(), you're defeating PPR. Move session checks into dynamic Suspense boundaries. Your edge cache will thank you.
Key Takeaway
PPR's static parts must be completely user-agnostic. If your cache component touches auth, you're doing it wrong.
Why PPR Matters for E-commerce — The 300ms Revenue Cliff You Already Know
E-commerce is where PPR earns its keep, but not for the reasons the docs hype. Sure, faster pages mean more conversions. But the real win is isolating slow backends from your first render. Your inventory API is slow? Fine. Your product recommendation engine is flaky? Who cares. With PPR, those failures only crater the dynamic parts, not the entire page.
Here's the scenario that killed a production app I fixed: Black Friday. The recommendation service buckled under load. With traditional SSR, every single product page timed out. Revenue loss was catastrophic. With PPR, the static product grid rendered instantly. The 'Recommended for You' section just showed a loading spinner until the API recovered. Users still added items to cart. That's the difference between a degraded experience and a dead site.
For product pages, cache the hero image, title, price, and Add to Cart button. Stream in reviews, stock availability, and personalized recommendations. The Add to Cart button must always be visible — that's your core conversion path. Everything else can wait.
Never Do This: Don't wrap your entire product page in a single Suspense boundary. That turns PPR into a slow-loading mess. Each dynamic element should have its own boundary with a meaningful fallback.
Reviews, stock, and recommendations each load independently with their own loading state.
If Recs service crashes, the page still sells products.
Senior Shortcut:
Put your Add to Cart button in the static shell. Never let a slow recommendation API block a sale. If your button breaks, your revenue breaks.
Key Takeaway
In e-commerce, PPR's killer feature isn't speed — it's fault isolation. Cache the cart path. Stream everything else.
How PPR Fits with Other Rendering Strategies — You're Already Using a Hybrid, Whether You Know It or Not
Nobody runs pure SSR in production anymore. Everyone mixes — ISR for product pages, SSR for dashboards, static export for marketing. PPR isn't a replacement. It's the glue that lets you combine them without rewriting everything. You just need to know when to use which.
Here's the matrix I use in production: Static generation (SSG) for pages that never change per user. PPR for pages that have a stable shell but dynamic sections. SSR for pages where every byte is user-specific (admin panels, financial dashboards). ISR for content that changes predictably on a schedule. They're not competing strategies. They're layers.
PPR actually makes ISR better. With pure ISR, you regenerate the entire page when one product changes. With PPR, you can revalidate only the cache component while the dynamic parts stay streaming. Less compute, faster updates.
Production rule of thumb: If 60%+ of your page is static, use PPR. If it's under 30%, stick with SSR. If it's 30-60%, PPR still wins but you need to aggressively cache the static portions or the overhead eats your gains.
RenderingStrategySelector.javascriptJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// io.thecodeforge — javascript tutorial// Production helper — choose rendering strategy based on page profilefunctionchooseRenderingStrategy(pageProfile: {
staticPercentage: number; // How much content is user-agnostic
updateFrequency: 'real-time' | 'scheduled' | 'rarely';
authRequired: boolean;
}): 'ssg' | 'ppr' | 'ssr' | 'isr' {
// If every byte is personalized, SSR is the only optionif (pageProfile.authRequired && pageProfile.staticPercentage < 10) {
return'ssr';
}
// PPR works best when static content dominatesif (pageProfile.staticPercentage >= 60) {
// ISR with PPR gives the best of bothif (pageProfile.updateFrequency === 'scheduled') {
return 'isr'; // Combine with PPR boundaries inside
}
return'ppr';
}
// Between 30-60%, PPR still beats pure SSRif (pageProfile.staticPercentage >= 30) {
return'ppr';
}
// Below 30%, SSR is simpler and fasterreturn'ssr';
}
// Usage exampleconst strategy = chooseRenderingStrategy({
staticPercentage: 70, // Product shell is static
updateFrequency: 'scheduled', // Prices update nightly
authRequired: false, // Public catalog
});
console.log(strategy); // 'isr' — but with PPR boundaries for dynamic sections
Output
For a product page with 70% static content: 'isr'
For an admin dashboard with 5% static content: 'ssr'
For a marketing landing page: 'ssg'
Production Trap:
Don't use PPR on fully dynamic pages. The overhead of managing Suspense boundaries and cache components costs more than the benefit. SSR those pages directly.
Key Takeaway
PPR isn't a silver bullet — it's best for pages with 30-90% static content. Below that, SSR wins. Above that, SSG or ISR is simpler.
● Production incidentPOST-MORTEMseverity: high
PPR Caused Stale Cart Data on E-Commerce Checkout Page
Symptom
Users reported seeing a flash of incorrect pricing on the checkout page. The static shell showed $29.99 (the price at build time), and 200ms later the dynamic stream updated it to $34.99. Conversion rate dropped 12% in the first week after deploying PPR.
Assumption
PPR automatically knows which parts of a page are dynamic and should not be prerendered.
Root cause
The price was rendered outside any Suspense boundary, so PPR prerendered it as part of the static shell. The price was fetched at build time from the database and baked into the static HTML. The dynamic Suspense slot only contained the cart item count, not the price itself. The developer assumed PPR would detect that price is dynamic — it does not. PPR relies on explicit Suspense boundaries to determine what is dynamic.
Fix
Moved the price rendering inside a Suspense boundary so it streams dynamically. Added a skeleton loader that matches the price layout to prevent layout shift during streaming. Implemented a revalidation strategy using revalidateTag('product-123') in a CMS webhook to invalidate the static shell when prices change in the CMS.
Key lesson
PPR does not auto-detect dynamic content — you must explicitly wrap dynamic parts in Suspense boundaries
Anything outside Suspense is prerendered as static — including data that changes between builds
Test PPR pages by inspecting the initial HTML response — if dynamic data appears in the static shell, it is baked in
Use skeleton loaders inside Suspense fallbacks to prevent layout shift during streaming
Production debug guideDiagnose Partial Prerendering issues in production5 entries
Symptom · 01
Dynamic content appears stale or frozen at build-time values
→
Fix
Check if the dynamic content is inside a Suspense boundary — if not, it is prerendered as static
Symptom · 02
TTFB is not improving after enabling PPR
→
Fix
Verify the route has static parts outside Suspense — if everything is inside Suspense, PPR adds no benefit
Symptom · 03
Layout shift during streaming phase
→
Fix
Add skeleton loaders to Suspense fallbacks that match the dimensions of the dynamic content
Symptom · 04
Build time increased significantly after enabling PPR
→
Fix
Check for dynamic imports or server-side fetches outside Suspense boundaries that force prerender of dynamic data
Symptom · 05
Streaming stalls and the page hangs mid-render
→
Fix
Check for errors in async server components — a thrown error inside Suspense causes the fallback to render, but an error outside Suspense breaks the entire route
★ PPR Quick Debug ReferenceFast commands for diagnosing PPR issues
curl -s http://localhost:3000/product/123 | head -100
Fix now
Move dynamic content inside a Suspense boundary with a skeleton fallback
PPR not activating on a route+
Immediate action
Verify PPR is enabled and the route is eligible
Commands
cat next.config.ts | grep -A2 ppr
__NEXT_PRIVATE_DEBUG_PPR=1 next build 2>&1 | grep -i 'ppr\|prerender'
Fix now
Ensure experimental.ppr is true and the route uses generateStaticParams or has no dynamic API usage outside Suspense
Streaming not working in development+
Immediate action
Check if React version supports Suspense streaming
Commands
cat package.json | grep -E 'react|next'
npm ls react 2>&1 | head -5
Fix now
Ensure Next.js 16+ and React 19+ are installed — PPR requires both
Build fails with PPR enabled+
Immediate action
Check for incompatible APIs or configurations
Commands
__NEXT_PRIVATE_DEBUG_PPR=1 next build 2>&1 | grep -i 'error\|fail' | head -20
grep -rn 'force-dynamic\|noStore\|cookies\|headers' app/ --include='*.tsx' | head -10
Fix now
Move dynamic APIs (cookies, headers, searchParams) inside Suspense boundaries or use generateStaticParams for static routes
Rendering Strategies Compared
Metric
Full Static (SSG)
Full Dynamic (SSR)
ISR
Partial Prerendering (PPR)
TTFB (p50)
12ms
320ms
15ms
18ms
FCP (p50)
180ms
520ms
200ms
85ms
LCP (p50)
450ms
780ms
480ms
380ms
Data freshness
Build time only
Every request
Configurable interval
Mixed — static build, dynamic per request
Server compute
Zero at runtime
Full page per request
On revalidation only
Dynamic slots only
CDN cacheable
Yes — full page
No
Yes — with TTL
Yes — static shell
User personalization
No
Yes
No
Yes — in dynamic slots
Best for
Marketing pages, docs
Dashboards, auth pages
Blogs, catalog pages
E-commerce, SaaS dashboards
Build time impact
High — all pages built
None — no build
Medium — top pages built
Medium — static shell built
Complexity
Low
Medium
Medium
High
Key takeaways
1
PPR serves a static shell instantly while streaming dynamic content
sub-50ms TTFB with full personalization
2
Suspense boundaries control what is static vs dynamic
outside is prerendered, inside is streamed
3
Granular boundaries enable progressive rendering
each section streams independently
4
PPR does not auto-detect dynamic content
you must explicitly place Suspense boundaries
5
Dynamic APIs (cookies, headers, searchParams) outside Suspense disable PPR for that route
6
PPR reduces server compute by ~60%
static shells served from CDN, only dynamic slots require compute
Common mistakes to avoid
6 patterns
×
Wrapping the entire page in a single Suspense boundary
Symptom
TTFB does not improve after enabling PPR — the page renders identically to full dynamic rendering because the entire page is inside one dynamic slot.
Fix
Split the page into granular Suspense boundaries — one per independently-fetched data source. Static content (navigation, layout, metadata) stays outside all boundaries.
×
Using dynamic APIs outside Suspense boundaries
Symptom
PPR is enabled but the route still renders fully dynamically — cookies(), headers(), or searchParams called outside Suspense force dynamic rendering for the entire segment.
Fix
Move all dynamic API calls inside Suspense boundaries. For example, move getServerSession() inside the component that wraps the dynamic content, not in the page component.
×
Not using generateStaticParams for dynamic routes
Symptom
Dynamic routes like /product/[id] render fully dynamically even with PPR enabled — no static shell is generated because Next.js does not know which IDs to prerender.
Fix
Add generateStaticParams() to tell Next.js which route segments to prerender. Return the top N most popular IDs — less popular IDs fall back to dynamic rendering.
×
Skeleton loaders that don't match the final content dimensions
Symptom
Layout shift occurs when dynamic content streams in — the skeleton is 50px tall but the content is 120px tall, pushing other elements down.
Fix
Design skeleton loaders that match the exact dimensions of the final content. Use fixed heights, matching text line counts, and identical padding/margins.
×
Caching user-specific data in the static shell
Symptom
User A sees User B's personalized data — the static shell was built with User A's data and served to all users until the next build.
Fix
Never put user-specific data outside a Suspense boundary. User-specific content must always be dynamic — fetched at request time inside a Suspense boundary.
×
Not testing the initial HTML response for PPR correctness
Symptom
Dynamic data appears in the static shell — it was prerendered at build time and is now stale. This is invisible during development because dev mode renders everything dynamically.
Fix
Test with curl -s http://your-app.com/page | head -200 to inspect the initial HTML. Dynamic content should NOT appear in the initial response — it should stream in after the static shell.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Explain how Partial Prerendering works in Next.js 16. How does it differ...
Q02SENIOR
A product page using PPR shows stale pricing to users. The price was upd...
Q03JUNIOR
What is the role of Suspense boundaries in Partial Prerendering?
Q04SENIOR
How does PPR affect server costs compared to full dynamic rendering?
Q01 of 04SENIOR
Explain how Partial Prerendering works in Next.js 16. How does it differ from traditional static and dynamic rendering?
ANSWER
Partial Prerendering splits a single route into two rendering phases. The static phase runs at build time — components outside Suspense boundaries are prerendered into a static shell. The dynamic phase runs at request time — components inside Suspense boundaries are evaluated and streamed into the page.
The key difference from traditional rendering: SSG renders the entire page at build time — no personalization or real-time data. SSR renders the entire page at request time — slow TTFB because all data must be fetched before sending HTML. PPR renders part of the page at build time and part at request time — you get SSG-speed TTFB with SSR-level dynamic content.
Suspense boundaries are the control mechanism. Outside a boundary = static. Inside a boundary = dynamic. The placement of boundaries directly determines what gets prerendered and what streams at request time.
The browser receives content in two phases: instant static HTML (sub-50ms TTFB), then streamed dynamic HTML as each Suspense slot resolves. This enables progressive rendering — users see content immediately and dynamic sections fill in as they load.
Q02 of 04SENIOR
A product page using PPR shows stale pricing to users. The price was updated in the CMS 2 hours ago but users still see the old price. How would you debug and fix this?
ANSWER
This is a classic PPR misconfiguration — the price is outside a Suspense boundary, so it was prerendered at build time and baked into the static shell.
Debugging steps:
1. Inspect the initial HTML response: curl -s https://app.com/product/123 | grep 'price' — if the old price appears in the initial HTML, it is in the static shell.
2. Check the page component: verify whether the price component is inside or outside a Suspense boundary.
3. Check ISR revalidation: if the price is intentionally static (inside the shell), check export const revalidate — the shell may revalidate on a long interval.
Fix depends on the desired behavior:
- If price should be real-time: move the price component inside a Suspense boundary so it streams at request time.
- If price can be stale for a short period: keep it in the static shell but reduce the revalidation interval (e.g., revalidate = 60 for 60 seconds).
- If price changes should trigger immediate revalidation: use revalidateTag('product-123') in a webhook handler when the CMS updates the price.
The root cause is always the same: content outside Suspense is prerendered at build time. If it changes between builds, it must be inside a Suspense boundary or use on-demand revalidation.
Q03 of 04JUNIOR
What is the role of Suspense boundaries in Partial Prerendering?
ANSWER
Suspense boundaries are the control mechanism for PPR. They define the boundary between static content (prerendered at build time) and dynamic content (streamed at request time).
Components outside all Suspense boundaries are prerendered into the static shell at build time. Components inside a Suspense boundary are evaluated at request time and streamed into the page as they resolve.
Granular boundaries are important — one boundary wrapping the entire page makes the whole page dynamic (PPR adds no benefit). Multiple boundaries, one per data source, enable progressive rendering where each section streams independently.
The fallback prop of each Suspense boundary determines what the user sees while the dynamic content loads. Skeleton loaders that match the final content dimensions prevent layout shift during streaming.
Q04 of 04SENIOR
How does PPR affect server costs compared to full dynamic rendering?
ANSWER
PPR significantly reduces server compute costs. In benchmarks, PPR reduced compute costs by 62% compared to full dynamic rendering.
The mechanism: the static shell is prerendered at build time and served from CDN edge — zero server compute per request. Only the dynamic slots require server compute, and only for the parts of the page that are inside Suspense boundaries.
Full dynamic rendering runs server-side code for the entire page on every request. PPR runs server-side code only for the dynamic slots — the static shell is pure HTML served from cache.
The cost savings scale with the ratio of static to dynamic content. A product page that is 80% static and 20% dynamic sees the largest savings. A fully dynamic dashboard sees minimal savings because there is no static shell to cache.
01
Explain how Partial Prerendering works in Next.js 16. How does it differ from traditional static and dynamic rendering?
SENIOR
02
A product page using PPR shows stale pricing to users. The price was updated in the CMS 2 hours ago but users still see the old price. How would you debug and fix this?
SENIOR
03
What is the role of Suspense boundaries in Partial Prerendering?
JUNIOR
04
How does PPR affect server costs compared to full dynamic rendering?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Is PPR stable in Next.js 16 or still experimental?
PPR is enabled via `experimental.ppr: true` in next.config.ts, indicating it is still in the experimental phase. However, it is production-ready for most use cases. The experimental flag exists because the API surface may change in future versions, not because the feature is unstable. Many production applications on Vercel use PPR today.
Was this helpful?
02
Can I use PPR with the Pages Router?
No. PPR requires the App Router and React Server Components. It relies on Suspense boundaries and streaming, which are App Router features. If you are on the Pages Router, you must migrate to the App Router to use PPR.
Was this helpful?
03
Does PPR work with client components?
Yes, but client components inside a Suspense boundary are still rendered at request time — they cannot be prerendered. Client components outside Suspense boundaries are prerendered as part of the static shell, but they hydrate on the client after the page loads. If a client component needs server-side data, it must be wrapped in a Suspense boundary.
Was this helpful?
04
How do I know if PPR is working on my route?
Build with __NEXT_PRIVATE_DEBUG_PPR=1 next build — the output shows which routes use PPR and which segments are static vs dynamic. You can also inspect the initial HTML response with curl — static content appears immediately, dynamic content is absent and streams in after. In the browser, the Network tab shows the streaming response with chunked transfer encoding.
Was this helpful?
05
Can I use PPR with middleware?
Yes. Middleware runs before the static shell is served — it can redirect, rewrite, or add headers to the response. Middleware does not affect PPR eligibility. However, if middleware reads cookies or headers and the result affects rendering, ensure that the affected content is inside a Suspense boundary.