Partial Prerendering (PPR)

Serve a static shell instantly and stream the dynamic holes into it — one route that is both static and dynamic at once.

For years the choice was binary: a page was static (fast, but can't personalize) or dynamic (personalized, but pays render cost per request). Partial Prerendering collapses that choice. A single route ships a static shell immediately from the edge, with dynamic holes streamed in as they resolve.

How it works

At build time, Next prerenders everything it can into a static HTML shell. Anything wrapped in a <Suspense> boundary that reads request-time data becomes a hole: its fallback is baked into the shell, and the real content streams in at request time. The user gets an instant, cacheable first paint, then the personalized bits fill in — no route-level static-vs-dynamic decision required.

The rule that makes it click

The Suspense boundary is the boundary between static and dynamic. Everything outside your Suspense boundaries is prerendered; everything inside a boundary that touches cookies(), headers(), or an uncached fetch is the dynamic hole. So PPR design is really Suspense-placement design: wrap the personalized widget, leave the shell static.

Enabling it

PPR is incremental. Turn it on in next.config with experimental.ppr, then opt a route in with export const experimental_ppr = true. Roll it out route by route rather than flipping the whole app.

Example

Example · typescript
// app/product/[id]/page.tsx
export const experimental_ppr = true;

import { Suspense } from 'react';

export default async function ProductPage({ params }: any) {
  const { id } = await params;
  const product = await getProduct(id); // cached -> part of static shell

  return (
    <main>
      {/* Static shell: prerendered, cached, instant */}
      <h1>{product.name}</h1>
      <p>{product.description}</p>

      {/* Dynamic hole: personalized, streamed in per request */}
      <Suspense fallback={<PriceSkeleton />}>
        <PersonalizedPrice productId={id} />
      </Suspense>
      <Suspense fallback={<CartSkeleton />}>
        <CartStatus /> {/* reads cookies() -> dynamic */}
      </Suspense>
    </main>
  );
}

When to use it

  • A product page pre-renders the product name, price, and add-to-cart form statically while streaming personalised stock and recommendations.
  • A news site serves its entire page layout from the CDN edge in under 50 ms while live headlines fill in via Suspense.
  • An authenticated dashboard has a static chrome pre-rendered and a dynamic @user slot streamed after the session resolves.

More examples

Enable PPR and use it in a page

experimental_ppr opts a specific route into PPR; everything outside Suspense is pre-rendered and cached.

Example · ts
// next.config.ts
export default { experimental: { ppr: true } };

// app/product/[id]/page.tsx
import { Suspense } from 'react';
import StockLevel from './StockLevel'; // dynamic (reads cookies)

export const experimental_ppr = true;

export default function ProductPage() {
  return (
    <>
      <h1>Product Name</h1>         {/* static */}
      <Suspense fallback={<p>Checking stock...</p>}>
        <StockLevel />               {/* dynamic, streamed */}
      </Suspense>
    </>
  );
}

Dynamic slot reading user session

Reading cookies() makes this component dynamic; PPR streams it into the pre-rendered shell after the session resolves.

Example · ts
// app/product/[id]/StockLevel.tsx
import { cookies } from 'next/headers';

export default async function StockLevel() {
  const cookieStore = await cookies();
  const region = cookieStore.get('region')?.value ?? 'us';
  const stock = await fetchStock(region);
  return <p>In stock ({region}): {stock.count}</p>;
}

PPR with parallel dynamic slots

Multiple dynamic Suspense boundaries in a PPR page stream independently, each resolving as fast as its data allows.

Example · ts
import { Suspense } from 'react';
import RecentOrders from './RecentOrders';
import PersonalisedBanner from './PersonalisedBanner';

export const experimental_ppr = true;

export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<p>Loading orders...</p>}>
        <RecentOrders />
      </Suspense>
      <Suspense fallback={<div className="skeleton h-16" />}>
        <PersonalisedBanner />
      </Suspense>
    </div>
  );
}

Discussion

  • Be the first to comment on this lesson.