Code & DevelopmentCode & Development · 13 Jul 2026
Next.js 16 PPR: Eliminate the static vs. dynamic tradeoff and achieve Edge TTFB with fresh data
Partial Prerendering in Next.js 16 resolves the classic rendering tradeoff, delivering static speed with dynamic data in one seamless response.
Next.js Partial Prerendering Explained
Partial Prerendering eliminates the tradeoff between static and dynamic content by serving a static shell from the CDN while streaming dynamic parts.
Next.js 16 uses Suspense boundaries as the architectural dividing line to determine which parts of a page are prerendered and which are dynamic.
The framework now defaults to dynamic rendering, requiring developers to explicitly opt into static caching via the cacheComponents configuration.
Components accessing request-time APIs like cookies or headers automatically become dynamic holes that stream into the static shell at request time.
Developers can enable the new architecture by setting cacheComponents to true in the next.config.ts file for Next.js 16 applications.
The oldest tradeoff in web development — finally resolved
The agonizing choice between lightning-fast static pages and real-time dynamic content has long defined the Next.js development experience. But what if you never had to compromise again? With PPR, the static versus dynamic trade-off is finally obsolete.
Take a product page on an e-commerce site. The title, description, and images almost never change — they should be static, cached at the CDN edge, served in milliseconds. The user's cart count, their personalized recommendations, and real-time inventory — those change per request and per user. They have to be dynamic.
Before PPR, you had to pick one mode for the entire route. Make it static and the cart is always stale. Make it SSR and every user pays the full server rendering cost — even for content that hasn't changed since the last deploy. PPR lets you have both, in one route, in one HTTP response.
Static Generation
HTML at build time. CDN cached. Fastest TTFB possible. ✗ Stale user data. No personalization.
Server Rendering
Fresh data on every request. Full personalization. ✗ Slow TTFB. No CDN. Every user pays the cost.
Streaming
Sends chunks as they're ready. Progressive loading. ✗ Entire page is dynamic. No static CDN shell.
Partial Prerendering
Static shell from CDN. Dynamic parts streamed in the same response. ✓ Edge TTFB + fresh data. Best of all strategies.
PPR is not a new rendering strategy. It's the end of the rendering strategy decision tree.
How PPR works under the hood
The mental model is simpler than it sounds. One rule, one boundary, one response.
The static shell
At build time, Next.js renders everything it can — navigation, layout, headings, product descriptions, images, footers. Anything that doesn't depend on the incoming request. This becomes the static shell: a complete HTML document with Suspense fallback placeholders where the dynamic parts will go. The shell is stored at the CDN edge.
The dynamic holes
When a user requests the page, the CDN sends the cached shell immediately — that's your TTFB. In parallel, the origin server renders only the dynamic parts: the user's cart, their recommendations, real-time stock. These stream into the same HTTP response, filling the Suspense holes as they resolve. No second request. No client-side fetch. One response, two types of content.
<Suspense> is the dividing line
Everything outside a <Suspense> boundary is static — prerendered at build time, cached at the edge. Everything inside is dynamic — rendered at request time and streamed. The Suspense boundary is not just a loading UI: in PPR, it's the architectural boundary that separates static from dynamic.
Dynamic by default
In Next.js 16, all code is dynamic by default. You opt into static caching using the "use cache" directive or Suspense structure. This is the inverse of previous versions where pages were static by default. The experimental.ppr flag is gone — replaced by cacheComponents: true.
Enabling PPR in Next.js 16
One config flag. That's the entire setup to unlock Partial Prerendering for your application.
next.config.ts — enable Cache Components (Next.js 16)
// next.config.ts — Next.js 16+
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Enables Partial Prerendering + Cache Components
cacheComponents: true,
};
export default nextConfig;
// ⚠️ If you're on Next.js 14 or 15, use the old experimental flag:
// const nextConfig = { experimental: { ppr: 'incremental' } }
// The experimental.ppr flag was REMOVED in Next.js 16Your first PPR page — product listing example
app/products/[id]/page.tsx — static shell + dynamic holes
import { Suspense } from 'react';
import { ProductDetails } from './product-details'; // static
import { UserCart } from './user-cart'; // dynamic
import { Recommendations } from './recommendations'; // dynamic
import { CartSkeleton } from './skeletons';
import { RecommendationsSkeleton } from './skeletons';
// This page uses PPR — shell is prerendered, holes are streamed
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
return (
<main>
{/*
✅ STATIC SHELL
ProductDetails only uses params.id — known at build time
Next.js prerenders this and caches it at the CDN edge
*/}
<ProductDetails productId={params.id} />
{/*
✅ DYNAMIC HOLE #1 — reads cookies() inside
Wrapped in Suspense → streams at request time
CartSkeleton shows immediately while UserCart loads
*/}
<Suspense fallback={<CartSkeleton />}>
<UserCart />
</Suspense>
{/*
✅ DYNAMIC HOLE #2 — personalized per user
Streams in parallel with UserCart — no waterfall
*/}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={params.id} />
</Suspense>
</main>
);
}user-cart.tsx — dynamic component (uses request-time API)
// app/products/[id]/user-cart.tsx
import { cookies } from 'next/headers'; // ← makes this component dynamic
export async function UserCart() {
const sessionToken = (await cookies()).get('session')?.value;
if (!sessionToken) {
return <div>Sign in to see your cart</div>;
}
const cart = await fetchCart(sessionToken); // fetch user-specific data
return (
<div className="cart">
<span>{cart.itemCount} items</span>
<span>${cart.total}</span>
</div>
);
}
// Why is this dynamic?
// cookies() accesses the incoming HTTP request — unknown at build time.
// Any component that calls cookies(), headers(), or connection()
// is automatically dynamic in Next.js 16.
// PPR streams it in after the static shell is served.The "use cache" directive — fine-grained caching
Beyond Suspense boundaries, Next.js 16 introduced the "use cache" directive — a way to mark individual functions or components as cacheable at the server level.
"use cache" — component-level caching
// Cache an individual async function — not the whole component
async function getProductData(productId: string) {
'use cache'; // ← this function's result is cached
const data = await db.products.findUnique({ where: { id: productId } });
return data;
}
// Cache a component directly
async function ProductHeader({ productId }: { productId: string }) {
'use cache'; // ← this component is cached at build time
const product = await getProductData(productId);
return (
<header>
<h1>{product.title}</h1>
<p>{product.description}</p>
</header>
);
}
// The key insight in Next.js 16:
// "use cache" = explicit opt-in to static caching
// No directive = dynamic by default
// Suspense boundary = where dynamic content streams in
//
// Together, "use cache" + Suspense = Partial PrerenderingReading the build output
The ◐ symbol in the build output is how you confirm PPR is working for a route.
Route (app) Size First Load JS
◐ /products/[id] 4.2 kB 89 kB
○ /about 1.1 kB 82 kB
● /blog/[slug] 2.3 kB 84 kB
λ /dashboard 3.1 kB 86 kB
○ Static ● SSG ◐ Partial Prerendering λ Dynamic (SSR)⚠ Common issue
Page shows as λ (fully dynamic) instead of ◐ (PPR): a dynamic API — cookies(), headers(), or connection() — is being called outside a Suspense boundary, usually at the page or layout level. Find the call and move it inside a Suspense-wrapped component. The moment those APIs are inside a <Suspense>, Next.js can prerender everything outside it and the route becomes ◐.
Migrating an SSR page to PPR
Most SSR routes can become PPR routes with minimal changes. The key is identifying which parts need request-time data and wrapping them in Suspense.
❌ Before — full SSR, slow TTFB
// The whole page waits for user data
// Even the static header is blocked
export default async function Page() {
// ❌ All of this blocks the entire page
const user = await getUser(); // auth check
const cart = await getCart(user); // user-specific
const recs = await getRecs(user); // personalized
const product = await getProduct(); // static data
// Nothing ships until ALL of these resolve
return (
<main>
<Header />
<ProductDetails product={product} />
<CartWidget cart={cart} />
<Recommendations recs={recs} />
</main>
);
}✅ After — PPR, edge TTFB + fresh data
// Static content ships immediately from CDN
// Dynamic content streams in parallel
export default async function Page({ params }) {
// ✅ Only static data fetched at the page level
const product = await getProduct(params.id);
return (
<main>
{/* ✅ Static — in the prerendered shell /}
<Header />
<ProductDetails product={product} />
{/ ✅ Dynamic — streamed at request time /}
<Suspense fallback={<CartSkeleton />}>
<CartWidget /> {/ fetches user + cart inside /}
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations id={params.id} /> {/ personalized */}
</Suspense>
</main>
);
}✓ Migration rule
Move request-time data fetching down the tree. Any await that uses cookies(), headers(), or user-specific data should live inside a component that is wrapped in <Suspense>. The page-level component should only fetch data that's known at build time — route params are fine; session tokens are not.
Where PPR breaks in production
PPR is not a magic bullet. There is one category of content where PPR can actually hurt you — and it's easy to miss in development.
⚠ The stale shell problem
PPR does not guarantee consistency between the static shell and the streamed content. If your pricing API updates every 30 seconds but the static shell is cached for 5 minutes, users see a stale price in the initial render, then a corrected price after the stream completes. In one documented e-commerce case, this caused a 12% drop in conversion rate — users saw an outdated discount in the static shell and abandoned the cart before the dynamic price loaded. Never put price, inventory count, or time-sensitive offers in the static shell.
Content type | Put in static shell | Keep in Suspense (dynamic) |
|---|---|---|
Product title, description, images | ✓ Static shell | — |
Navigation, layout, footer | ✓ Static shell | — |
Blog content, documentation | ✓ Static shell | — |
User cart, wishlist | ✗ Never static | ✓ Stream via Suspense |
Personalized recommendations | ✗ Never static | ✓ Stream via Suspense |
Product price | ✗ Avoid — stale risk | ✓ Stream if it changes |
Real-time inventory count | ✗ Never static | ✓ Stream via Suspense |
Authentication state | ✗ Never static | ✓ Stream via Suspense |
A/B test variant | ⚠ Only if tolerates stale | ✓ Prefer Suspense |
→ The production rule
If a user might act on the static content before the dynamic content loads — clicking buy, adding to cart, making a decision based on price — PPR is the wrong choice for that content. Put it in Suspense. The rule: if it changes and it drives action, it must be dynamic.
PPR, SSR, SSG, ISR — the strategy matrix
These aren't competing strategies. They're layers. PPR works best when 60%+ of your page is static.
Strategy | TTFB | Fresh data | Personalized | Use when |
|---|---|---|---|---|
SSG | Edge speed | Build time only | No | Marketing pages, docs — zero dynamic content |
ISR | Edge speed | Revalidated | No | Blog, catalog — changes predictably on a schedule |
PPR | Edge speed | Per request | Yes (streamed) | 60%+ static layout + dynamic user data — e-commerce, dashboards with public shell |
SSR | Origin latency | Per request | Yes | Every byte is user-specific — admin panels, financial dashboards |
CSR | Shell instant | Client fetch | Yes | Highly interactive UI behind auth — maps, editors, complex apps |
→ The 60/30 rule
If 60%+ of your page is static, use PPR — the gains are significant. If it's under 30% static, stick with SSR — the overhead of splitting eats your gains. Between 30-60%, PPR still wins, but you need to aggressively cache the static portions. And if the page is 100% static — use SSG, don't add PPR complexity you don't need.
✓ PPR is the right choice when
- Product pages: static content + personalized sidebar, cart, recommendations
- News or blog articles with comments, user-specific ads, or reading progress
- Landing pages with A/B-tested elements or user-specific CTAs
- Dashboard with a public shell and user-specific widgets
- 60%+ of the page content doesn't change per user
SEO matters — static shell is immediately crawlable
→ Choose a different strategy when
- Every byte is user-specific (financial data, admin panels) → SSR
- Content is 100% static and never personalizes → SSG or ISR
- Prices or inventory are in the above-fold area (stale risk) → SSR
- You're on Pages Router — PPR requires App Router
- You need full consistency between shell and dynamic content at all times
Oct 2023 — Announced at Next.js Conf.
Introduced as experimental behind experimental.ppr: true. The 'incremental' mode allowed per-route opt-in via export const experimental_ppr = true. Not stable — API subject to change.
Oct 2024 — Still experimental.
Same flags, refined behavior. Vercel continued positioning PPR as not production-ready for most apps. The incremental opt-in remained the recommended way to test per route.
Oct 2025 — PPR graduates to stable via Cache Components.
experimental.ppr flag is removed. New model: cacheComponents: true in next.config.ts. The "use cache" directive replaces the explicit experimental_ppr route export. All code is now dynamic by default. React Compiler support promoted to stable.
⚠ Breaking change from v14/v15
If you used experimental.ppr or experimental_ppr on Next.js 14 or 15, those flags no longer exist in Next.js 16. Migrate to cacheComponents: true and the "use cache" directive. The behavior is the same but the configuration API changed completely.
What PPR means for how you build Next.js apps
PPR doesn't replace SSR, SSG, ISR, or CSR. It coordinates with them. The rendering decision tree that defined Next.js development for years — "is this page static or dynamic?" — is now a component-level decision, not a route-level one.
The architectural shift is real: in Next.js 16, you stop thinking about pages as having a single rendering mode. You start thinking about which parts of this page are static and which parts need to be fresh. Suspense is your boundary. The static side goes to the CDN edge and ships at edge latency. The dynamic side streams from the origin in the same HTTP response.
The practical outcome is that most content sites, e-commerce pages, and dashboards with a public shell and user-specific data become faster without any SSR tax. The page your user sees is the same data — it just arrives in a smarter order.
The one place to be careful: never put content in the static shell that users might act on before the dynamic update arrives. Stale prices, stale inventory, stale discounts in the shell while the stream is still loading — that's where PPR can hurt conversion instead of helping it.
✓ Checklist before shipping PPR
1. Confirm cacheComponents: true in next.config.ts. 2. Check build output — your route should show ◐, not λ. 3. If it shows λ, find the dynamic API call outside a Suspense boundary. 4. Audit every piece of content in the static shell — would it hurt the user to see it stale? 5. Every dynamic hole has a Suspense fallback (skeleton, not null). 6. Test in production-like conditions — CDN caching behavior differs from local dev.
Sources — June 2026
nextjs.org/blog/next-16 (Oct 2025) — Official Next.js 16 release notes: Cache Components, PPR stable,
cacheComponents: true, React Compiler stable, Turbopack stable.nextjs.org/docs/app/guides/ppr-platform-guide (Mar 2026) — Platform guide: static shell,
postponedState, CDN resume protocol, streaming architecture.nextjs.org/docs/15/app/getting-started/partial-prerendering (Aug 2025) — Getting started guide:
experimental_ppr, Suspense boundaries,"use cache"directive.PageGlass (Apr 27, 2026) — "Partial Prerendering: A Practical Guide (2026)" — PPR vs streaming SSR, CDN caching, App Router requirement, 60/30 rule.
TheCodeForge (Apr 11, 2026) — "Partial Prerendering in Next.js 16" — production pitfalls, 12% conversion drop case study, strategy matrix, PPR + ISR composition.
DEV Community / Pockit (Mar 18, 2026) — "Next.js PPR Deep Dive" —
◐build output indicator, debugging fully-dynamic pages, migration patterns.Ashish Gogula (Dec 4, 2025) — "A Practical Guide to PPR in Next.js 16" —
cacheComponents: trueconfig, demo project, Suspense + skeleton pattern.U11D (Feb 13, 2026) — "Next.js 16 PPR: Best of Static and Dynamic Rendering" — e-commerce use case,
"use cache"directive, comparison with SSG/ISR.
Code examples reflect Next.js 16 (October 2025) stable API. The experimental.ppr flag and experimental_ppr route export were removed in Next.js 16 — do not use them in new projects. cacheComponents: true is the stable API. PPR requires the App Router — it is not available on the Pages Router. Always verify behavior with your CDN provider, as edge caching behavior for PPR routes varies by platform.