Next.js 15/16 Partial Prerendering Practical Guide — Suspense Boundary Design for Minimizing FCP with Static Shell and Dynamic Streaming
A product detail page that had a TTFB (Time To First Byte) of 680ms dropped to 65ms after applying PPR. This figure, confirmed in a Vercel case study, is not the result of a simple optimization trick — it's the result of changing the rendering model itself. FCP (First Contentful Paint) is one of the biggest reasons users see a blank screen while waiting for DB queries, and Partial Prerendering addresses this problem at a structural level. By the end of this article, you'll be able to apply PPR to an existing Next.js App Router route in under 30 minutes.
TTFB (Time To First Byte): The time from when the browser sends a request to the server until it receives the first byte. FCP (First Contentful Paint): The moment the browser paints the first content — text, images, etc. — from the DOM onto the screen; a key Core Web Vitals metric.
The long-standing dilemma of web performance optimization was simple: "Do you serve it fast with static, or show up-to-date data with dynamic?" SSG responds instantly from CDN edges but can't carry real-time data, while SSR increases TTFB by waiting for all data. ISR is a compromise, but it still can't escape the binary of "make the entire page static or not." Next.js's Partial Prerendering (PPR) is a rendering model that dissolves this binary — within a single route, the parts that don't change are placed on the CDN as static HTML at build time, while the parts that must vary per request are filled in via streaming at runtime.
This article is aimed at frontend developers who already understand the basics of React Suspense and the Next.js App Router. It covers the internals of how PPR works, the HTTP streaming mechanism, how to apply it across real production scenarios, and the Cache Components API (use cache, cacheLife, cacheTag) that was stabilized in Next.js 16 — all in one place.
Core Concepts
PPR's Three-Phase Operation and the HTTP Streaming Mechanism
PPR operates across two phases: build time and request time.
Phase 1 — Pre-build the static shell
At build time, all components outside <Suspense> boundaries are rendered as static HTML and stored on CDN edges. Content that doesn't depend on request data — navigation, layouts, product images — is all included here.
Phase 2 — Reserve dynamic holes
Dynamic component areas wrapped in <Suspense> are replaced with fallback UI (skeletons, loading spinners, etc.) at build time. React internally marks these areas with <template>-tag-based markers, reserving positions for where dynamic content will be inserted later.
Phase 3 — Stream via a single HTTP response
When a request arrives, the server immediately begins sending the static shell from the CDN as the first HTTP chunk. Simultaneously, the server origin renders the components corresponding to dynamic holes in parallel and, as each completes, appends them as additional chunks to the same HTTP response stream. The browser parses the static shell chunk first to paint the screen, then fills the holes with the dynamic chunks as they arrive. The biggest technical advantage of PPR is that static and dynamic coexist within a single HTTP connection, so no additional network round trips occur.
The key boundary: Outside
<Suspense>= statically pre-rendered / Inside<Suspense>= dynamically streamed at request time. This single boundary is the entirety of PPR's design.
Comparison with Existing Rendering Approaches
| Approach | FCP Speed | Dynamic Data | Server Load | Design Complexity |
|---|---|---|---|---|
| SSG | Very fast (CDN) | Not possible | Low | Low |
| SSR | Slow (DB wait) | Possible | High | Low |
| ISR | Fast (before expiry) | Partial | Medium | Medium |
| PPR | Very fast (CDN) | Possible (streaming) | Medium | Medium–High |
Enabling PPR: Differences Between Next.js 15 and 16
PPR was introduced experimentally in Next.js 14 and stabilized in Next.js 16 (October 2025).
Important: PPR is an App Router-only feature. Because it presupposes the React Server Components (RSC) architecture, it cannot be used with the Pages Router.
Next.js 15 — incremental mode
incremental mode exists to let you introduce PPR into existing projects without risk. Instead of applying PPR to the entire app at once, you can declare experimental_ppr = true only in the route files you want, applying it progressively on a per-route basis.
// next.config.ts (Next.js 15)
const nextConfig = {
experimental: {
ppr: 'incremental', // opt-in per route, not for the entire app
},
};
export default nextConfig;// app/products/[id]/page.tsx
export const experimental_ppr = true; // PPR enabled for this route only
export default function ProductPage() {
return (
<main>
{/* Static shell: to CDN at build time */}
<ProductHeader />
<ProductImages />
{/* Dynamic holes: streamed at request time */}
<Suspense fallback={<PriceSkeleton />}>
<RealtimePrice />
</Suspense>
</main>
);
}Next.js 16 — Cache Components and the use cache directive
In Next.js 16, PPR was stabilized under the name Cache Components. The core change is a shift in how caching works. Previously, Next.js implicitly decided whether to cache a component; from Next.js 16 onward, developers explicitly declare intent via the use cache directive — "include this component's result in the static cache." A component declared with use cache has its result cached at build time or on the first request, and subsequent requests receive an immediate response from the CDN. In other words, it automatically becomes part of the static shell.
// next.config.ts (Next.js 16)
const nextConfig = {
cacheComponents: true, // stabilized config, replaces experimental.ppr
};
export default nextConfig;// components/ProductHeader.tsx (Next.js 16)
'use cache'; // this component's result is included in the static cache
import { cacheLife, cacheTag } from 'next/cache';
export async function ProductHeader({ productId }: { productId: string }) {
// Cache lifetime: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks'
cacheLife('days');
// Register a tag → later, calling revalidateTag() can selectively invalidate just this component
cacheTag(`product-${productId}`);
const product = await fetch(`/api/products/${productId}`).then(r => r.json());
return <header>{product.name}</header>;
}A tag registered with cacheTag can be immediately invalidated (On-Demand Revalidation) simply by calling revalidateTag('product-123') from a Server Action or Route Handler. For example, when product information is updated, the admin API calls revalidateTag(product-${productId}) to selectively re-cache only that product's page.
Practical Application
Example 1: E-commerce Product Detail Page
This is the most representative PPR scenario. Product names, descriptions, and images rarely change, so they go in the static shell, while real-time stock, prices, and personalized recommendations are separated into dynamic holes.
// app/products/[id]/page.tsx (Next.js 15)
export const experimental_ppr = true;
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>; // params is a Promise from Next.js 15 onward
}) {
const { id } = await params;
return (
<main className="product-page">
{/* Static shell: deployed to CDN at build time */}
<Breadcrumb />
<ProductImages productId={id} />
<ProductDescription productId={id} />
<ShippingInfo />
{/* Dynamic holes: parallel streaming from server per request */}
<Suspense fallback={<PriceSkeleton />}>
<RealtimePrice productId={id} />
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<StockStatus productId={id} />
</Suspense>
<Suspense fallback={<CartSkeleton />}>
<CartButton productId={id} />
</Suspense>
<Suspense fallback={<RecommendationSkeleton />}>
<PersonalizedRecommendations productId={id} />
</Suspense>
</main>
);
}ProductImages and ProductDescription are included in the static shell even if they use fetch internally, because the default is cached:
// components/ProductImages.tsx — static shell component
export async function ProductImages({ productId }: { productId: string }) {
// Default fetch is cached → included in static shell at build time
const images = await fetch(`/api/products/${productId}/images`).then(r =>
r.json()
);
return (
<div className="product-images">
{images.map((img: { src: string; alt: string }) => (
<img key={img.src} src={img.src} alt={img.alt} />
))}
</div>
);
}Components that must fetch fresh data on every request — like real-time prices — explicitly specify cache: 'no-store' to be processed dynamically:
// components/RealtimePrice.tsx — dynamic hole component
export async function RealtimePrice({ productId }: { productId: string }) {
// cache: 'no-store' → fresh fetch on every request → handled dynamically inside Suspense
const price = await fetch(`/api/products/${productId}/price`, {
cache: 'no-store',
}).then(r => r.json());
return <div className="price">{price.amount.toLocaleString()} KRW</div>;
}| Area | Handling | Reason |
|---|---|---|
| Product images & description | Static shell | Default fetch (cached), rarely changes |
| Navigation & breadcrumb | Static shell | Shared layout, user-independent |
| Real-time price & stock | Dynamic hole | cache: 'no-store', requires real-time data |
| Cart button | Dynamic hole | Requires cookie-based user state |
| Personalized recommendations | Dynamic hole | Different results per user |
Example 2: SaaS Dashboard — The Benefits of Parallel Streaming
The core reason PPR outperforms SSR in a dashboard scenario is parallel streaming. With SSR, the first screen isn't painted until all data fetches complete; with PPR, each <Suspense> hole is processed independently and in parallel. Pay attention to the timing comments in the example below:
// app/dashboard/page.tsx
export const experimental_ppr = true;
export default function DashboardPage() {
return (
<DashboardLayout> {/* static: sidebar, header, grid container */}
{/* The four holes below each start independently in parallel */}
<Suspense fallback={<KpiSkeleton />}>
<KpiMetrics /> {/* DB query 200ms */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* External API 450ms */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders /> {/* DB query 120ms */}
</Suspense>
<Suspense fallback={<AlertSkeleton />}>
<SystemAlerts /> {/* Internal API 80ms */}
</Suspense>
</DashboardLayout>
);
}Parallel streaming effect: With SSR, you'd wait 200 + 450 + 120 + 80 = 850ms before the page is painted. With PPR, the layout responds instantly from the CDN, all four holes start simultaneously, and all holes are filled after 450ms — the slowest external API. Users immediately see the interface structure, then experience each hole filling in naturally as it becomes ready.
Example 3: Separating Public and Personalized Areas in an Auth App
// app/home/page.tsx
export const experimental_ppr = true;
export default function HomePage() {
return (
<>
{/* Static: public content regardless of login status */}
<HeroSection />
<FeaturedContent />
<PublicNav />
{/* Dynamic: login state and per-user content */}
<Suspense fallback={<UserNavSkeleton />}>
<UserNavBar /> {/* internally checks session via cookies() */}
</Suspense>
<Suspense fallback={<NotificationSkeleton />}>
<Notifications /> {/* per-user notifications */}
</Suspense>
</>
);
}It's important that components calling request-dependent APIs like cookies() and headers() always be placed inside <Suspense>. Otherwise, those components will cause the entire page to be rendered dynamically.
3 Common Mistakes
1. Calling cookies() / headers() outside <Suspense>
If a component outside <Suspense> calls cookies() or headers(), the entire page will be processed dynamically. It's always safest to place any component that accesses request-dependent data inside <Suspense>.
// ❌ Wrong: UserGreeting outside Suspense calls cookies() → entire page rendered dynamically
export default function Page() {
return (
<main>
<StaticHero />
<UserGreeting /> {/* cookies() call → invalidates static shell */}
</main>
);
}
// ✅ Correct: wrap in Suspense so only that area is dynamic
export default function Page() {
return (
<main>
<StaticHero />
<Suspense fallback={<UserGreetingSkeleton />}>
<UserGreeting />
</Suspense>
</main>
);
}2. Declaring <Suspense> without a fallback UI
Omitting the fallback prop or setting it to null means the area appears empty until the dynamic data is filled in, which can cause layout shift (CLS). It's recommended to design a fallback UI that provides visual feedback — a skeleton or spinner.
// ❌ Suspense without fallback — can cause layout shift (CLS)
<Suspense>
<DynamicWidget />
</Suspense>
// ✅ Reserve space with a skeleton and provide visual feedback
<Suspense fallback={<WidgetSkeleton />}>
<DynamicWidget />
</Suspense>3. Making Suspense boundaries too large
Wrapping a wide area of the page in a single <Suspense> means all content inside must wait for the slowest piece of data. Components with independent data should each have their own <Suspense> to maximize the benefits of parallel streaming.
// ❌ One large Suspense — all widgets wait for the slowest API
<Suspense fallback={<DashboardSkeleton />}>
<KpiMetrics />
<RevenueChart /> {/* slowest external API */}
<RecentOrders />
</Suspense>
// ✅ Independent Suspense — each widget appears as soon as it's ready
<Suspense fallback={<KpiSkeleton />}><KpiMetrics /></Suspense>
<Suspense fallback={<ChartSkeleton />}><RevenueChart /></Suspense>
<Suspense fallback={<TableSkeleton />}><RecentOrders /></Suspense>Pros and Cons
Advantages
| Item | Description |
|---|---|
| Dramatically reduced FCP | The static shell is served instantly from CDN edges, so TTFB is determined by the user's geographic distance to the CDN, not DB query speed |
| Single HTTP round trip | Static HTML and dynamic streaming are delivered in the same response stream, with no unnecessary network round trips |
| Reduced server load | The CDN handles static parts; the origin server only handles dynamic parts |
| SSG + SSR balance | You can serve content that is as fast as SSG and as dynamic as SSR simultaneously |
| Incremental adoption | incremental mode lets you selectively apply PPR to specific routes in an existing project |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Suspense boundary design overhead | You must explicitly design static/dynamic boundaries; incorrect placement results in no benefit or build errors | Classify each component's data dependencies in advance and share a design document with your team |
| Caution with cookies/headers access | If a component calling cookies() or headers() is outside <Suspense>, the entire page is rendered dynamically |
Make it a team-wide rule that all components accessing request data must be placed inside <Suspense> |
| Difficulty detecting build errors | Accessing uncached data outside <Suspense> produces an Uncached data was accessed outside of <Suspense> error |
Make active use of error messages in development and consider including a PPR compatibility check in your build pipeline |
| Platform dependency | Maximizing CDN edge caching and streaming optimizations requires Vercel or a platform that supports PPR | Self-hosted environments require separate configuration of Node.js streaming HTTP settings and a CDN layer |
| App Router only | Because it presupposes the React Server Components architecture, it cannot be used with the Pages Router | You'll need to migrate to App Router or apply it only to new routes |
| Learning curve | Moving from the SSR/SSG mindset to the new design thinking of "how far is static?" is required | Incremental learning is recommended — start with a small route and build intuition for boundary design |
Closing Thoughts
When introducing PPR, the first thing to examine is your Suspense boundary design. Simply identifying which components use request-dependent data (cookies(), headers(), cache: 'no-store' fetches) and wrapping each of those boundaries in its own <Suspense> is enough to achieve SSG-level FCP and SSR-level dynamic data at the same time.
Using Next.js 15's incremental mode, you can start on a per-route basis within an existing project, and then migrate naturally to the stabilized Cache Components API alongside the use cache directive in Next.js 16. Here's a good way to get started in 3 steps:
- Add the PPR flag to
next.config.ts. For Next.js 15, setexperimental: { ppr: 'incremental' }; for Next.js 16, setcacheComponents: true. Then declareexport const experimental_ppr = trueat the top of the route file with the most traffic. - Classify the components in that route as static or dynamic. List out the components that access
cookies(),headers(),cache: 'no-store'fetches, or user-personalized data, and wrap each of them in<Suspense fallback={<ComponentSkeleton />}>. - After building, compare the Network tab and Core Web Vitals. Run a production build with
pnpm build && pnpm start, verify in Chrome DevTools' Network tab that the first response is streaming, and measure changes in FCP numbers with Lighthouse.
Next article: The next article will cover the full options for
cacheLifeintroduced briefly here, On-Demand invalidation patterns withcacheTag, and real-time cache refresh strategies usingrevalidateTagin Server Actions — Next.js 16 Cache API Complete Guide.
References
- Partial Prerendering | Next.js Official Docs (v15)
- Partial Prerendering — Cache Components | Next.js Official Docs (v16)
- Partial prerendering: Building towards a new default rendering model | Vercel Blog
- Next.js 16 Release Notes
- Next.js 15 Release Notes
- ppr Config API Reference | Next.js
- cacheComponents Config API Reference | Next.js
- Next.js Partial Prerendering (PPR) Deep Dive | DEV Community
- Next.js 16 Release Analysis | InfoQ
- A guide to enabling partial pre-rendering in Next.js | LogRocket
- partialprerendering.com