Topics
v5.0.0
On this page · sections

PPR & Advanced Rendering

Partial Prerendering (PPR) prerenders a static HTML shell at build time and streams dynamic holes at request time. Advanced rendering in the App Router also means choosing the right route segment config, dynamic APIs, and Suspense boundaries so static content stays at the CDN while personalized sections stream in.

ApproachShellDynamic contentBest for
Full SSGStaticNone until rebuildMarketing, docs
Full SSRRendered per requestEverything dynamicFully personalized pages
ISRStatic, revalidated on timerWhole page refreshesCatalogs, blogs
PPRStatic shell at CDNHoles stream at request timeStatic LCP + personalized sections
Streaming SSRPer-request chunksAll segments, no persistent shellDashboards, slow DB

What PPR solves

Definition

The classic dilemma: “We want SSG LCP but need cart count and personalized recommendations on the same page.” Without PPR, you choose full SSR (slow TTFB) or full SSG (no personalization).

PPR prerenders static parts at build time and leaves dynamic segments as Suspense holes that stream in per request.

// next.config.ts — verify flag for your pinned Next.js version const nextConfig = { experimental: { ppr: true, // or 'incremental' — check docs for your version }, }; export default nextConfig;
// app/page.tsx — static hero + dynamic hole import { Suspense } from "react"; async function PersonalizedBanner() { const user = await getSessionUser(); // dynamic — request time return <p>Welcome, {user?.name ?? "Guest"}</p>; } export default function HomePage() { return ( <main> <section>{/* static marketing — in shell */}</section> <h1>Shop deals</h1> <Suspense fallback={<div className="skeleton h-6 w-40" />}> <PersonalizedBanner /> </Suspense> </main> ); }

Interview: PPR is the answer when you need SSG-speed shell plus per-user dynamic content on the same route.

Static shell vs dynamic holes

Definition

Next.js analyzes the component tree at build time to determine what is static (shell) vs dynamic (hole).

Static (shell) → Server Components with cacheable fetch → No cookies() / headers() / searchParams in that subtree Dynamic hole → Components using dynamic APIs (cookies, headers, searchParams) → Uncached fetch or Suspense with dynamic child → Streamed at request time

Request lifecycle:

Build time → static shell HTML generated (layout, hero, CMS blocks) → stored at CDN edge Request time → shell served instantly from CDN → dynamic Suspense holes stream in with per-user data → complete page assembles

Interview: Only holes are dynamic. The shell is prerendered and served from the CDN.

Route segment config

Definition

Export segment config from page.tsx, layout.tsx, or route.ts to override Next.js defaults for that route subtree.

// app/products/page.tsx export const dynamic = "force-static"; // always static at build export const revalidate = 3600; // ISR — revalidate every hour export const fetchCache = "default-cache"; // opt into Data Cache // app/dashboard/page.tsx export const dynamic = "force-dynamic"; // always per-request export const fetchCache = "force-no-store";

Common exports:

dynamic → 'auto' | 'force-static' | 'force-dynamic' | 'error' revalidate → false (static) | number (ISR seconds) | 0 (dynamic) fetchCache → 'auto' | 'default-cache' | 'only-cache' | 'force-cache' | 'force-no-store' runtime → 'nodejs' | 'edge' preferredRegion → edge region hint for latency-sensitive routes

Calling cookies(), headers(), or searchParams in a layout makes that subtree dynamic — even if force-static is set elsewhere.

Real-world example

Product catalog: static shell with ISR; stock ticker as a dynamic hole inside Suspense.

// app/products/[id]/page.tsx export const revalidate = 300; async function LiveStock({ id }: { id: string }) { const stock = await fetch(`https://api.example.com/stock/${id}`, { cache: "no-store", }).then((r) => r.json()); return <span>{stock.count} in stock</span>; } export default async function ProductPage(props: { params: Promise<{ id: string }>; }) { const { id } = await props.params; const product = await getProduct(id); // cached with ISR return ( <article> <h1>{product.name}</h1> <p>{product.description}</p> <Suspense fallback={<span>Checking stock…</span>}> <LiveStock id={id} /> </Suspense> </article> ); }

Interview: Segment config controls route-level defaults. Dynamic APIs in a parent layout override child force-static.

PPR vs ISR vs streaming SSR

Definition

These patterns overlap but solve different problems:

