<!-- Canonical: https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering
Language: en
Content-SHA256: 3fbc628f16d341890586b7afc93191be7aaad6d30aff6098effcff0f2dc7649d
-->

Frontend

# Next.js 15 PPR: Partial Prerendering, Static Shells, and the End of the ISR vs SSR Debate

2025-12-178 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/next-js-15-ppr-partial-prerendering>)

## 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](<https://nextjs.org/>) 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:

```jsx
// 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:

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

module.exports = nextConfig;
```

Or per-route:

```javascript
// 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:

```javascript
// 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:

```javascript
// 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)

```javascript
// 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)

```javascript
// 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:

```jsx
// 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:

```css
.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.

## Frequently asked questions

### What's the difference between PPR and ISR?

ISR (Incremental Static Regeneration) serves a fully cached page and revalidates in the background after a timeout. The entire page is either stale or fresh. PPR serves a static shell immediately (from the CDN edge) and streams dynamic content in the same response. Different parts of the page can have different freshness guarantees, the shell is always instant, and dynamic holes are always fresh.

### Does PPR work with the Pages Router?

No. PPR requires the App Router because it depends on React Server Components and Suspense boundaries to define the static/dynamic split. If you're on the Pages Router, ISR with getStaticProps is still your best option for hybrid static/dynamic pages.

### How much does PPR improve TTFB?

In benchmarks, PPR improves TTFB by 60-80% compared to full SSR because the static shell is served from the CDN edge without hitting the origin server. The total page load time improvement is smaller (20-40%) because dynamic content still needs to be computed and streamed. The perceived improvement is even larger because users see the static shell immediately.

## Tips

1. **Tip:** Wrap only the truly dynamic parts in ` <Suspense> `. Every Suspense boundary becomes a dynamic hole in PPR. If you wrap your entire page in one Suspense, you've recreated SSR with extra steps. Be surgical, the shell should be the majority of the page.
2. **Tip:** Use ` unstable_noStore() ` in your data fetching functions to explicitly mark them as dynamic. PPR detects dynamic data access during the prerender, if a component calls ` cookies() `, ` headers() `, or ` searchParams `, it becomes a dynamic hole. But explicit marking prevents subtle bugs where a seemingly static component accidentally becomes dynamic.
3. **Tip:** Test your PPR pages with ` next build ` and check the output. Next.js shows which pages are fully static (circle icon), which are PPR (half-filled circle), and which are fully dynamic (lambda icon). If a page you expected to be PPR shows as fully dynamic, one of your top-level components is accessing dynamic data outside a Suspense boundary.
4. **Tip:** For e-commerce product pages, put the price and stock status in a Suspense boundary but keep the product description, images, and reviews in the static shell. Prices change hourly but descriptions change monthly, matching your rendering strategy to your data freshness requirements is the whole point of PPR.

- nextjs
- ppr
- partial-prerendering
- react
- suspense
- performance
- ssr
- isr
- static-generation

[Back to Blog](<https://lucioduran.com/blog>)
