Skip to content
Homeβ€Ί JavaScriptβ€Ί Partial Prerendering in Next.js 16 – The Complete Guide

Partial Prerendering in Next.js 16 – The Complete Guide

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: React.js β†’ Topic 21 of 23
Master Partial Prerendering, Next.
πŸ”₯ Advanced β€” solid JavaScript foundation required
In this tutorial, you'll learn
Master Partial Prerendering, Next.
  • PPR serves a static shell instantly while streaming dynamic content β€” sub-50ms TTFB with full personalization
  • Suspense boundaries control what is static vs dynamic β€” outside is prerendered, inside is streamed
  • Granular boundaries enable progressive rendering β€” each section streams independently
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑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
🚨 START HERE
PPR Quick Debug Reference
Fast commands for diagnosing PPR issues
🟑Static shell contains dynamic data
Immediate ActionInspect 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 NowMove dynamic content inside a Suspense boundary with a skeleton fallback
🟑PPR not activating on a route
Immediate ActionVerify 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 NowEnsure experimental.ppr is true and the route uses generateStaticParams or has no dynamic API usage outside Suspense
🟑Streaming not working in development
Immediate ActionCheck if React version supports Suspense streaming
Commands
cat package.json | grep -E 'react|next'
npm ls react 2>&1 | head -5
Fix NowEnsure Next.js 16+ and React 19+ are installed β€” PPR requires both
🟑Build fails with PPR enabled
Immediate ActionCheck 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 NowMove dynamic APIs (cookies, headers, searchParams) inside Suspense boundaries or use generateStaticParams for static routes
Production IncidentPPR Caused Stale Cart Data on E-Commerce Checkout PageA product page using PPR served a static price from build time while the dynamic cart summary streamed in β€” users saw a $29.99 price that had changed to $34.99.
SymptomUsers 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.
AssumptionPPR automatically knows which parts of a page are dynamic and should not be prerendered.
Root causeThe 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.
FixMoved 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 boundariesAnything outside Suspense is prerendered as static β€” including data that changes between buildsTest PPR pages by inspecting the initial HTML response β€” if dynamic data appears in the static shell, it is baked inUse skeleton loaders inside Suspense fallbacks to prevent layout shift during streaming
Production Debug GuideDiagnose Partial Prerendering issues in production
Dynamic content appears stale or frozen at build-time values→Check if the dynamic content is inside a Suspense boundary — if not, it is prerendered as static
TTFB is not improving after enabling PPR→Verify the route has static parts outside Suspense — if everything is inside Suspense, PPR adds no benefit
Layout shift during streaming phase→Add skeleton loaders to Suspense fallbacks that match the dimensions of the dynamic content
Build time increased significantly after enabling PPR→Check for dynamic imports or server-side fetches outside Suspense boundaries that force prerender of dynamic data
Streaming stalls and the page hangs mid-render→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

Partial Prerendering (PPR) is Next.js 16's answer to the static-vs-dynamic trade-off that has defined web rendering since the early days of server-side rendering. Instead of choosing between full static generation and full dynamic rendering for an entire route, PPR lets you mix both in a single page.

The static shell β€” navigation, layout, product images, metadata β€” is prerendered at build time and served from the edge. The dynamic parts β€” user-specific data, real-time inventory, personalized recommendations β€” stream in via React Suspense boundaries. The browser receives content in two phases: instant static HTML, then streamed dynamic HTML.

This is not an incremental improvement. PPR changes the mental model for how you think about rendering. Every route is now a composition of static and dynamic fragments, not a monolithic choice.

How Partial Prerendering Works

PPR splits a single route into two rendering phases: static and dynamic. The static phase runs at build time. The dynamic phase runs at request time. Both phases produce HTML that the browser receives incrementally via streaming.

At build time, Next.js prerenders everything it can. Components outside Suspense boundaries are evaluated, their HTML is generated, and the result is stored as a static shell. Components inside Suspense boundaries are replaced with their fallback UI in the static shell.

At request time, the static shell is served immediately β€” sub-50ms TTFB from the edge. Then, React streams the dynamic content into the Suspense slots. The browser progressively renders the page: static content appears instantly, dynamic content streams in as it resolves.

The key architectural insight: PPR does not change how React rendering works. It changes when rendering happens. Static fragments render at build time. Dynamic fragments render at request time. Suspense boundaries are the seam between the two.

io.thecodeforge.ppr.basic_example.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// 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>
  )
}
Mental Model
PPR as a Rendering Timeline
PPR moves the rendering decision from route-level to component-level β€” each component has its own render time.
  • 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.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// 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
πŸ“Š 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.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
// 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>
  )
}
Mental Model
Suspense Boundaries as Rendering Checkpoints
Each Suspense boundary is a checkpoint where the renderer pauses to wait for data β€” the boundary decides what blocks and what streams.
  • 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.ts Β· TYPESCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// ============================================