ISR → Revalidates whole static pages on a timer → Page is fully static between revalidations Streaming SSR → Entire page rendered per request, sent in chunks → No persistent static shell at CDN PPR → Permanent static shell + request-time dynamic holes → Shell never revalidates; holes always fresh
ScenarioPick
Public blog post, updates hourlyISR
Dashboard, all data per-userFull SSR or streaming
Marketing page + “Hello, Name” bannerPPR
Product page + live stock tickerPPR or hybrid static + client island

Interview: PPR complements ISR — it does not replace it. ISR refreshes whole pages; PPR keeps a permanent shell with dynamic holes.

Blocking vs streaming delivery

Definition

Blocking SSR waits for every async operation before sending HTML. Streaming SSR flushes the shell and Suspense fallbacks first, then streams resolved segments.

// app/dashboard/page.tsx import { Suspense } from "react"; async function SlowMetrics() { const data = await fetchMetrics(); // 2s DB query return <pre>{JSON.stringify(data, null, 2)}</pre>; } export default function DashboardPage() { return ( <main> <h1>Dashboard</h1> <Suspense fallback={<div className="skeleton">Loading metrics…</div>}> <SlowMetrics /> </Suspense> </main> ); }

Timeline:

Blocking: [wait 2s] → complete HTML → TTFB = 2s Streaming: [0ms] shell + skeleton → [2s] metrics chunk streams in

PPR adds a persistent shell at the CDN on top of streaming — the shell does not wait for dynamic holes on repeat visits.

Interview: Streaming changes delivery timing, not where render happens. PPR adds a CDN-persistent shell layer.

Real-world example — e-commerce homepage

E-commerce homepage: static hero, CMS blocks, and category grid in the shell; personalized recommendations and cart count as dynamic holes.

// app/page.tsx import { Suspense } from "react"; async function CartBadge() { const cart = await getCart(); // uses cookies() — dynamic return <span>{cart.itemCount} items</span>; } async function Recommendations() { const user = await getSessionUser(); const items = await fetchRecommendations(user?.id); return <ProductGrid products={items} />; } export default function HomePage() { return ( <main> <Hero /> <FeaturedCategories /> <CMSBlock slug="homepage-promo" /> <Suspense fallback={<BadgeSkeleton />}> <CartBadge /> </Suspense> <Suspense fallback={<GridSkeleton />}> <Recommendations /> </Suspense> </main> ); }

Senior-level usage

Similar philosophy to Astro islands — but integrated in a single React/Next tree with Suspense holes rather than independent hydration roots.

Astro islands → separate hydration roots, strict isolation PPR holes → one coordinated React tree, Suspense streaming Client islands → 'use client' widget for real-time WebSocket data

Interview: PPR vs Astro islands — same static+dynamic split. PPR uses one React tree; Astro uses per-island isolation.

Enabling and rollout

Definition

PPR requires explicit opt-in in next.config.ts. Roll out incrementally:

// Per-route opt-in (when supported by your version) export const experimental_ppr = true; export default function Page() { // ... }

Verify the flag name (ppr / experimental_ppr) for your pinned Next.js version at deploy time — the API evolved across Next.js 14–15.

Footguns:

Dynamic API in layout → entire subtree becomes dynamic Uncached fetch in shell → prevents shell prerendering Missing Suspense on hole → hole may block shell delivery force-static + cookies() → conflict — dynamic wins

Interview: Calling cookies() or headers() in a layout makes that subtree dynamic. Keep dynamic APIs in leaf components inside Suspense.

When not to use PPR

Definition

Fully personalized page → full SSR is simpler Rarely visited long-tail → ISR on-demand may suffice Real-time data everywhere → CSR client fetch or full SSR Simple static site → plain SSG — PPR adds complexity for no gain

PPR shines on high-traffic routes where the majority of content is static but a few sections need request-time data.

Senior Interview Summary

PPR

Static shell at CDN + dynamic Suspense holes streamed per request. Combines SSG LCP with personalization.

Static vs dynamic split

Build-time analysis determines shell vs holes. Dynamic APIs (cookies, headers) mark holes.

Segment config

dynamic, revalidate, fetchCache set route defaults — parent dynamic APIs override children.

Best mental model

Shell → "CDN-served, build-time HTML." Hole → "Request-time, streamed via Suspense." PPR → "SSG speed + SSR personalization, same page." ISR → "Whole page refreshes on timer." Stream → "Per-request chunks, no persistent shell."

Practical rule

Static marketing + user banner → PPR Whole page per-user → full SSR Public content, periodic update → ISR Mostly static, one live widget → PPR hole or client island High-traffic hybrid routes → PPR with incremental rollout

Last updated on