
What Is Partial Prerendering (PPR) in Next.js?
For years, every Next.js route had to pick a side. A route was either static, built once and served from a CDN in milliseconds, or dynamic, rendered on the server for every request so it could read cookies, headers, or fresh data. One personalized element, such as a cart count or a "Welcome back" message, was enough to push an entire product page into dynamic rendering, and every visitor paid for it with a slower first byte.
Partial Prerendering (PPR) removes that all-or-nothing choice. A single route can now be mostly static and partly dynamic at the same time. Next.js prerenders everything it can into a static HTML shell, serves that shell immediately, and streams the dynamic parts into the same response as they become ready.
This article covers what Partial Prerendering is, how it works under the hood, how to enable it in Next.js 16 with cacheComponents, how to decide what goes into the static shell, how use cache and <Suspense> boundaries shape the output, what changed from the experimental PPR in Next.js 15, and how to verify and deploy a PPR route.
What Partial Prerendering Means
Partial Prerendering is a rendering model that combines prerendering and dynamic rendering in a single route. At build time, Next.js renders your component tree as far as it can. Anything that does not depend on the incoming request, or that you have explicitly cached, becomes part of a static shell. Anything that needs request-time information, such as cookies, headers, search params, or uncached data, is left behind a <Suspense> boundary, and the boundary's fallback is written into the shell in its place.
When a request arrives:
- The server sends the static shell immediately. It can come straight from a CDN.
- The server resumes rendering only the dynamic parts that were postponed at build time.
- The dynamic HTML streams into the same HTTP response and replaces the fallbacks.
The visitor sees the page layout, navigation, product details, and article text instantly, while the personalized or real-time pieces fill in a moment later.
| Rendering model | When HTML is produced | Can read cookies/headers | First byte speed |
|---|---|---|---|
| Static (SSG) | Build time | No | Fastest |
| Incremental (ISR) | Build time, regenerated in background | No | Fastest |
| Dynamic (SSR) | Every request | Yes | Slowest |
| Partial Prerendering (PPR) | Shell at build time, holes per request | Yes, inside Suspense | Fast, like static |
If you need a refresher on the two classic models PPR blends together, see what is static site generation in Next.js and what is server-side rendering in Next.js.
Why PPR Exists
Consider a typical e-commerce product page:
- Header, navigation, footer: identical for everyone.
- Product title, images, description, price: change only when the catalog changes.
- Cart badge and "Recommended for you": different for every visitor.
- Stock level: changes constantly.
Before PPR, the cart badge reading a cookie forced the whole page to render on every request. Teams worked around it by moving personalized parts into client components that fetched data after hydration, which added loading spinners, extra round trips, and layout shift. PPR keeps those parts on the server, renders them in the same response, and still lets 90 percent of the page come from a static cache.
The practical benefits:
- Faster Time to First Byte and First Contentful Paint, because the shell is static.
- No client-side waterfalls for personalized data. The dynamic parts are server-rendered and streamed.
- One route, one request. There is no separate API call for the personalized section.
- Better Core Web Vitals. Fallbacks reserve space in the shell, so content streaming in does not have to shift the layout.
How PPR Works Under the Hood
At build time, for each route that uses PPR, Next.js produces three artifacts:
- A static HTML shell containing all prerenderable content, with
<Suspense>fallbacks where dynamic content will appear. - A postponed state, an opaque serialized value that tells the server where rendering stopped and how to resume.
- An RSC payload for the static portions, used for client-side navigation.
With next start, the server handles the shell and the dynamic render in a single pass. Platforms that cache the shell on a CDN use a resume protocol instead: the CDN serves the cached shell, sends a resume request with the postponed state to the origin, and stitches the streamed dynamic HTML onto the shell. You do not need to implement any of this in your application code. It matters only if you build a custom deployment adapter.
The key idea is that Suspense boundaries define where the static shell ends and streaming begins. You are not configuring PPR route by route. You are shaping it with the same React primitives you already use.
Enabling PPR in Next.js 16
In Next.js 16, Partial Prerendering is not a separate flag. It is the default rendering behavior of Cache Components, which you enable with one option:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
Once cacheComponents is on:
- Data fetching is dynamic by default. Nothing is cached unless you say so with
use cache. - Every route is prerendered into a static shell where possible.
- Next.js validates your routes in development and tells you when a component would block the shell.
If you used experimental PPR in Next.js 15
Next.js 15 canaries had experimental.ppr in next.config and an experimental_ppr route segment export. Both have been removed in Next.js 16:
// next.config.ts (Next.js 15 canary, no longer valid)
const nextConfig = {
experimental: {
ppr: "incremental",
},
};
// app/products/page.tsx (Next.js 15 canary, no longer valid)
export const experimental_ppr = true;
Remove them, set cacheComponents: true, and run the codemod to clean up segment configs:
npx @next/codemod@latest remove-experimental-ppr .
PPR in Next.js 16 behaves differently from the 15 canaries, mainly because caching is now opt-in through use cache. If you upgrade a large app, read the migration guide before flipping the flag in production. Also note that Cache Components requires the Node.js runtime, so routes that export runtime = 'edge' must move to Node.js.
Building a PPR Page Step by Step
Let's build a product page with three kinds of content: static, cached, and dynamic.
Step 1: Static content
Plain JSX, imports, and pure computations are prerendered automatically:
// app/products/[id]/page.tsx
import { Suspense } from "react";
import { ProductDetails } from "./product-details";
import { CartBadge } from "./cart-badge";
import { StockLevel } from "./stock-level";
export default function ProductPage({ params }: PageProps<"/products/[id]">) {
return (
<main className="container">
<header className="flex items-center justify-between">
<a href="/">Acme Store</a>
<Suspense fallback={<span className="badge">Cart</span>}>
<CartBadge />
</Suspense>
</header>
<Suspense fallback={<ProductSkeleton />}>
{params.then(({ id }) => (
<ProductDetails id={id} />
))}
</Suspense>
<Suspense fallback={<p>Checking stock...</p>}>
{params.then(({ id }) => (
<StockLevel id={id} />
))}
</Suspense>
</main>
);
}
function ProductSkeleton() {
return <div className="h-96 animate-pulse rounded bg-gray-100" />;
}
Notice that the page component is not async and does not await params at the top. Awaiting params at the top level would make the whole page wait for request data. Passing the params promise down and resolving it inside a Suspense boundary keeps the header and the fallbacks in the static shell. PageProps<"/products/[id]"> is a globally available type helper generated by Next.js 16; run npx next typegen if your editor does not see it yet.
Step 2: Cached content with use cache
Product details change only when the catalog changes, so they should be cached and become part of the shell for known products:
// app/products/[id]/product-details.tsx
import { cacheLife, cacheTag } from "next/cache";
import { db } from "@/lib/db";
export async function ProductDetails({ id }: { id: string }) {
"use cache";
cacheLife("hours");
cacheTag(`product-${id}`);
const product = await db.product.findUnique({ where: { id } });
if (!product) {
return <p>Product not found.</p>;
}
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p className="text-2xl font-bold">
${(product.priceCents / 100).toFixed(2)}
</p>
</article>
);
}
use cache tells Next.js the result can be reused across requests. The id argument becomes part of the cache key automatically, so each product gets its own entry. cacheLife("hours") sets how long the entry stays fresh, and cacheTag lets you invalidate it on demand when an admin edits the product.
Step 3: Dynamic content that reads the request
The cart badge reads a cookie, so it can only render at request time:
// app/products/[id]/cart-badge.tsx
import { cookies } from "next/headers";
import { getCartCount } from "@/lib/cart";
export async function CartBadge() {
const cartId = (await cookies()).get("cart_id")?.value;
const count = cartId ? await getCartCount(cartId) : 0;
return <span className="badge">Cart ({count})</span>;
}
Step 4: Fresh data on every request
Stock level should never be cached, and it does not read cookies. Uncached data fetches are dynamic by default under Cache Components, so wrapping the component in <Suspense> is enough:
// app/products/[id]/stock-level.tsx
import { getLiveStock } from "@/lib/inventory";
export async function StockLevel({ id }: { id: string }) {
const stock = await getLiveStock(id);
if (stock === 0) return <p className="text-red-600">Out of stock</p>;
if (stock < 5) return <p className="text-amber-600">Only {stock} left</p>;
return <p className="text-green-700">In stock</p>;
}
Step 5: Prerender known products
To include concrete product content in the shell for your best sellers, return them from generateStaticParams. With Cache Components, it must return at least one param:
// app/products/[id]/page.tsx
import { db } from "@/lib/db";
export async function generateStaticParams() {
const top = await db.product.findMany({
select: { id: true },
orderBy: { sales: "desc" },
take: 50,
});
return top.map((p) => ({ id: p.id }));
}
Products not returned here still work. Next.js serves a reusable App Shell with the param-specific parts behind their fallbacks, then fills in the concrete version after the first visit. Note that dynamicParams is not supported with Cache Components; if you used dynamicParams = false to reject unknown IDs, call notFound() inside the component instead.
What Ends Up in the Static Shell
The rule of thumb: Next.js renders the tree at build time, and each component lands in one of these buckets.
| What the component does | Where it goes |
|---|---|
| Plain JSX, imports, pure computation | Static shell |
use cache with a long enough cacheLife | Static shell |
Reads cookies(), headers(), searchParams, unknown params | Fallback in shell, content streams at request time |
Uncached fetch or database query | Fallback in shell, content streams at request time |
Math.random(), Date.now(), crypto.randomUUID() | Must be cached, or deferred with connection() |
Random values and timestamps
Values like Date.now() would be frozen into the shell at build time, which is almost never what you want, so Next.js makes you choose. Either cache the value explicitly, or defer it to request time:
// app/components/request-time.tsx
import { connection } from "next/server";
export async function RequestTime() {
await connection();
return <p>Rendered at {new Date().toLocaleTimeString()}</p>;
}
Wrap <RequestTime /> in <Suspense> wherever you use it. connection() tells Next.js that everything after it belongs to the request, not the build.
Maximizing the static shell
The deeper your dynamic work sits in the tree, the more of the page is prerendered. Common mistakes that shrink the shell:
- Awaiting
paramsorsearchParamsat the top of a page or layout. - Reading
cookies()in the root layout to pick a theme. - Fetching user data in a layout and passing it down as props.
Push those reads into the smallest leaf component that needs them, and wrap that leaf in <Suspense>. A layout that reads a cookie for a "Sign in" link should delegate that to a small <AccountMenu /> inside a boundary rather than reading it itself.
Revalidating Cached Parts of a PPR Page
Cached segments in the shell are refreshed by time (through cacheLife) or on demand. After an admin updates a product, invalidate its tag from a Server Action:
// app/admin/products/actions.ts
"use server";
import { revalidateTag } from "next/cache";
import { db } from "@/lib/db";
export async function updateProduct(id: string, formData: FormData) {
await db.product.update({
where: { id },
data: {
name: String(formData.get("name")),
description: String(formData.get("description")),
},
});
revalidateTag(`product-${id}`, "max");
}
In Next.js 16, revalidateTag takes a cacheLife profile as its second argument, and "max" gives stale-while-revalidate behavior: the next visitor gets the existing content while a fresh version is generated in the background. For read-your-own-writes behavior inside a Server Action, Next.js 16 also provides updateTag. The full details are in how to revalidate data on demand in Next.js.
Verifying That PPR Is Working
Build output
Run npm run build and read the route table. Each route gets a symbol:
Route (app)
┌ ○ /about
├ ◐ /products/[id]
├ ◐ /products/abc123
└ ƒ /api/cart
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demand
With Cache Components, pages sit somewhere between ○ and ◐. A route shows ƒ only when there is nothing to prerender, such as a Route Handler that depends on the request. A product page that you expected to be mostly static but that has almost nothing in its shell usually means something near the top of the tree is reading request data.
The dev overlay
With cacheComponents enabled, next dev validates every route you visit. If a component would block the static shell, the overlay shows a blocking-route insight naming the component and offering fixes: cache it with use cache, wrap it in <Suspense>, or move the access deeper. These insights do not change the HTTP status in development, so keep an eye on the overlay and the terminal.
View source
Load the page with JavaScript disabled, or run curl against it:
npm run build && npm run start
curl -sN http://localhost:3000/products/abc123 | head -c 4000
You should see the full static content and the fallback markup in the first chunk of HTML, followed later in the stream by the dynamic content. Check against a production build, because next dev renders everything on demand.
Adopting incrementally
You do not have to fix every route at once. When you enable cacheComponents in an existing app, you can mark segments that are not ready with export const instant = false;. That allows the segment to block instead of failing validation, so the app keeps building while you migrate route by route. A codemod adds it everywhere for you:
npx @next/codemod@canary cache-components-instant-false ./src/app
Remove the export from one route at a time and fix its insights.
Deploying PPR
PPR needs a host that supports streaming HTTP responses. That covers:
next starton any Node.js server, including Docker and VPS deployments. The server reads the shell from its cache and streams dynamic content in one response.- Vercel, which caches the shell at the edge and resumes dynamic rendering at the origin.
- Other platforms through Next.js deployment adapters that implement the resume protocol.
If you put Nginx or another reverse proxy in front of next start, you must disable response buffering, otherwise the proxy waits for the whole response and the streaming benefit disappears. Nginx buffers by default. The simplest fix is to have Next.js send the X-Accel-Buffering: no header, which Nginx honors:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
async headers() {
return [
{
source: "/:path*{/}?",
headers: [{ key: "X-Accel-Buffering", value: "no" }],
},
];
},
};
export default nextConfig;
A full Nginx setup is covered in how to self-host a Next.js app on a VPS with Nginx and PM2.
Common Problems and Fixes
- "Route segment config dynamicParams is not compatible with nextConfig.cacheComponents." Delete the
dynamicParamsexport and callnotFound()for invalid params. - Build error about
export const revalidateordynamic. These segment configs are replaced under Cache Components. Useuse cachewithcacheLifeinstead ofrevalidate, and deletedynamic = "force-dynamic"because pages are dynamic by default. - "empty-generate-static-params" error.
generateStaticParamsreturned an empty array. Return at least one param. - The whole page renders dynamically. A layout or page is awaiting
params,searchParams, orcookies()at the top. Move the read into a child inside<Suspense>. - Build fails on
new Date()orMath.random(). Wrap the value in a component that callsawait connection()first, or cache it withuse cache. useSearchParamsbuild error. Client components that read search params must be inside a<Suspense>boundary.- Shell arrives but dynamic parts appear only when the response ends. A proxy or CDN is buffering. Disable buffering for the route.
Partial Prerendering FAQ
Yes. In Next.js 16, Partial Prerendering is the default rendering behavior when you enable cacheComponents in next.config. The experimental ppr flag and the experimental_ppr segment config from Next.js 15 canaries were removed.
No. Partial Prerendering relies on React Server Components and Suspense streaming in the App Router. Pages Router routes keep using getStaticProps, getServerSideProps, and ISR.
Yes. The static shell contains your main content as real HTML, and dynamic parts are streamed in the same response, so crawlers receive complete HTML. Keep content that matters for ranking in the static or cached part of the page rather than behind personalized boundaries.
No. Any platform that supports streaming HTTP responses works, including next start on a VPS or in Docker. Vercel and adapter-based platforms add CDN caching of the shell, but the origin-only setup is fully supported.
ISR caches the whole page and regenerates it in the background, so the page cannot read cookies or headers. PPR caches only a static shell and renders the request-dependent parts on every request, so one route can be both fast and personalized. Cached segments inside a PPR page can still be revalidated with cacheLife, revalidateTag, or revalidatePath.
There is no per-route PPR switch in Next.js 16. You control what is prerendered through use cache and Suspense boundaries, and you can set instant to false on a segment to allow it to block while you migrate.
Conclusion
Partial Prerendering ends the old trade-off between static speed and dynamic personalization. A Next.js 16 route with cacheComponents enabled is prerendered into a static shell wherever possible, and only the parts that genuinely depend on the request are rendered at request time and streamed into the same response.
To get the most out of it, treat <Suspense> boundaries as the line between static and dynamic, cache stable data with use cache and an explicit cacheLife, push reads of params, cookies(), and headers() down into the smallest components that need them, and watch the dev overlay for blocking-route insights. Deploy on a host that streams responses, and your pages get static-site speed without giving up personalized content.
Here are some useful references for going deeper on Partial Prerendering:
- Next.js Docs: Caching with Cache Components — how use cache, Suspense, and prerendering produce the static shell.
- Next.js Docs: cacheComponents — the config flag that enables PPR in Next.js 16.
- Next.js Docs: Migrating to Cache Components — replacing dynamic, revalidate, and experimental_ppr.
- Next.js Docs: PPR Platform Guide — how the shell, postponed state, and resume protocol work on different hosts.
- React Docs: Suspense — the React primitive that defines PPR boundaries.