// 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.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
// ============================================
// 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.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
// ============================================
// 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
πŸ“Š 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.
πŸ—‚ Rendering Strategies Compared
Performance, cost, and use-case comparison for Next.js rendering approaches
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

  • PPR serves a static shell instantly while streaming dynamic content β€” sub-50ms TTFB with full personalization
  • Suspense boundaries control what is static vs dynamic β€” outside is prerendered, inside is streamed
  • Granular boundaries enable progressive rendering β€” each section streams independently
  • PPR does not auto-detect dynamic content β€” you must explicitly place Suspense boundaries
  • Dynamic APIs (cookies, headers, searchParams) outside Suspense disable PPR for that route
  • PPR reduces server compute by ~60% β€” static shells served from CDN, only dynamic slots require compute

⚠ Common Mistakes to Avoid

    βœ•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 Questions on This Topic

  • QExplain how Partial Prerendering works in Next.js 16. How does it differ from traditional static and dynamic rendering?Mid-levelReveal
    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.
  • QA product page using PPR shows stale pricing to users. The price was updated in the CMS 2 hours ago but users still see the old price. How would you debug and fix this?SeniorReveal
    This is a classic PPR misconfiguration β€” the price is outside a Suspense boundary, so it was prerendered at build time and baked into the static shell. Debugging steps: 1. Inspect the initial HTML response: curl -s https://app.com/product/123 | grep 'price' β€” if the old price appears in the initial HTML, it is in the static shell. 2. Check the page component: verify whether the price component is inside or outside a Suspense boundary. 3. Check ISR revalidation: if the price is intentionally static (inside the shell), check export const revalidate β€” the shell may revalidate on a long interval. Fix depends on the desired behavior: - If price should be real-time: move the price component inside a Suspense boundary so it streams at request time. - If price can be stale for a short period: keep it in the static shell but reduce the revalidation interval (e.g., revalidate = 60 for 60 seconds). - If price changes should trigger immediate revalidation: use revalidateTag('product-123') in a webhook handler when the CMS updates the price. The root cause is always the same: content outside Suspense is prerendered at build time. If it changes between builds, it must be inside a Suspense boundary or use on-demand revalidation.
  • QWhat is the role of Suspense boundaries in Partial Prerendering?JuniorReveal
    Suspense boundaries are the control mechanism for PPR. They define the boundary between static content (prerendered at build time) and dynamic content (streamed at request time). Components outside all Suspense boundaries are prerendered into the static shell at build time. Components inside a Suspense boundary are evaluated at request time and streamed into the page as they resolve. Granular boundaries are important β€” one boundary wrapping the entire page makes the whole page dynamic (PPR adds no benefit). Multiple boundaries, one per data source, enable progressive rendering where each section streams independently. The fallback prop of each Suspense boundary determines what the user sees while the dynamic content loads. Skeleton loaders that match the final content dimensions prevent layout shift during streaming.
  • QHow does PPR affect server costs compared to full dynamic rendering?Mid-levelReveal
    PPR significantly reduces server compute costs. In benchmarks, PPR reduced compute costs by 62% compared to full dynamic rendering. The mechanism: the static shell is prerendered at build time and served from CDN edge β€” zero server compute per request. Only the dynamic slots require server compute, and only for the parts of the page that are inside Suspense boundaries. Full dynamic rendering runs server-side code for the entire page on every request. PPR runs server-side code only for the dynamic slots β€” the static shell is pure HTML served from cache. The cost savings scale with the ratio of static to dynamic content. A product page that is 80% static and 20% dynamic sees the largest savings. A fully dynamic dashboard sees minimal savings because there is no static shell to cache.

Frequently Asked Questions

Is PPR stable in Next.js 16 or still experimental?

PPR is enabled via experimental.ppr: true in next.config.ts, indicating it is still in the experimental phase. However, it is production-ready for most use cases. The experimental flag exists because the API surface may change in future versions, not because the feature is unstable. Many production applications on Vercel use PPR today.

Can I use PPR with the Pages Router?

No. PPR requires the App Router and React Server Components. It relies on Suspense boundaries and streaming, which are App Router features. If you are on the Pages Router, you must migrate to the App Router to use PPR.

Does PPR work with client components?

Yes, but client components inside a Suspense boundary are still rendered at request time β€” they cannot be prerendered. Client components outside Suspense boundaries are prerendered as part of the static shell, but they hydrate on the client after the page loads. If a client component needs server-side data, it must be wrapped in a Suspense boundary.

How do I know if PPR is working on my route?

Build with __NEXT_PRIVATE_DEBUG_PPR=1 next build β€” the output shows which routes use PPR and which segments are static vs dynamic. You can also inspect the initial HTML response with curl β€” static content appears immediately, dynamic content is absent and streams in after. In the browser, the Network tab shows the streaming response with chunked transfer encoding.

Can I use PPR with middleware?

Yes. Middleware runs before the static shell is served β€” it can redirect, rewrite, or add headers to the response. Middleware does not affect PPR eligibility. However, if middleware reads cookies or headers and the result affects rendering, ensure that the affected content is inside a Suspense boundary.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousNext.js 16: Every New Feature Explained with Code ExamplesNext β†’Advanced shadcn/ui Patterns Every Developer Should Know in 2026
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged