Type something to search...
What Is Partial Prerendering (PPR) in Next.js?

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:

  1. The server sends the static shell immediately. It can come straight from a CDN.
  2. The server resumes rendering only the dynamic parts that were postponed at build time.
  3. 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 modelWhen HTML is producedCan read cookies/headersFirst byte speed
Static (SSG)Build timeNoFastest
Incremental (ISR)Build time, regenerated in backgroundNoFastest
Dynamic (SSR)Every requestYesSlowest
Partial Prerendering (PPR)Shell at build time, holes per requestYes, inside SuspenseFast, 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 doesWhere it goes
Plain JSX, imports, pure computationStatic shell
use cache with a long enough cacheLifeStatic shell
Reads cookies(), headers(), searchParams, unknown paramsFallback in shell, content streams at request time
Uncached fetch or database queryFallback 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 params or searchParams at 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 start on 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 dynamicParams export and call notFound() for invalid params.
  • Build error about export const revalidate or dynamic. These segment configs are replaced under Cache Components. Use use cache with cacheLife instead of revalidate, and delete dynamic = "force-dynamic" because pages are dynamic by default.
  • "empty-generate-static-params" error. generateStaticParams returned an empty array. Return at least one param.
  • The whole page renders dynamically. A layout or page is awaiting params, searchParams, or cookies() at the top. Move the read into a child inside <Suspense>.
  • Build fails on new Date() or Math.random(). Wrap the value in a component that calls await connection() first, or cache it with use cache.
  • useSearchParams build 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:

  1. Next.js Docs: Caching with Cache Components — how use cache, Suspense, and prerendering produce the static shell.
  2. Next.js Docs: cacheComponents — the config flag that enables PPR in Next.js 16.
  3. Next.js Docs: Migrating to Cache Components — replacing dynamic, revalidate, and experimental_ppr.
  4. Next.js Docs: PPR Platform Guide — how the shell, postponed state, and resume protocol work on different hosts.
  5. React Docs: Suspense — the React primitive that defines PPR boundaries.
Tags :
Share :

Related Posts

Building Powerful Desktop Applications with Next.js

Building Powerful Desktop Applications with Next.js

Next.js is a popular React framework known for its capabilities in building server-side rendered (S

Continue Reading
Can use custom server logic with Next.js?

Can use custom server logic with Next.js?

Next.js, a popular React framework for building web applications, has gained widespread adoption for its simplicity, performance, and developer-frien

Continue Reading
TypeScript with Next.js?

TypeScript with Next.js?

Next.js has emerged as a popular React framework for building robust web applications, offering developers a powerful set of features to enhance thei

Continue Reading