Partial Prerendering Stale Pricing — Conversion Drop 12%
Conversion dropped 12% when PPR baked a build-time price into the static shell outside Suspense.
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- 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
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.
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.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
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
- 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
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 })
- 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
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.
// io.thecodeforge — javascript tutorial // Production cache component — deterministic, no user logic async function ProductShell({ categorySlug }: { categorySlug: string }) { // Cache key based on URL alone — statically cacheable const category = await cache( `category:${categorySlug}`, async () => { // This only runs once per cache TTL, not per user const 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 caching async function BrokenProductShell({ userId }: { userId: string }) { // ❌ User-specific cache key — busts edge cache per user const user = await db.query( 'SELECT preferences FROM users WHERE id = $1', [userId] ); return <nav>{user.preferences.theme === 'dark' ? <DarkHeader /> : <LightHeader />}</nav>; }
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.
// io.thecodeforge — javascript tutorial import { Suspense } from 'react'; // Cache component — renders instantly, always visible async function ProductStaticShell({ productId }: { productId: string }) { const product = await cache( `product:${productId}`, () => fetchFromCDN(`/api/products/${productId}`), { revalidate: 600 } // 10 min — safe for product data ); return ( <div className="product-shell"> <img src={product.heroImage} alt={product.name} /> <h2>{product.name}</h2> <p className="price">${product.price}</p> <AddToCartButton productId={productId} /> </div> ); } // Dynamic boundaries — independent failure domains // Each falls back independently function ProductPage({ productId }: { productId: string }) { return ( <div> <ProductStaticShell productId={productId} /> <Suspense fallback={<ReviewsSkeleton />}> <ReviewsPanel productId={productId} /> </Suspense> <Suspense fallback={<StockSkeleton />}> <StockAvailability productId={productId} /> </Suspense> <Suspense fallback={<RecsSkeleton />}> <PersonalizedRecs productId={productId} /> </Suspense> </div> ); }
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.
// io.thecodeforge — javascript tutorial // Production helper — choose rendering strategy based on page profile function chooseRenderingStrategy(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 option if (pageProfile.authRequired && pageProfile.staticPercentage < 10) { return 'ssr'; } // PPR works best when static content dominates if (pageProfile.staticPercentage >= 60) { // ISR with PPR gives the best of both if (pageProfile.updateFrequency === 'scheduled') { return 'isr'; // Combine with PPR boundaries inside } return 'ppr'; } // Between 30-60%, PPR still beats pure SSR if (pageProfile.staticPercentage >= 30) { return 'ppr'; } // Below 30%, SSR is simpler and faster return 'ssr'; } // Usage example const 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
PPR Caused Stale Cart Data on E-Commerce Checkout Page
revalidateTag('product-123') in a CMS webhook to invalidate the static shell when prices change in the CMS.- 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
curl -s http://localhost:3000/product/123 | grep -o 'data-price="[^"]*"'curl -s http://localhost:3000/product/123 | head -100cat next.config.ts | grep -A2 ppr__NEXT_PRIVATE_DEBUG_PPR=1 next build 2>&1 | grep -i 'ppr\|prerender'cat package.json | grep -E 'react|next'npm ls react 2>&1 | head -5__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 -10| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| io.thecodeforge.ppr.basic_example.tsx | export const experimental_ppr = true | How Partial Prerendering Works |
| io.thecodeforge.ppr.config.ts | const nextConfig: NextConfig = { | Configuring PPR in Next.js 16 |
| io.thecodeforge.ppr.suspense_strategy.tsx | export async function DashboardBad() { | Suspense Boundaries |
| io.thecodeforge.ppr.patterns.tsx | export async function generateStaticParams() { | PPR Patterns for Real-World Applications |
| io.thecodeforge.ppr.caching.ts | export const experimental_ppr = true | PPR with Caching Strategies |
| ProductPageCacheComponent.javascript | async function ProductShell({ categorySlug }: { categorySlug: string }) { | Cache Components: The Heart of PPR |
| EcommerceProductPagePPR.javascript | async function ProductStaticShell({ productId }: { productId: string }) { | Why PPR Matters for E-commerce |
| RenderingStrategySelector.javascript | function chooseRenderingStrategy(pageProfile: { | How PPR Fits with Other Rendering Strategies |
Key takeaways
Common mistakes to avoid
6 patternsWrapping the entire page in a single Suspense boundary
Using dynamic APIs outside Suspense boundaries
cookies(), headers(), or searchParams called outside Suspense force dynamic rendering for the entire segment.Not using generateStaticParams for dynamic routes
Skeleton loaders that don't match the final content dimensions
Caching user-specific data in the static shell
Not testing the initial HTML response for PPR correctness
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 Questions on This Topic
Explain how Partial Prerendering works in Next.js 16. How does it differ from traditional static and dynamic rendering?
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?
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.What is the role of Suspense boundaries in Partial Prerendering?
How does PPR affect server costs compared to full dynamic rendering?
Frequently Asked Questions
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.
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.
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.
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.
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.
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
That's React.js. Mark it forged?
6 min read · try the examples if you haven't