Search across all documentation pages
Serve a static shell instantly and stream dynamic content into Suspense holes -- the best of static and dynamic rendering.
Quick-reference recipe card -- copy-paste ready.
// next.config.ts -- enable PPR
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
ppr: true, // or "incremental" for per-route opt-in
},
};
export default config;// app/page.tsx -- static shell with dynamic holes
import { Suspense } from "react";
import { cookies } from "next/headers";
export default function Page() {
return (
<main>
<h1>Welcome to the Store</h1> {/* Static shell */}
When to reach for this: You have a page where most content is static (product info, marketing copy) but a small part depends on the request (user session, cart, personalization).
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
ppr: "incremental", // opt in per route
},
};
export default config;// app/products/[slug]/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { db } from "@/lib/db";
import { AddToCartButton } from "./add-to-cart";
// Opt this route into PPR
export const experimental_ppr = true;
What this demonstrates:
experimental_ppr = truecookies() and stream in at request timegenerateStaticParams pre-renders product pages at build timecookies(), headers(), searchParams, or cache: "no-store").Incremental adoption (per-route opt-in):
// next.config.ts
export default { experimental: { ppr: "incremental" } };
// Only this page uses PPR
// app/products/[slug]/page.tsx
export const experimental_ppr = true;All routes PPR (global):
// next.config.ts
export default { experimental: { ppr: true } };
// All routes automatically use PPRMultiple dynamic holes with independent data sources:
export default function Page() {
return (
<main>
<StaticNav />
<StaticHero />
{/* Each Suspense boundary is an independent dynamic hole */}
<Suspense fallback={<UserBarSkeleton />}>
<UserBar
// Per-route PPR opt-in export
export const experimental_ppr: boolean = true;
// next.config.ts type
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
ppr: true | "incremental",
PPR is experimental -- As of Next.js 15/16, PPR requires the experimental.ppr flag. The API may change. Fix: Pin your Next.js version and test thoroughly before production use.
Suspense boundary placement determines the split -- If you forget to wrap a dynamic component in Suspense, the entire page becomes dynamic. Fix: Always wrap components that use dynamic functions in a <Suspense> boundary.
Fallback quality matters -- The Suspense fallback is part of the static shell and is visible to all users instantly. A poor fallback creates a bad first impression. Fix: Use well-designed skeletons that match the shape and size of the real content.
Dynamic holes add server cost -- Each dynamic hole requires server computation at request time. Too many dynamic holes negate the CDN benefits. Fix: Consolidate related dynamic data into fewer Suspense boundaries.
No PPR on Edge Runtime for all providers -- PPR requires the hosting platform to support streaming and the postpone API. Fix: Verify your deployment platform supports PPR (Vercel supports it natively).
Client Components in the static shell still ship JavaScript -- PPR makes the HTML static, but Client Components still hydrate on the client. Fix: Keep the static shell composed primarily of Server Components for minimal JavaScript.
| Approach | Use When | Don't Use When |
|---|---|---|
| PPR | Most of the page is static but some parts need request-time data | The entire page is either fully static or fully dynamic |
| Full Static Generation | The entire page can be built at deploy time | Any part needs user-specific data |
| Full Dynamic Rendering | The entire page depends on request-time data | Most content is the same for all users |
| Client-side fetching | Dynamic data should load after hydration, not block SSR | You want the dynamic data in the initial HTML |
| ISR | Content changes periodically but not per-request | Content is personalized per user |
| Streaming SSR (without PPR) | You want progressive rendering without a static shell | You want the shell served from the CDN edge |
PPR combines static generation and dynamic rendering in a single route. The static parts are prerendered at build time and served from the CDN. Dynamic parts are streamed into Suspense boundaries at request time.
Add experimental: { ppr: true } (global) or experimental: { ppr: "incremental" } (per-route opt-in) to next.config.ts. For incremental mode, also export experimental_ppr = true in the page file.
Suspense boundaries define the split. Everything outside a Suspense boundary is part of the static shell. Components inside a Suspense boundary that call dynamic functions (cookies(), headers(), cache: "no-store") become dynamic holes.
The fallback is part of the static shell -- it is prerendered and served instantly from the CDN. Users see the skeleton immediately while the dynamic content streams in.
The entire page becomes dynamic. Without a Suspense boundary, PPR cannot identify the static/dynamic split, so the whole route falls back to full dynamic rendering.
Yes. PPR makes the HTML static, but Client Components still hydrate on the client and ship their JavaScript. Keep the static shell composed primarily of Server Components for minimal JS.
Yes. Each Suspense boundary is an independent dynamic hole with its own data source:
<Suspense fallback={<UserBarSkeleton />}>
<UserBar /> {/* cookies() */}
</Suspense>
<Suspense fallback={<PricingSkeleton />}>
<LivePricing /> {/* cache: "no-store" */}
// next.config.ts
export default { experimental: { ppr: "incremental" } };
// app/products/[slug]/page.tsx
export const experimental_ppr = true;export const experimental_ppr: boolean = true;In next.config.ts, the PPR option is typed as true | "incremental" inside the experimental object of NextConfig.
Yes. generateStaticParams pre-renders product pages at build time as the static shell. Dynamic holes inside those pages stream in at request time.
PPR requires a hosting platform that supports streaming and the postpone API. Vercel supports it natively. Verify your deployment platform before enabling PPR in production.
Each dynamic hole requires server computation at request time. Too many dynamic holes negate the CDN performance benefits of the static shell. Consolidate related dynamic data into fewer Suspense boundaries when possible.