{"schemaVersion":1,"id":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering","title":"Next.js 15 PPR: Partial Prerendering, Static Shells, and the End of the ISR vs SSR Debate","description":"How Next.js 15's Partial Prerendering combines static and dynamic rendering in a single request. Static shell + dynamic holes via Suspense, real TTFB measurements, and a practical migration guide from ISR.","language":"en","datePublished":"2025-12-17","dateModified":null,"contentFormat":"text/markdown","content":"Frontend\n\n# Next.js 15 PPR: Partial Prerendering, Static Shells, and the End of the ISR vs SSR Debate\n\n2025-12-178 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/next-js-15-ppr-partial-prerendering>)\n\n## The Problem PPR Solves\n\nConsider a typical e-commerce product page:\n\n```\n┌──────────────────────────────────────┐\n│ Header (with user avatar) │ ← Dynamic (needs auth)\n├──────────────────────────────────────┤\n│ Product Image Gallery │ ← Static (changes monthly)\n│ Product Title & Description │ ← Static\n│ Product Reviews (234 reviews) │ ← Static (cached)\n├──────────────────────────────────────┤\n│ Price: $49.99 │ ← Dynamic (changes hourly)\n│ Stock: 3 left │ ← Dynamic (real-time)\n│ [Add to Cart] │ ← Dynamic (needs session)\n├──────────────────────────────────────┤\n│ Related Products │ ← Static (personalization = dynamic)\n│ Footer │ ← Static\n└──────────────────────────────────────┘\n```\n\nBefore PPR, you had three options:\n\n1. **SSG**: Fast, but price and stock are stale. Users see yesterday's price.\n2. **SSR**: Fresh, but every request hits the origin server. TTFB goes from 50ms to 800ms.\n3. **ISR + client-side fetching**: Static page with ` useEffect ` calls for dynamic data. Layout shift, complexity, double data fetching.\n\nPPR introduces a fourth option: **serve the static parts instantly from the CDN, and stream the dynamic parts from the origin in the same response.**\n\n## How PPR Works\n\nPPR 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:\n\n```jsx\n// app/products/[slug]/page.jsx\nimport { Suspense } from 'react';\nimport { ProductDetails } from './ProductDetails';\nimport { PricingWidget } from './PricingWidget';\nimport { CartButton } from './CartButton';\nimport { ProductReviews } from './ProductReviews';\n\nexport default async function ProductPage({ params }) {\n const product = await getProduct(params.slug); // static data\n\n return (\n <div className=\"product-page\">\n {/* Static shell, prerendered at build time */}\n <ProductDetails product={product} />\n <ProductReviews productId={product.id} />\n\n {/* Dynamic holes, streamed at request time */}\n <Suspense fallback={<PriceSkeleton />}>\n <PricingWidget productId={product.id} />\n </Suspense>\n\n <Suspense fallback={<CartSkeleton />}>\n <CartButton productId={product.id} />\n </Suspense>\n </div>\n );\n}\n```\n\nWhen a user requests this page, here's what happens:\n\n```\nTimeline:\n\n0ms: CDN receives request\n5ms: CDN serves static shell (prerendered HTML)\n User sees: product images, description, reviews, loading skeletons for price/cart\n5ms: CDN simultaneously forwards request to origin for dynamic holes\n200ms: Origin sends dynamic content (price, stock, cart button)\n User sees: complete page with real-time price and stock\n```\n\nThe 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.\n\n## Enabling PPR\n\nIn Next.js 15, PPR is enabled per-route or globally:\n\n```javascript\n// next.config.js, enable globally\n/** @type {import('next').NextConfig} */\nconst nextConfig = {\n experimental: {\n ppr: true,\n },\n};\n\nmodule.exports = nextConfig;\n```\n\nOr per-route:\n\n```javascript\n// app/products/[slug]/page.jsx\nexport const experimental_ppr = true;\n```\n\n## The Static Shell: What Gets Prerendered\n\nEverything 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.\n\nThe prerender happens during build like this:\n\n```javascript\n// This component is STATIC, it's part of the shell\nasync function ProductDetails({ product }) {\n // This data was fetched at build time (or ISR revalidation)\n return (\n <div>\n <h1>{product.title}</h1>\n <p>{product.description}</p>\n <ImageGallery images={product.images} />\n </div>\n );\n}\n\n// This component is DYNAMIC, it's a hole in the shell\nasync function PricingWidget({ productId }) {\n // cookies() makes this dynamic, PPR detects it automatically\n const userCurrency = cookies().get('currency')?.value || 'USD';\n const price = await fetchPrice(productId, userCurrency);\n\n return (\n <div>\n <span className=\"price\">{formatPrice(price, userCurrency)}</span>\n <StockIndicator productId={productId} />\n </div>\n );\n}\n```\n\nHow does Next.js know what's dynamic? It detects usage of dynamic APIs:\n\n```javascript\n// These make a component dynamic:\ncookies() // Reads request cookies\nheaders() // Reads request headers\nsearchParams // Page prop from URL search params\nunstable_noStore() // Explicit opt-out of caching\n\n// These are still static (cached at build):\nfetch('https://api.example.com/product/123') // Cached by default\n```\n\n## Real TTFB Measurements\n\nThese measurements are from a product listing page with 20 products, each with a dynamic price and stock status:\n\n```\nRendering Strategy TTFB (p50) TTFB (p99) Full Load\n──────────────────────────────────────────────────────────────────\nSSR (no caching) 380ms 1200ms 920ms\nISR (60s revalidation) 45ms 120ms 520ms\nISR + client fetch 45ms 120ms 780ms*\nPPR 42ms 95ms 580ms\n──────────────────────────────────────────────────────────────────\n* ISR + client fetch has layout shift as prices load\n```\n\nPPR'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.\n\nThe 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.\n\n## Migration from ISR\n\nHere is how a product catalog migrates from ISR to PPR:\n\n### Before (ISR)\n\n```javascript\n// app/products/[slug]/page.jsx (ISR approach)\nexport const revalidate = 60; // Revalidate every 60 seconds\n\nexport default async function ProductPage({ params }) {\n const product = await getProduct(params.slug);\n const price = await getPrice(product.id); // Stale for up to 60s\n const stock = await getStock(product.id); // Stale for up to 60s\n\n return (\n <div>\n <ProductDetails product={product} />\n <div className=\"price\">{price}</div>\n <div className=\"stock\">{stock} left</div>\n <AddToCartButton productId={product.id} />\n </div>\n );\n}\n```\n\n### After (PPR)\n\n```javascript\n// app/products/[slug]/page.jsx (PPR approach)\nexport const experimental_ppr = true;\n\nexport default async function ProductPage({ params }) {\n const product = await getProduct(params.slug); // Cached (static shell)\n\n return (\n <div>\n <ProductDetails product={product} />\n\n <Suspense fallback={<div className=\"price-skeleton\" />}>\n <DynamicPricing productId={product.id} />\n </Suspense>\n\n <Suspense fallback={<div className=\"stock-skeleton\" />}>\n <DynamicStock productId={product.id} />\n </Suspense>\n\n <Suspense fallback={<div className=\"cart-skeleton\" />}>\n <AddToCartButton productId={product.id} />\n </Suspense>\n </div>\n );\n}\n\n// Separate component for dynamic data\nimport { unstable_noStore } from 'next/cache';\n\nasync function DynamicPricing({ productId }) {\n unstable_noStore(); // Explicitly mark as dynamic\n const price = await getPrice(productId); // Fresh on every request\n\n return <div className=\"price\">{formatPrice(price)}</div>;\n}\n\nasync function DynamicStock({ productId }) {\n unstable_noStore();\n const stock = await getStock(productId);\n\n return (\n <div className={`stock ${stock < 5 ? 'low-stock' : ''}`}>\n {stock > 0 ? `${stock} left` : 'Out of stock'}\n </div>\n );\n}\n```\n\nThe 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.\n\n## Common Gotchas\n\n**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:\n\n```jsx\n// BAD: makes entire site dynamic\nexport default function RootLayout({ children }) {\n const theme = cookies().get('theme')?.value || 'dark';\n return <html data-theme={theme}>{children}</html>;\n}\n\n// GOOD: layout is static, theme is a dynamic hole\nexport default function RootLayout({ children }) {\n return (\n <html>\n <Suspense fallback={<body className=\"theme-dark\">}>\n <ThemedBody>{children}</ThemedBody>\n </Suspense>\n </html>\n );\n}\n```\n\n**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:\n\n```css\n.price-skeleton {\n height: 32px; /* matches rendered price height */\n width: 120px;\n background: var(--skeleton-bg);\n border-radius: 4px;\n animation: pulse 1.5s ease-in-out infinite;\n}\n```\n\n**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.\n\n## When NOT to Use PPR\n\n- **Fully static pages** (blog posts, docs): Use regular SSG. PPR adds complexity for no benefit.\n- **Fully dynamic pages** (dashboards, admin panels): Use regular SSR. If nothing is cacheable, the static shell is just an empty layout.\n- **Pages with heavily personalized content**: If 80%+ of the page is user-specific, the static shell is too thin to provide meaningful TTFB improvement.\n\nPPR 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.\n\nThe 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.\n\n## Frequently asked questions\n\n### What's the difference between PPR and ISR?\n\nISR (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.\n\n### Does PPR work with the Pages Router?\n\nNo. 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.\n\n### How much does PPR improve TTFB?\n\nIn 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.\n\n## Tips\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n\n- nextjs\n- ppr\n- partial-prerendering\n- react\n- suspense\n- performance\n- ssr\n- isr\n- static-generation\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Next.js 15 PPR: Partial Prerendering, Static Shells, and the End of the ISR vs SSR Debate","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#title"},{"level":2,"text":"The Problem PPR Solves","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#the-problem-ppr-solves"},{"level":2,"text":"How PPR Works","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#how-ppr-works"},{"level":2,"text":"Enabling PPR","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#enabling-ppr"},{"level":2,"text":"The Static Shell: What Gets Prerendered","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#the-static-shell-what-gets-prerendered"},{"level":2,"text":"Real TTFB Measurements","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#real-ttfb-measurements"},{"level":2,"text":"Migration from ISR","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#migration-from-isr"},{"level":3,"text":"Before (ISR)","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#before-isr"},{"level":3,"text":"After (PPR)","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#after-ppr"},{"level":2,"text":"Common Gotchas","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#common-gotchas"},{"level":2,"text":"When NOT to Use PPR","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#when-not-to-use-ppr"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#questions"},{"level":3,"text":"What's the difference between PPR and ISR?","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#question-1"},{"level":3,"text":"Does PPR work with the Pages Router?","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#question-2"},{"level":3,"text":"How much does PPR improve TTFB?","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#article-tips"}],"sections":[{"heading":"Next.js 15 PPR: Partial Prerendering, Static Shells, and the End of the ISR vs SSR Debate","level":1,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering","content":"Frontend"},{"heading":"Next.js 15 PPR: Partial Prerendering, Static Shells, and the End of the ISR vs SSR Debate","level":1,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#title","content":"2025-12-178 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/next-js-15-ppr-partial-prerendering>)"},{"heading":"The Problem PPR Solves","level":2,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#the-problem-ppr-solves","content":"Consider a typical e-commerce product page:\n\n```\n┌──────────────────────────────────────┐\n│ Header (with user avatar) │ ← Dynamic (needs auth)\n├──────────────────────────────────────┤\n│ Product Image Gallery │ ← Static (changes monthly)\n│ Product Title & Description │ ← Static\n│ Product Reviews (234 reviews) │ ← Static (cached)\n├──────────────────────────────────────┤\n│ Price: $49.99 │ ← Dynamic (changes hourly)\n│ Stock: 3 left │ ← Dynamic (real-time)\n│ [Add to Cart] │ ← Dynamic (needs session)\n├──────────────────────────────────────┤\n│ Related Products │ ← Static (personalization = dynamic)\n│ Footer │ ← Static\n└──────────────────────────────────────┘\n```\n\nBefore PPR, you had three options:\n\n1. **SSG**: Fast, but price and stock are stale. Users see yesterday's price.\n2. **SSR**: Fresh, but every request hits the origin server. TTFB goes from 50ms to 800ms.\n3. **ISR + client-side fetching**: Static page with ` useEffect ` calls for dynamic data. Layout shift, complexity, double data fetching.\n\nPPR introduces a fourth option: **serve the static parts instantly from the CDN, and stream the dynamic parts from the origin in the same response.**"},{"heading":"How PPR Works","level":2,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#how-ppr-works","content":"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:\n\n```jsx\n// app/products/[slug]/page.jsx\nimport { Suspense } from 'react';\nimport { ProductDetails } from './ProductDetails';\nimport { PricingWidget } from './PricingWidget';\nimport { CartButton } from './CartButton';\nimport { ProductReviews } from './ProductReviews';\n\nexport default async function ProductPage({ params }) {\n const product = await getProduct(params.slug); // static data\n\n return (\n <div className=\"product-page\">\n {/* Static shell, prerendered at build time */}\n <ProductDetails product={product} />\n <ProductReviews productId={product.id} />\n\n {/* Dynamic holes, streamed at request time */}\n <Suspense fallback={<PriceSkeleton />}>\n <PricingWidget productId={product.id} />\n </Suspense>\n\n <Suspense fallback={<CartSkeleton />}>\n <CartButton productId={product.id} />\n </Suspense>\n </div>\n );\n}\n```\n\nWhen a user requests this page, here's what happens:\n\n```\nTimeline:\n\n0ms: CDN receives request\n5ms: CDN serves static shell (prerendered HTML)\n User sees: product images, description, reviews, loading skeletons for price/cart\n5ms: CDN simultaneously forwards request to origin for dynamic holes\n200ms: Origin sends dynamic content (price, stock, cart button)\n User sees: complete page with real-time price and stock\n```\n\nThe 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."},{"heading":"Enabling PPR","level":2,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#enabling-ppr","content":"In Next.js 15, PPR is enabled per-route or globally:\n\n```javascript\n// next.config.js, enable globally\n/** @type {import('next').NextConfig} */\nconst nextConfig = {\n experimental: {\n ppr: true,\n },\n};\n\nmodule.exports = nextConfig;\n```\n\nOr per-route:\n\n```javascript\n// app/products/[slug]/page.jsx\nexport const experimental_ppr = true;\n```"},{"heading":"The Static Shell: What Gets Prerendered","level":2,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#the-static-shell-what-gets-prerendered","content":"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.\n\nThe prerender happens during build like this:\n\n```javascript\n// This component is STATIC, it's part of the shell\nasync function ProductDetails({ product }) {\n // This data was fetched at build time (or ISR revalidation)\n return (\n <div>\n <h1>{product.title}</h1>\n <p>{product.description}</p>\n <ImageGallery images={product.images} />\n </div>\n );\n}\n\n// This component is DYNAMIC, it's a hole in the shell\nasync function PricingWidget({ productId }) {\n // cookies() makes this dynamic, PPR detects it automatically\n const userCurrency = cookies().get('currency')?.value || 'USD';\n const price = await fetchPrice(productId, userCurrency);\n\n return (\n <div>\n <span className=\"price\">{formatPrice(price, userCurrency)}</span>\n <StockIndicator productId={productId} />\n </div>\n );\n}\n```\n\nHow does Next.js know what's dynamic? It detects usage of dynamic APIs:\n\n```javascript\n// These make a component dynamic:\ncookies() // Reads request cookies\nheaders() // Reads request headers\nsearchParams // Page prop from URL search params\nunstable_noStore() // Explicit opt-out of caching\n\n// These are still static (cached at build):\nfetch('https://api.example.com/product/123') // Cached by default\n```"},{"heading":"Real TTFB Measurements","level":2,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#real-ttfb-measurements","content":"These measurements are from a product listing page with 20 products, each with a dynamic price and stock status:\n\n```\nRendering Strategy TTFB (p50) TTFB (p99) Full Load\n──────────────────────────────────────────────────────────────────\nSSR (no caching) 380ms 1200ms 920ms\nISR (60s revalidation) 45ms 120ms 520ms\nISR + client fetch 45ms 120ms 780ms*\nPPR 42ms 95ms 580ms\n──────────────────────────────────────────────────────────────────\n* ISR + client fetch has layout shift as prices load\n```\n\nPPR'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.\n\nThe 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."},{"heading":"Migration from ISR","level":2,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#migration-from-isr","content":"Here is how a product catalog migrates from ISR to PPR:"},{"heading":"Before (ISR)","level":3,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#before-isr","content":"```javascript\n// app/products/[slug]/page.jsx (ISR approach)\nexport const revalidate = 60; // Revalidate every 60 seconds\n\nexport default async function ProductPage({ params }) {\n const product = await getProduct(params.slug);\n const price = await getPrice(product.id); // Stale for up to 60s\n const stock = await getStock(product.id); // Stale for up to 60s\n\n return (\n <div>\n <ProductDetails product={product} />\n <div className=\"price\">{price}</div>\n <div className=\"stock\">{stock} left</div>\n <AddToCartButton productId={product.id} />\n </div>\n );\n}\n```"},{"heading":"After (PPR)","level":3,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#after-ppr","content":"```javascript\n// app/products/[slug]/page.jsx (PPR approach)\nexport const experimental_ppr = true;\n\nexport default async function ProductPage({ params }) {\n const product = await getProduct(params.slug); // Cached (static shell)\n\n return (\n <div>\n <ProductDetails product={product} />\n\n <Suspense fallback={<div className=\"price-skeleton\" />}>\n <DynamicPricing productId={product.id} />\n </Suspense>\n\n <Suspense fallback={<div className=\"stock-skeleton\" />}>\n <DynamicStock productId={product.id} />\n </Suspense>\n\n <Suspense fallback={<div className=\"cart-skeleton\" />}>\n <AddToCartButton productId={product.id} />\n </Suspense>\n </div>\n );\n}\n\n// Separate component for dynamic data\nimport { unstable_noStore } from 'next/cache';\n\nasync function DynamicPricing({ productId }) {\n unstable_noStore(); // Explicitly mark as dynamic\n const price = await getPrice(productId); // Fresh on every request\n\n return <div className=\"price\">{formatPrice(price)}</div>;\n}\n\nasync function DynamicStock({ productId }) {\n unstable_noStore();\n const stock = await getStock(productId);\n\n return (\n <div className={`stock ${stock < 5 ? 'low-stock' : ''}`}>\n {stock > 0 ? `${stock} left` : 'Out of stock'}\n </div>\n );\n}\n```\n\nThe 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."},{"heading":"Common Gotchas","level":2,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#common-gotchas","content":"**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:\n\n```jsx\n// BAD: makes entire site dynamic\nexport default function RootLayout({ children }) {\n const theme = cookies().get('theme')?.value || 'dark';\n return <html data-theme={theme}>{children}</html>;\n}\n\n// GOOD: layout is static, theme is a dynamic hole\nexport default function RootLayout({ children }) {\n return (\n <html>\n <Suspense fallback={<body className=\"theme-dark\">}>\n <ThemedBody>{children}</ThemedBody>\n </Suspense>\n </html>\n );\n}\n```\n\n**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:\n\n```css\n.price-skeleton {\n height: 32px; /* matches rendered price height */\n width: 120px;\n background: var(--skeleton-bg);\n border-radius: 4px;\n animation: pulse 1.5s ease-in-out infinite;\n}\n```\n\n**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."},{"heading":"When NOT to Use PPR","level":2,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#when-not-to-use-ppr","content":"- **Fully static pages** (blog posts, docs): Use regular SSG. PPR adds complexity for no benefit.\n- **Fully dynamic pages** (dashboards, admin panels): Use regular SSR. If nothing is cacheable, the static shell is just an empty layout.\n- **Pages with heavily personalized content**: If 80%+ of the page is user-specific, the static shell is too thin to provide meaningful TTFB improvement.\n\nPPR 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.\n\nThe 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."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#questions","content":""},{"heading":"What's the difference between PPR and ISR?","level":3,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#question-1","content":"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."},{"heading":"Does PPR work with the Pages Router?","level":3,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#question-2","content":"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."},{"heading":"How much does PPR improve TTFB?","level":3,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#question-3","content":"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."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#article-tips","content":"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.\n2. **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.\n3. **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.\n4. **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.\n\n- nextjs\n- ppr\n- partial-prerendering\n- react\n- suspense\n- performance\n- ssr\n- isr\n- static-generation\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/next-js-15-ppr-partial-prerendering"},{"text":"Next.js","url":"https://nextjs.org/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering"},"headline":"Next.js 15 PPR: Partial Prerendering, Static Shells, and the End of the ISR vs SSR Debate","description":"How Next.js 15's Partial Prerendering combines static and dynamic rendering in a single request. Static shell + dynamic holes via Suspense, real TTFB measurements, and a practical migration guide from ISR.","datePublished":"2025-12-17","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Frontend","keywords":"nextjs, ppr, partial-prerendering, react, suspense, performance, ssr, isr, static-generation","wordCount":1386,"timeRequired":"PT8M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/next-js-15-ppr-partial-prerendering"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Next.js 15 PPR: Partial Prerendering, Static Shells, and the End of the ISR vs SSR Debate","item":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What's the difference between PPR and ISR?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Does PPR work with the Pages Router?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"How much does PPR improve TTFB?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}]}],"contentHash":"3fbc628f16d341890586b7afc93191be7aaad6d30aff6098effcff0f2dc7649d","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":66255,"contentBytes":14004}}
