Next.js 16 PPR: Eliminate the static vs. dynamic tradeoff and achieve Edge TTFB with fresh dataCode & Development

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

Genildo Souza13 Jul 2026Read: 14 min

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.

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 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>
  );
}
typescript
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.

typescript
"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 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)

⚠ 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

typescript
// 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

typescript
// 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

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.

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