Partial Prerendering in Next.js 16 β The Complete Guide
- PPR serves a static shell instantly while streaming dynamic content β sub-50ms TTFB with full personalization
- Suspense boundaries control what is static vs dynamic β outside is prerendered, inside is streamed
- Granular boundaries enable progressive rendering β each section streams independently
- 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: truein 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
Static shell contains dynamic data
curl -s http://localhost:3000/product/123 | grep -o 'data-price="[^"]*"'curl -s http://localhost:3000/product/123 | head -100PPR not activating on a route
cat next.config.ts | grep -A2 ppr__NEXT_PRIVATE_DEBUG_PPR=1 next build 2>&1 | grep -i 'ppr\|prerender'Streaming not working in development
cat package.json | grep -E 'react|next'npm ls react 2>&1 | head -5Build fails with PPR enabled
__NEXT_PRIVATE_DEBUG_PPR=1 next build 2>&1 | grep -i 'error\|fail' | head -20grep -rn 'force-dynamic\|noStore\|cookies\|headers' app/ --include='*.tsx' | head -10Production Incident
revalidateTag('product-123') in a CMS webhook to invalidate the static shell when prices change in the CMS.Production Debug GuideDiagnose Partial Prerendering issues in production
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.
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.
// 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 // The static 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 : 0 if (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> ) }
- 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
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.
// next.config.ts // Enable Partial Prerendering globally import type { 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, }, } export default 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.tsx export async function generateStaticParams() { 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
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.
// app/dashboard/page.tsx // Demonstrating Suspense boundary strategy for PPR import { Suspense } from 'react' // ---- Strategy 1: 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> ) } // ---- Strategy 2: 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 for ALL 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/JS for 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 RevenueCard const 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">Active Users</p> <p className="stat-value">{users.active.toLocaleString()}</p> <p className="stat-change text-blue-500">{users.onlineNow} online now</p> </div> ) }
- 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
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.
// ============================================ // 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.
- 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
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.
// ============================================ // PPR Pattern 1: Product Page // Static: product info, images, description // Dynamic: price, inventory, reviews, cart // ============================================ // app/product/[id]/page.tsx import { Suspense } from 'react' import { getProduct } from '@/lib/products' import { ProductGallery } from './gallery' import { ProductInfo } from './info' import { ProductPrice } from './price' import { ProductInventory } from './inventory' import { ProductReviews } from './reviews' import { AddToCart } from './add-to-cart' export async function generateStaticParams() { const products = await fetch('https://api.example.com/products/top-1000') .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 // Static data β fetched at build time, baked into the shell const product = await getProduct(id) return ( <article className="product-page"> {/* STATIC: Build-time data */} <ProductGallery images={product.images} /> <ProductInfo name={product.name} description={product.description} category={product.category} /> {/* DYNAMIC: Request-time data β each streams independently */} <div className="product-actions"> <Suspense fallback={<div className="h-12 w-32 bg-gray-100 animate-pulse rounded" />}> <ProductPrice productId={id} /> </Suspense> <Suspense fallback={<div className="h-8 w-24 bg-gray-100 animate-pulse rounded" />}> <ProductInventory productId={id} /> </Suspense> <Suspense fallback={<div className="h-12 w-full bg-gray-100 animate-pulse rounded" />}> <AddToCart productId={id} /> </Suspense> </div> {/* DYNAMIC: Reviews β can be slow, don't block the purchase flow */} <Suspense fallback={<div className="mt-8 space-y-4"> {Array.from({ length: 3 }).map((_, i) => ( <div key={i} className="h-24 bg-gray-100 animate-pulse rounded" /> ))} </div>}> <ProductReviews productId={id} /> </Suspense> </article> ) } // ============================================ // PPR Pattern 2: Dashboard // Static: layout, navigation, chart shells // Dynamic: metrics, real-time data, user preferences // ============================================ // app/(dashboard)/analytics/page.tsx export default async function AnalyticsPage() { return ( <div className="analytics-layout"> {/* STATIC: Page chrome β always the same */} <header className="analytics-header"> <h1>Analytics</h1> <DateRangePicker /> {/* Client component β interactive */} </header> {/* DYNAMIC: Metrics β each fetches independently */} <div className="metrics-grid"> <Suspense fallback={<MetricSkeleton />}> <TotalRevenue /> </Suspense> <Suspense fallback={<MetricSkeleton />}> <NewCustomers /> </Suspense> <Suspense fallback={<MetricSkeleton />}> <ChurnRate /> </Suspense> <Suspense fallback={<MetricSkeleton />}> <MRR /> </Suspense> </div> {/* DYNAMIC: Charts β heavy computation, streamed independently */} <div className="charts-grid"> <Suspense fallback={<ChartSkeleton />}> <RevenueChart /> </Suspense> <Suspense fallback={<ChartSkeleton />}> <UserGrowthChart /> </Suspense> </div> {/* DYNAMIC: Table β paginated, streamed last */} <Suspense fallback={<TableSkeleton rows={10} />}> <RecentTransactions /> </Suspense> </div> ) } // ============================================ // PPR Pattern 3: Content Page // Static: article body, author info, metadata // Dynamic: comments, related posts, share counts // ============================================ // app/blog/[slug]/page.tsx export async function generateStaticParams() { const posts = await fetch('https://api.example.com/blog/published') .then(res => res.json()) return posts.map((p: { slug: string }) => ({ slug: p.slug })) } export default async function BlogPost({ params, }: { params: Promise<{ slug: string }> }) { const { slug } = await params const post = await getPost(slug) // Static β fetched at build time return ( <article className="blog-post"> {/* STATIC: Article content β prerendered */} <header> <h1>{post.title}</h1> <AuthorInfo author={post.author} /> <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time> </header> <div className="prose" dangerouslySetInnerHTML={{ __html: post.content }} /> {/* DYNAMIC: Social proof β fetched at request time */} <Suspense fallback={<div className="h-8 w-48 bg-gray-100 animate-pulse rounded" />}> <ShareCounts slug={slug} /> </Suspense> {/* DYNAMIC: Comments β user-specific, slow third-party API */} <Suspense fallback={<CommentsSkeleton />}> <Comments slug={slug} /> </Suspense> {/* DYNAMIC: Related posts β personalized recommendation */} <Suspense fallback={<RelatedPostsSkeleton />}> <RelatedPosts slug={slug} /> </Suspense> </article> ) }
- Product pages: static catalog data + dynamic pricing/inventory β highest TTFB improvement
- Dashboards: static layout + dynamic metrics β best progressive rendering experience
- Content pages: static article + dynamic comments β minimal dynamic data, maximum static benefit
- 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
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.
// ============================================ // 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.tsx export const experimental_ppr = true export const revalidate = 3600 // static shell revalidates hourly export async function generateStaticParams() { 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' export const getProduct = cache(async (id: string) => { return fetch(`https://api.example.com/products/${id}`, { next: { revalidate: 3600, tags: [`product-${id}`] } }).then(res => res.json()) }) export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params const product = await getProduct(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.tsx import { getServerSession } from 'next-auth/next' export async function ProductPrice({ productId, }: { productId: string }) { // Shared across users, revalidates every 60s const price = await fetch( `https://api.example.com/products/${productId}/price`, { next: { revalidate: 60 } } ).then(res => res.json()) // User-specific β runs every request, never cached const session = await getServerSession() let discount = 0 if (session?.user) { discount = await getUserDiscount(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.ts import { revalidateTag, revalidatePath } from 'next/cache' import { NextRequest, NextResponse } from 'next/server' export async function POST(req: NextRequest) { const { tag, path } = await req.json() if (tag) { // Invalidate specific data cache revalidateTag(tag) // Example: revalidateTag('product-123') } if (path) { // Invalidate entire route cache revalidatePath(path) // Example: revalidatePath('/product/123') } return NextResponse.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 components export const getProduct = cache(async (id: string) => { const product = await fetch( `https://api.example.com/products/${id}`, { next: { revalidate: 3600, tags: [`product-${id}`] } } ).then(res => res.json()) return product })
| 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
- PPR serves a static shell instantly while streaming dynamic content β sub-50ms TTFB with full personalization
- Suspense boundaries control what is static vs dynamic β outside is prerendered, inside is streamed
- Granular boundaries enable progressive rendering β each section streams independently
- PPR does not auto-detect dynamic content β you must explicitly place Suspense boundaries
- Dynamic APIs (cookies, headers, searchParams) outside Suspense disable PPR for that route
- PPR reduces server compute by ~60% β static shells served from CDN, only dynamic slots require compute
β Common Mistakes to Avoid
Interview Questions on This Topic
- QExplain how Partial Prerendering works in Next.js 16. How does it differ from traditional static and dynamic rendering?Mid-levelReveal
- QA 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?SeniorReveal
- QWhat is the role of Suspense boundaries in Partial Prerendering?JuniorReveal
- QHow does PPR affect server costs compared to full dynamic rendering?Mid-levelReveal
Frequently Asked Questions
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.
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.
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.
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.
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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.