Next.js Partial Prerendering (PPR) Explained 2026
Every web page has parts that never change and parts that are deeply personal. Your navigation, hero section, and footer are static. Your shopping cart badge, user avatar, and personalized feed are dynamic. Until Partial Prerendering, you had to pick: either prerender the whole page as static HTML (fast, but stale for dynamic parts) or server-render the whole page on every request (fresh, but slower). PPR breaks that trade-off entirely.
Partial Prerendering ships a static HTML shell to the browser in milliseconds, then streams in dynamic content as it resolves — all in a single HTTP request, with no client-side JavaScript needed to stitch the page together.
TL;DR
PPR is the most significant rendering advancement in Next.js since App Router. It uses React Suspense boundaries to mark "dynamic holes" in an otherwise static page. The static shell is served instantly from the CDN edge; dynamic content streams in behind it. You get the TTFB of static generation and the freshness of server rendering — without ISR's stale window or SSR's full-request latency. PPR launched as experimental in Next.js 15 (canary-only in stable releases) and evolved into the Cache Components model in Next.js 16 — the new default for mixed static/dynamic pages.
Key Takeaways
- PPR is experimental in Next.js 15 stable — requires canary builds;
experimental.ppr: 'incremental'is the opt-in - Next.js 16 reframes PPR as Cache Components —
experimental_pprremoved; usecacheComponents: true+"use cache"directive - Static shell served in ~20-50ms from CDN edge; dynamic holes stream in 50-500ms later
- Powered by React Suspense — wrap dynamic components in
<Suspense>to mark dynamic holes - Single HTTP response — no separate API calls from the client; streams via HTTP chunked transfer
connection()is the current dynamic API —unstable_noStore()deprecated in Next.js 15+- Unique advantage over ISR: ISR caches the whole page (no personalization); PPR caches only the shell while serving per-user dynamic content
At a Glance
| SSG | ISR | SSR | PPR | |
|---|---|---|---|---|
| Initial TTFB | ~50ms (CDN) | ~50ms (CDN) | ~200-500ms (server) | ~50ms (shell from CDN) |
| Dynamic data | ❌ Build-time only | ⚠️ Stale for revalidate window | ✅ Every request | ✅ Streams in |
| Personalization | ❌ | ❌ | ✅ | ✅ |
| CDN caching | ✅ Full page | ✅ Full page | ❌ | ✅ Static shell only |
| Cold start impact | None | None | High | Low (shell bypasses it) |
| Suspense required | ❌ | ❌ | Optional | ✅ Required for dynamic |
| Next.js config | Default | revalidate export | Default (no cache) | experimental.ppr (15 canary) → cacheComponents (16) |
How PPR Works
PPR relies on three interconnected mechanisms:
1. The Static Shell
When you build a PPR-enabled page, Next.js renders everything it can statically at build time. The output is a static HTML shell — your layout, navigation, hero content, and any components that don't access request-time data. This shell is cached on CDN edge nodes globally and served instantly (sub-50ms TTFB).
2. Dynamic Holes via Suspense
Any component wrapped in a <Suspense> boundary becomes a "dynamic hole." Next.js recognizes that these components can't be statically rendered and omits them from the static shell — replacing them with your fallback UI during the initial HTML delivery.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { StaticHero } from './hero'
import { UserCart } from './cart'
import { PersonalizedFeed } from './feed'
import { CartSkeleton, FeedSkeleton } from './skeletons'
export default function DashboardPage() {
return (
<main>
{/* ✅ Static — included in CDN-cached shell */}
<StaticHero />
{/* 🔄 Dynamic — streamed after shell */}
<Suspense fallback={<CartSkeleton />}>
<UserCart />
</Suspense>
{/* 🔄 Dynamic — streamed after shell */}
<Suspense fallback={<FeedSkeleton />}>
<PersonalizedFeed />
</Suspense>
</main>
)
}
3. Streaming via HTTP Chunked Transfer
The browser receives the static shell HTML immediately. The same HTTP connection stays open, and as each dynamic component resolves on the server, its HTML is streamed down as additional chunks. React's client-side runtime swaps out the skeleton fallbacks for real content as chunks arrive — without any separate API calls or client-side data fetching.
This is fundamentally different from client-side data fetching (where the browser makes separate requests after hydration) and from full SSR (where the server waits for all data before sending anything).
Enabling PPR
Next.js 15 (Canary Only)
PPR in Next.js 15 requires canary builds — it does not work in the stable Next.js 15 releases. Using it on stable will throw: "The experimental feature 'experimental.ppr' can only be enabled when using the latest canary version of Next.js."
Enable globally in next.config.ts (canary only):
// next.config.ts — Next.js 15 canary
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental', // Per-route opt-in via experimental_ppr segment config
},
}
export default nextConfig
Per-route opt-in:
// app/dashboard/page.tsx
export const experimental_ppr = true
export default function DashboardPage() {
// ...
}
Next.js 16: Cache Components (PPR's Evolution)
In Next.js 16, the experimental_ppr segment config and experimental.ppr flag are removed entirely. PPR's core mechanism — static shell + dynamic holes — is now the foundation of the Cache Components model, enabled via cacheComponents: true and the "use cache" directive.
// next.config.ts — Next.js 16
const nextConfig = {
cacheComponents: true, // Replaces experimental.ppr
}
export default nextConfig
// Next.js 16: Cache Components pattern
// Mark a component's result as cacheable with "use cache"
async function ProductDetails({ id }: { id: string }) {
'use cache' // This component's output is cached and served in the static shell
const product = await getProduct(id)
return <ProductInfo product={product} />
}
// Dynamic components still use Suspense boundaries
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
<ProductDetails id={params.id} /> {/* Cached in shell */}
<Suspense fallback={<PricingSkeleton />}>
<PersonalizedPricing id={params.id} /> {/* Streams in dynamically */}
</Suspense>
</div>
)
}
The "use cache" directive is more granular than the old PPR model — you can mark individual functions or entire components as cacheable, with configurable TTLs and cache tags for on-demand invalidation. This replaces both ISR (export const revalidate) and unstable_noStore() with a unified caching primitive.
Making Components Dynamic
For PPR to work, dynamic components need to signal to Next.js that they require request-time data. There are several ways to do this:
unstable_noStore() — Deprecated
unstable_noStore() from 'next/cache' was the original way to opt a component out of static rendering. It's deprecated as of Next.js 15 — use connection() instead. It still works for backward compatibility but will be removed in a future major version.
// ⚠️ Deprecated — avoid in new code
import { unstable_noStore as noStore } from 'next/cache'
async function UserCart() {
noStore()
const cart = await fetchUserCart()
return <CartUI items={cart.items} />
}
connection() — The Current API
connection() from 'next/server' is the replacement for unstable_noStore(). Awaiting it explicitly signals that this component requires a live request and must not be prerendered:
import { connection } from 'next/server'
async function PersonalizedFeed() {
await connection() // Signals: this component needs the live request
const user = await getCurrentUser()
const feed = await fetchFeed(user.id)
return <FeedUI items={feed} />
}
Accessing Request Data (Cookies, Headers, SearchParams)
Any component that reads from cookies(), headers(), or searchParams is automatically treated as dynamic:
import { cookies } from 'next/headers'
async function ThemeProvider({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies()
const theme = cookieStore.get('theme')?.value ?? 'light'
// This component is now automatically dynamic
return <div data-theme={theme}>{children}</div>
}
Important: If a dynamic component is NOT wrapped in <Suspense>, it causes the entire page to fall back to full SSR (no PPR benefit). The Suspense boundary is what enables the static/dynamic split.
PPR vs ISR: When to Use Each
ISR (Incremental Static Regeneration) and PPR solve overlapping problems but in different ways:
ISR: When Data Is Shared Across All Users
ISR is best when the same content serves all users and has an acceptable staleness window:
// app/blog/[slug]/page.tsx
export const revalidate = 3600 // Regenerate every hour
export default async function BlogPost({ params }) {
const post = await fetchPost(params.slug)
return <Article post={post} />
}
Use ISR for: blog posts, product pages, documentation, news articles, pricing pages.
PPR: When You Need Per-User Data
PPR is best when part of the page is shared (and can be static) and part is user-specific:
// app/product/[id]/page.tsx
export const experimental_ppr = true
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.id) // ← Static, at build time
return (
<div>
<ProductDetails product={product} /> {/* ← Cached in shell */}
<Suspense fallback={<PricingSkeleton />}>
<PersonalizedPricing productId={params.id} /> {/* ← Dynamic: user-specific discounts */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<UserReviewSection productId={params.id} /> {/* ← Dynamic: user's own review */}
</Suspense>
</div>
)
}
The Decision Matrix
| Scenario | Best Strategy |
|---|---|
| Blog, docs, marketing pages | SSG (or ISR if content changes) |
| E-commerce product pages (shared catalog + personal pricing) | PPR |
| News/sports (content changes hourly, same for all) | ISR with short revalidate |
| Dashboards (all data is user-specific) | SSR or PPR with all-dynamic shell |
| Auth-gated pages with common layout | PPR (static nav/layout + dynamic content area) |
| Real-time: stock prices, live scores | SSR + WebSocket / Server-Sent Events |
Performance Impact
The key performance win with PPR is TTFB for personalized pages. Before PPR, a product page with personalized pricing had to SSR everything (200-500ms TTFB). With PPR:
- Static shell arrives in ~50ms from CDN
<Suspense>skeletons are visible immediately- Dynamic content streams in as server computes it (50-300ms later)
The perceived performance is close to static generation even though the page is serving fresh, personalized data. This matters most for:
- E-commerce: Product detail pages with personal pricing, stock, and recommendations
- SaaS dashboards: Common navigation/layout static, user-specific metrics dynamic
- Media sites: Article content static, user-specific (bookmarks, recommendations) dynamic
- Authentication flows: Login page static, post-auth redirect instant
Traditional SSR product page:
[User request] → [200ms server render (product + user data)] → [First byte]
PPR product page:
[User request] → [50ms CDN shell] → [First byte visible]
↳ [Server: 200ms for user data] → [Stream dynamic content]
Common PPR Pitfalls
1. Forgetting Suspense = Full SSR fallback
If a dynamic component (one that calls cookies(), headers(), or noStore()) is not wrapped in <Suspense>, Next.js can't split the page — the whole page falls back to SSR. Always wrap dynamic components in Suspense.
2. Suspense boundaries that are too coarse
Wrapping your entire <main> in a single Suspense boundary defeats the purpose — the entire content area becomes dynamic. Use multiple, targeted Suspense boundaries so the most content possible is in the static shell.
3. Confusing PPR with Client-Side Loading States
PPR streaming happens server-side over a single HTTP connection. It's not the same as a useEffect that fetches data after hydration. PPR content is rendered on the server — it's SEO-crawlable and arrives without JavaScript executing on the client.
4. Not providing meaningful fallback UIs
The fallback shown during streaming is the first thing users see in dynamic areas. Skeleton UIs that match the final layout prevent layout shift and feel intentional. Generic spinners create a poor experience.
PPR in the Broader Next.js Ecosystem
PPR works seamlessly with:
- Vercel's CDN — static shells are automatically edge-cached globally
- Self-hosted Node.js — PPR works without Vercel; just needs a Node.js 18+ server
- Next.js Image optimization — static shell images are preloaded correctly
- React 19 concurrent features — PPR is built on React's streaming architecture
next/font— font optimization works in the static shell
PPR does not yet work with:
- Pages Router (only App Router)
- Static export (
output: 'export'in next.config) — static export requires fully static pages
Compare rendering strategy adoption across npm packages on PkgPulse.
Related: SSR vs SSG vs ISR vs PPR: Full Comparison · Next.js vs Astro vs SvelteKit 2026 · React Server Components vs Astro Islands
See the live comparison
View nextjs rendering strategies on PkgPulse →