The Problem PPR Solves

Consider a typical e-commerce product page:

┌──────────────────────────────────────┐
│ Header (with user avatar) │ ← Dynamic (needs auth)
├──────────────────────────────────────┤
│ Product Image Gallery │ ← Static (changes monthly)
│ Product Title & Description │ ← Static
│ Product Reviews (234 reviews) │ ← Static (cached)
├──────────────────────────────────────┤
│ Price: $49.99 │ ← Dynamic (changes hourly)
│ Stock: 3 left │ ← Dynamic (real-time)
│ [Add to Cart] │ ← Dynamic (needs session)
├──────────────────────────────────────┤
│ Related Products │ ← Static (personalization = dynamic)
│ Footer │ ← Static
└──────────────────────────────────────┘

Before PPR, you had three options:

  1. SSG: Fast, but price and stock are stale. Users see yesterday's price.
  2. SSR: Fresh, but every request hits the origin server. TTFB goes from 50ms to 800ms.
  3. ISR + client-side fetching: Static page with useEffect calls for dynamic data. Layout shift, complexity, double data fetching.

PPR introduces a fourth option: serve the static parts instantly from the CDN, and stream the dynamic parts from the origin in the same response.

How PPR Works

PPR uses React Suspense boundaries to define the split between static and dynamic content. During next build, Next.js prerenders as much of the page as possible. When it hits a Suspense boundary that wraps a dynamic component, it stops prerendering that subtree and leaves a "hole" in the static HTML:

// app/products/[slug]/page.jsx
import { Suspense } from 'react';
import { ProductDetails } from './ProductDetails';
import { PricingWidget } from './PricingWidget';
import { CartButton } from './CartButton';
import { ProductReviews } from './ProductReviews';

export default async function ProductPage({ params }) {
 const product = await getProduct(params.slug); // static data

 return (
 <div className="product-page">
 {/* Static shell, prerendered at build time */}
 <ProductDetails product={product} />
 <ProductReviews productId={product.id} />

 {/* Dynamic holes, streamed at request time */}
 <Suspense fallback={<PriceSkeleton />}>
 <PricingWidget productId={product.id} />
 </Suspense>

 <Suspense fallback={<CartSkeleton />}>
 <CartButton productId={product.id} />
 </Suspense>
 </div>
 );
}

When a user requests this page, here's what happens:

Timeline:

0ms: CDN receives request
5ms: CDN serves static shell (prerendered HTML)
 User sees: product images, description, reviews, loading skeletons for price/cart
5ms: CDN simultaneously forwards request to origin for dynamic holes
200ms: Origin sends dynamic content (price, stock, cart button)
 User sees: complete page with real-time price and stock

The TTFB is 5ms (the static shell from CDN), not 200ms (the full SSR response). The user sees meaningful content immediately while the dynamic parts stream in.

Enabling PPR

In Next.js 15, PPR is enabled per-route or globally:

// next.config.js, enable globally
/** @type {import('next').NextConfig} */
const nextConfig = {
 experimental: {
 ppr: true,
 },
};

module.exports = nextConfig;

Or per-route:

// app/products/[slug]/page.jsx
export const experimental_ppr = true;

The Static Shell: What Gets Prerendered

Everything outside a Suspense boundary that wraps dynamic data is part of the static shell. The shell is generated at next build and served from the CDN edge.

The prerender happens during build like this:

// This component is STATIC, it's part of the shell
async function ProductDetails({ product }) {
 // This data was fetched at build time (or ISR revalidation)
 return (
 <div>
 <h1>{product.title}</h1>
 <p>{product.description}</p>
 <ImageGallery images={product.images} />
 </div>
 );
}

// This component is DYNAMIC, it's a hole in the shell
async function PricingWidget({ productId }) {
 // cookies() makes this dynamic, PPR detects it automatically
 const userCurrency = cookies().get('currency')?.value || 'USD';
 const price = await fetchPrice(productId, userCurrency);

 return (
 <div>
 <span className="price">{formatPrice(price, userCurrency)}</span>
 <StockIndicator productId={productId} />
 </div>
 );
}

How does Next.js know what's dynamic? It detects usage of dynamic APIs:

// These make a component dynamic:
cookies() // Reads request cookies
headers() // Reads request headers
searchParams // Page prop from URL search params
unstable_noStore() // Explicit opt-out of caching

// These are still static (cached at build):
fetch('https://api.example.com/product/123') // Cached by default

Real TTFB Measurements

These measurements are from a product listing page with 20 products, each with a dynamic price and stock status:

Rendering Strategy TTFB (p50) TTFB (p99) Full Load
──────────────────────────────────────────────────────────────────
SSR (no caching) 380ms 1200ms 920ms
ISR (60s revalidation) 45ms 120ms 520ms
ISR + client fetch 45ms 120ms 780ms*
PPR 42ms 95ms 580ms
──────────────────────────────────────────────────────────────────
* ISR + client fetch has layout shift as prices load

