Code & Development

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 16 PPR: Eliminate the static vs. dynamic tradeoff and achieve Edge TTFB with fresh data

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.

SSG / ISR

Static Generation

HTML at build time. CDN cached. Fastest TTFB possible. ✗ Stale user data. No personalization.

SSR

Server Rendering

Fresh data on every request. Full personalization. ✗ Slow TTFB. No CDN. Every user pays the cost.

Streaming SSR

Streaming

Sends chunks as they're ready. Progressive loading. ✗ Entire page is dynamic. No static CDN shell.

PPR · Next.js 16

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.

Build time

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.

Request time

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.

The boundary

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

Next.js 16 change

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.

TYPESCRIPT
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 16

Your first PPR page — product listing example

TYPESCRIPT
app/products/[id]/page.tsx — static shell + dynamic holesimport { Suspense }            from 'react';import { ProductDetails }      from './product-details';   // staticimport { UserCart }            from './user-cart';          // dynamicimport { Recommendations }     from './recommendations';    // dynamicimport { CartSkeleton }        from './skeletons';import { RecommendationsSkeleton } from './skeletons';// This page uses PPR — shell is prerendered, holes are streamedexport 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>  );}
TYPESCRIPT
user-cart.tsx — dynamic component (uses request-time API)// app/products/[id]/user-cart.tsximport { cookies } from 'next/headers'; // ← makes this component dynamicexport 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.

TYPESCRIPT
"use cache" — component-level caching// Cache an individual async function — not the whole componentasync 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 directlyasync 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 Prerendering

Reading the build output

The symbol in the build output is how you confirm PPR is working for a route.

TYPESCRIPT
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)

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
TYPESCRIPT
// The whole page waits for user data// Even the static header is blockedexport 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
TYPESCRIPT
// Static content ships immediately from CDN// Dynamic content streams in parallelexport 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>  );}

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.

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

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

✓ 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

v14
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.
v15
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.
v16
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.

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.

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: true config, 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.