Throughout the history of web development, engineers had to make an architectural compromise between Static Site Generation (SSG) and dynamic server-side rendering (SSR):
Attempting to resolve this dilemma on the client side (shipping a static loading skeleton followed by a client-side fetch in useEffect) severely harms Core Web Vitals: layout shifts (CLS), interaction delays (INP), and client-side network waterfalls.
Partial Prerendering (PPR) eliminates this compromise at the architecture level. PPR compiles the static shell of the page (Layouts, navigation, footers, fallback skeletons) at build time, while dynamically rendering personalized interface zones on the server upon incoming request and streaming them into the very same continuous HTTP connection.
Partial Prerendering bifurcates the page lifecycle into two distinct execution phases:
Build-time Prerender Pass:
The Next.js compiler traverses the component tree during next build. Static sections of the route are pre-rendered into an immutable Static Shell. When the compiler encounters a <Suspense> boundary containing calls to dynamic runtime APIs (await cookies(), await headers(), dynamic searchParams), the runtime triggers the React Postpone API. Execution of that dynamic subtree is suspended, the fallback skeleton is embedded into the Static Shell, and a Dynamic Hole marker is generated. The finalized Static Shell is uploaded to Edge CDN caches.
Request-time Resume Pass: When a user requests the URL:
PHASE 1: PROJECT COMPILATION (next build / Build-time Prerender Pass)
─────────────────────────────────────────────────────────────────────────────
Compiler traverses route component tree:
[Root Layout] ──► [Navigation] ──► [Static Products] ──► [<Suspense>]
│
cookies() encountered!
│
React.postpone() ──┐
▼
[Persist to Static Shell (HTML + RSC)] ◄── [Embed CartSkeleton fallback]
│
▼
Deploy to Edge CDN Cache
PHASE 2: REQUEST EXECUTION (Request-time Resume Pass)
─────────────────────────────────────────────────────────────────────────────
Browser Edge CDN Serverless Worker (Origin)
│ │ │
(1)├─── GET /shop ──────────►│ │
│ ├── Immediate Shell Flush (TTFB)───►│
│◄─── Chunk 1: Static Shell┤ [User perceives FCP in 30ms!] │
│ (with CartSkeleton) │ │
│ │ Resume from postpone point:
│ │ const cookie = await cookies();
│ │ const cart = await fetchCart();
│ │ │
(2)│ │◄── Chunk 2: Dynamic Hole ─────────┤
│◄── Replace Skeleton ────┤ Cart HTML + $RC() │
│ with live content │ │Next.js builds the static page shell. When encountering dynamic APIs inside Suspense, it suspends execution via React.postpone() and persists the fallback skeleton.
The user requests the page. Edge CDN immediately streams the cached Static Shell. The browser renders the interface structure within 20–30 ms.
The application server resumes execution strictly inside dynamic holes, reading incoming cookies and executing live database queries.
Rendered dynamic HTML and RSC Flight Payloads stream through the open HTTP socket without full-page reloads.
Inline React client scripts swap out the skeleton for actual content, followed by selective hydration of interactive components.
What happens during static compilation (next build) if a component in a route with experimental_ppr = true accesses await cookies() WITHOUT being wrapped in a <Suspense> boundary?
next.config.ts#In modern Next.js releases, Partial Prerendering is activated through the experimental configuration block. The 'incremental' option enables adopting PPR incrementally on a per-route basis:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
// 'incremental' enables route-level PPR adoption via export const experimental_ppr = true
ppr: 'incremental',
},
};
export default nextConfig;To enable Partial Prerendering for a specific page or layout, export the experimental_ppr constant:
import { Suspense } from 'react';
import { StaticCatalogHeader } from '@/components/StaticCatalogHeader';
import { StaticProductGrid } from '@/components/StaticProductGrid';
import { PersonalizedCartWidget } from '@/components/PersonalizedCartWidget';
import { CartSkeleton } from '@/components/skeletons';
// Enable Partial Prerendering for this route segment
export const experimental_ppr = true;
export default function CatalogPage() {
return (
<main className="max-w-7xl mx-auto p-6 space-y-8">
{/* Static section: compiled at build time (Build-time Prerender) */}
<StaticCatalogHeader />
<div className="flex justify-between items-start gap-8">
{/* Static product catalog: served from Edge CDN instantly */}
<div className="flex-1">
<StaticProductGrid />
</div>
{/* Suspense boundary declares a dynamic hole */}
<aside className="w-80">
<Suspense fallback={<CartSkeleton />}>
{/* Reads cookies() and evaluates during incoming request execution */}
<PersonalizedCartWidget />
</Suspense>
</aside>
</div>
</main>
);
}A component is automatically classified as a dynamic hole if any of the following APIs are invoked in its render tree:
await cookies() from 'next/headers' (asynchronous in Next.js 15+)await headers() from 'next/headers' (asynchronous in Next.js 15+)searchParams prop (params: Promise<...>, searchParams: Promise<...>)await connection() from 'next/server' (explicit trigger for deferred dynamic streaming)fetch(..., { cache: 'no-store' })In e-commerce platforms, product catalogs remain identical across visitors, while user cart counters rely on session authentication cookies:
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
export async function PersonalizedCartWidget() {
// In Next.js 15+, cookies() is strictly asynchronous!
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session_token')?.value;
if (!sessionToken) {
return <div className="text-sm text-text-muted">Cart is empty</div>;
}
// Executed exclusively during user request processing
const cart = await db.cart.findUnique({
where: { sessionToken },
include: { items: true },
});
const count = cart?.items.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-border bg-bg-elevated">
<span className="text-sm font-medium text-text-primary">Cart Items:</span>
<span className="font-mono font-bold text-accent-cyan">{count}</span>
</div>
);
}connection()#When a component does not read cookies but runs heavy analytics queries or fetches volatile market prices that should not be frozen during the build, use the connection() utility:
import { connection } from 'next/server';
import { fetchLiveExchangeRates } from '@/lib/finance';
export async function LiveStockTicker() {
// await connection() explicitly invokes React.postpone() at build time!
// This instructs the compiler: do not attempt to run this code during the build,
// execute it strictly when a real client connection is established.
await connection();
const rates = await fetchLiveExchangeRates();
return (
<div className="flex gap-4 text-xs font-mono py-1 overflow-x-auto text-text-muted">
<span>USD/EUR: {rates.USDEUR}</span>
<span>GBP/USD: {rates.GBPUSD}</span>
<span>BTC/USD: {rates.BTCUSD}</span>
</div>
);
}What does the application origin server execute when a request hits a PPR-enabled page whose Static Shell is already cached at the Edge CDN?
If you invoke asynchronous dynamic functions (await cookies(), await headers(), accessing searchParams) inside a Server Component that is not wrapped in a <Suspense> boundary while PPR is active, Next.js cannot generate a Static Shell and terminates next build with a build error.
Solution: Every component accessing dynamic request context must be enclosed in <Suspense fallback={<Skeleton />}>.
Error: Route /shop with `experimental_ppr` enabled accessed dynamic data without a Suspense boundary.Root layouts (layout.tsx) in Next.js intentionally do not have access to the searchParams prop. Layouts are persistent across subroute navigations to maximize caching efficiency within the Static Shell.
Solution: Access searchParams inside page.tsx or isolate search query handling in a Client Component using useSearchParams().
Because a PPR response begins with a static CDN chunk and terminates with a dynamic server stream, misconfigured corporate proxies or reverse proxies (such as Nginx) may attempt to cache the entire response body, including private user cart data!
Solution: When self-hosting via Docker or custom Nginx setups, verify that reverse proxies support Transfer-Encoding: chunked and do not buffer responses marked with Cache-Control: private, no-cache, no-store.
Wrapping dozens of minute text nodes in individual Suspense boundaries fragments the HTTP response into hundreds of micro-chunks, increasing TCP and TLS packet overhead. Group related dynamic components into logical compound containers.