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.
| Approach | Shell | Dynamic content | Best for |
|---|---|---|---|
| Full SSG | Static | None until rebuild | Marketing, docs |
| Full SSR | Rendered per request | Everything dynamic | Fully personalized pages |
| ISR | Static, revalidated on timer | Whole page refreshes | Catalogs, blogs |
| PPR | Static shell at CDN | Holes stream at request time | Static LCP + personalized sections |
| Streaming SSR | Per-request chunks | All segments, no persistent shell | Dashboards, 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 timeRequest 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 assemblesInterview: 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 routesCalling 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| Scenario | Pick |
|---|---|
| Public blog post, updates hourly | ISR |
| Dashboard, all data per-user | Full SSR or streaming |
| Marketing page + “Hello, Name” banner | PPR |
| Product page + live stock ticker | PPR 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 inPPR 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 dataInterview: 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 winsInterview: Calling
cookies()orheaders()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 gainPPR 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,fetchCacheset 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