Mid-level 7 min · April 11, 2026

Partial Prerendering Stale Pricing — Conversion Drop 12%

Conversion dropped 12% when PPR baked a build-time price into the static shell outside Suspense.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
// 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>
  )
}
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 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
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
// Demonstrating Suspense boundary strategy for PPR

import { Suspense } from 'react'

// ---- Strategy 1: BADEntire 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: GOODGranular 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>
  )
}
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.

io.thecodeforge.ppr.patterns.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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
// ============================================
// 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>
  )
}
Choosing the Right PPR Pattern
  • 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
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.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 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 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>;
}
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.

EcommerceProductPagePPR.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
40
41
42
43
// 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>
  );
}
Output
Static shell renders immediately.
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 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
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
Static shell contains dynamic data
Immediate action
Inspect initial HTML response for dynamic content
Commands
curl -s http://localhost:3000/product/123 | grep -o 'data-price="[^"]*"'
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
MetricFull Static (SSG)Full Dynamic (SSR)ISRPartial Prerendering (PPR)
TTFB (p50)12ms320ms15ms18ms
FCP (p50)180ms520ms200ms85ms
LCP (p50)450ms780ms480ms380ms
Data freshnessBuild time onlyEvery requestConfigurable intervalMixed — static build, dynamic per request
Server computeZero at runtimeFull page per requestOn revalidation onlyDynamic slots only
CDN cacheableYes — full pageNoYes — with TTLYes — static shell
User personalizationNoYesNoYes — in dynamic slots
Best forMarketing pages, docsDashboards, auth pagesBlogs, catalog pagesE-commerce, SaaS dashboards
Build time impactHigh — all pages builtNone — no buildMedium — top pages builtMedium — static shell built
ComplexityLowMediumMediumHigh

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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Is PPR stable in Next.js 16 or still experimental?
02
Can I use PPR with the Pages Router?
03
Does PPR work with client components?
04
How do I know if PPR is working on my route?
05
Can I use PPR with middleware?
🔥

That's React.js. Mark it forged?

7 min read · try the examples if you haven't

Previous
Next.js 16: Every New Feature Explained with Code Examples
21 / 47 · React.js
Next
Advanced shadcn/ui Patterns Every Developer Should Know in 2026