PPR's TTFB is comparable to ISR (both served from CDN edge), but the full page load is faster than ISR + client fetch because the dynamic content streams in the initial response instead of requiring a separate client-side API call.

The real win is user experience. With ISR + client fetch, the page loads, the user sees placeholder prices, then 300ms later the real prices pop in with a layout shift. With PPR, the skeleton appears in the static shell and gets seamlessly replaced by the streamed content, no layout shift, no flash of wrong content.

Migration from ISR

Here is how a product catalog migrates from ISR to PPR:

Before (ISR)

// app/products/[slug]/page.jsx (ISR approach)
export const revalidate = 60; // Revalidate every 60 seconds

export default async function ProductPage({ params }) {
 const product = await getProduct(params.slug);
 const price = await getPrice(product.id); // Stale for up to 60s
 const stock = await getStock(product.id); // Stale for up to 60s

 return (
 <div>
 <ProductDetails product={product} />
 <div className="price">{price}</div>
 <div className="stock">{stock} left</div>
 <AddToCartButton productId={product.id} />
 </div>
 );
}

After (PPR)

// app/products/[slug]/page.jsx (PPR approach)
export const experimental_ppr = true;

export default async function ProductPage({ params }) {
 const product = await getProduct(params.slug); // Cached (static shell)

 return (
 <div>
 <ProductDetails product={product} />

 <Suspense fallback={<div className="price-skeleton" />}>
 <DynamicPricing productId={product.id} />
 </Suspense>

 <Suspense fallback={<div className="stock-skeleton" />}>
 <DynamicStock productId={product.id} />
 </Suspense>

 <Suspense fallback={<div className="cart-skeleton" />}>
 <AddToCartButton productId={product.id} />
 </Suspense>
 </div>
 );
}

// Separate component for dynamic data
import { unstable_noStore } from 'next/cache';

async function DynamicPricing({ productId }) {
 unstable_noStore(); // Explicitly mark as dynamic
 const price = await getPrice(productId); // Fresh on every request

 return <div className="price">{formatPrice(price)}</div>;
}

async function DynamicStock({ productId }) {
 unstable_noStore();
 const stock = await getStock(productId);

 return (
 <div className={`stock ${stock < 5 ? 'low-stock' : ''}`}>
 {stock > 0 ? `${stock} left` : 'Out of stock'}
 </div>
 );
}

The migration pattern is consistent: identify which data is truly dynamic, extract it into separate Server Components, wrap them in Suspense boundaries, and add loading skeletons.

Common Gotchas

Dynamic data in layouts propagates. If your root layout reads cookies() for theme preference, the entire site becomes dynamic. Move cookie reading into a Suspense boundary within the layout:

// BAD: makes entire site dynamic
export default function RootLayout({ children }) {
 const theme = cookies().get('theme')?.value || 'dark';
 return <html data-theme={theme}>{children}</html>;
}

// GOOD: layout is static, theme is a dynamic hole
export default function RootLayout({ children }) {
 return (
 <html>
 <Suspense fallback={<body className="theme-dark">}>
 <ThemedBody>{children}</ThemedBody>
 </Suspense>
 </html>
 );
}

Skeleton design matters. The static shell is what users see for the first 100-300ms. If your skeletons don't match the final layout dimensions, you get Cumulative Layout Shift. Using fixed-height skeleton divs that match the rendered component size is recommended:

.price-skeleton {
 height: 32px; /* matches rendered price height */
 width: 120px;
 background: var(--skeleton-bg);
 border-radius: 4px;
 animation: pulse 1.5s ease-in-out infinite;
}

Not everything needs to be dynamic. A common mistake is wrapping too many things in Suspense, product reviews, related products, category navigation. Most of this data changes infrequently and should stay in the static shell with ISR-style revalidation. Only truly per-request data (prices, stock, user-specific content) should be dynamic holes.

When NOT to Use PPR

  • Fully static pages (blog posts, docs): Use regular SSG. PPR adds complexity for no benefit.
  • Fully dynamic pages (dashboards, admin panels): Use regular SSR. If nothing is cacheable, the static shell is just an empty layout.
  • Pages with heavily personalized content: If 80%+ of the page is user-specific, the static shell is too thin to provide meaningful TTFB improvement.

PPR shines when you have pages that are mostly static with a few dynamic widgets, e-commerce, news sites, marketing pages with real-time stats, product catalogs with live pricing. That covers a surprisingly large percentage of production web applications.

The ISR vs SSR debate is over. PPR isn't a compromise between the two, it's a genuinely new rendering model that gives you the TTFB of static generation with the freshness of server-side rendering. The key insight is thinking about a page as a static shell with dynamic holes instead of a monolithic render. Once that mental model clicks, the implementation follows naturally.