React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

pprpartial-prerenderingstatic-shelldynamic-holessuspense

Partial Prerendering (PPR)

Serve a static shell instantly and stream dynamic content into Suspense holes -- the best of static and dynamic rendering.

Recipe

Quick-reference recipe card -- copy-paste ready.

// next.config.ts -- enable PPR
import type { NextConfig } from "next";
 
const config: NextConfig = {
  experimental: {
    ppr: true, // or "incremental" for per-route opt-in
  },
};
 
export default config;
// app/page.tsx -- static shell with dynamic holes
import { Suspense } from "react";
import { cookies } from "next/headers";
 
export default function Page() {
  return (
    <main>
      <h1>Welcome to the Store</h1>  {/* Static shell */}
      <StaticHero />                  {/* Static shell */}
 
      <Suspense fallback={<CartSkeleton />}>
        <DynamicCart />               {/* Dynamic hole -- streamed */}
      </Suspense>
    </main>
  );
}
 
async function DynamicCart() {
  const cookieStore = await cookies();
  const cartId = cookieStore.get("cart-id")?.value;
  const cart = await fetchCart(cartId);
  return <CartWidget items={cart.items} />;
}

When to reach for this: You have a page where most content is static (product info, marketing copy) but a small part depends on the request (user session, cart, personalization).

Working Example

// next.config.ts
import type { NextConfig } from "next";
 
const config: NextConfig = {
  experimental: {
    ppr: "incremental", // opt in per route
  },
};
 
export default config;
// app/products/[slug]/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { db } from "@/lib/db";
import { AddToCartButton } from "./add-to-cart";
 
// Opt this route into PPR
export const experimental_ppr = true;
 
// Pre-generate product pages at build time
export async function generateStaticParams() {
  const products = await db.product.findMany({ select: { slug: true } });
  return products.map((p) => ({ slug: p.slug }));
}
 
type Props = { params: Promise<{ slug: string }> };
 
export default async function ProductPage({ params }: Props) {
  const { slug } = await params;
  const product = await db.product.findUnique({ where: { slug } });
 
  if (!product) return <p>Product not found</p>;
 
  return (
    <main className="max-w-4xl mx-auto p-6">
      {/* ---- Static Shell (prerendered at build time) ---- */}
      <div className="grid grid-cols-2 gap-8">
        <img
          src={product.imageUrl}
          alt={product.name}
          className="w-full rounded"
        />
        <div>
          <h1 className="text-3xl font-bold">{product.name}</h1>
          <p className="text-2xl text-gray-700 mt-2">
            ${product.price.toFixed(2)}
          </p>
          <p className="mt-4 text-gray-600">{product.description}</p>
          <AddToCartButton productId={product.id} />
        </div>
      </div>
 
      {/* ---- Dynamic Holes (streamed at request time) ---- */}
      <section className="mt-12">
        <h2 className="text-xl font-bold mb-4">Personalized For You</h2>
        <Suspense fallback={<RecommendationsSkeleton />}>
          <PersonalizedRecommendations />
        </Suspense>
      </section>
 
      <section className="mt-8">
        <h2 className="text-xl font-bold mb-4">Your Recent Views</h2>
        <Suspense fallback={<RecentViewsSkeleton />}>
          <RecentlyViewed />
        </Suspense>
      </section>
    </main>
  );
}
 
// ---- Dynamic Components (use request-time data) ----
 
async function PersonalizedRecommendations() {
  const cookieStore = await cookies();
  const userId = cookieStore.get("user-id")?.value;
 
  if (!userId) return <p>Sign in for personalized recommendations</p>;
 
  const recs = await fetch(
    `https://api.example.com/recommendations?user=${userId}`,
    { cache: "no-store" }
  );
  const products = await recs.json();
 
  return (
    <div className="grid grid-cols-4 gap-4">
      {products.map((p: { id: string; name: string; price: number }) => (
        <div key={p.id} className="border rounded p-3">
          <p className="font-medium">{p.name}</p>
          <p className="text-gray-500">${p.price.toFixed(2)}</p>
        </div>
      ))}
    </div>
  );
}
 
async function RecentlyViewed() {
  const cookieStore = await cookies();
  const history = cookieStore.get("view-history")?.value;
 
  if (!history) return <p>No recent views</p>;
 
  const ids = JSON.parse(history) as string[];
  const products = await db.product.findMany({
    where: { id: { in: ids } },
  });
 
  return (
    <div className="flex gap-4 overflow-x-auto">
      {products.map((p) => (
        <div key={p.id} className="min-w-[150px] border rounded p-3">
          <p className="font-medium">{p.name}</p>
        </div>
      ))}
    </div>
  );
}
 
// ---- Skeletons ----
 
function RecommendationsSkeleton() {
  return (
    <div className="grid grid-cols-4 gap-4">
      {Array.from({ length: 4 }).map((_, i) => (
        <div key={i} className="h-24 bg-gray-100 rounded animate-pulse" />
      ))}
    </div>
  );
}
 
function RecentViewsSkeleton() {
  return (
    <div className="flex gap-4">
      {Array.from({ length: 3 }).map((_, i) => (
        <div key={i} className="w-[150px] h-20 bg-gray-100 rounded animate-pulse" />
      ))}
    </div>
  );
}

What this demonstrates:

  • Enabling PPR incrementally per route with experimental_ppr = true
  • The product info (name, price, description, image) is the static shell -- prerendered at build time and served from the CDN
  • Personalized recommendations and recently viewed products are dynamic holes -- they use cookies() and stream in at request time
  • Skeleton fallbacks appear instantly as part of the static shell
  • generateStaticParams pre-renders product pages at build time

Deep Dive

How It Works

  • With PPR, Next.js prerenders the static parts of a page at build time, including Suspense fallbacks. The result is a complete HTML shell stored on the CDN.
  • When a request arrives, the static shell is sent immediately (edge latency). The server then resumes rendering the dynamic portions (components that call cookies(), headers(), searchParams, or cache: "no-store").
  • Dynamic content is streamed into the Suspense boundaries, replacing the fallback skeletons as each boundary resolves.
  • The key insight: Suspense boundaries define the static/dynamic split. Everything outside a Suspense boundary with a dynamic child is part of the static shell. Everything inside is a dynamic hole.
  • PPR combines the performance of static generation (instant TTFB from CDN) with the flexibility of dynamic rendering (personalized, request-time content).

Variations

Incremental adoption (per-route opt-in):

// next.config.ts
export default { experimental: { ppr: "incremental" } };
 
// Only this page uses PPR
// app/products/[slug]/page.tsx
export const experimental_ppr = true;

All routes PPR (global):

// next.config.ts
export default { experimental: { ppr: true } };
// All routes automatically use PPR

Multiple dynamic holes with independent data sources:

export default function Page() {
  return (
    <main>
      <StaticNav />
      <StaticHero />
 
      {/* Each Suspense boundary is an independent dynamic hole */}
      <Suspense fallback={<UserBarSkeleton />}>
        <UserBar />       {/* cookies() */}
      </Suspense>
 
      <Suspense fallback={<PricingSkeleton />}>
        <LivePricing />   {/* cache: "no-store" */}
      </Suspense>
 
      <Suspense fallback={<WeatherSkeleton />}>
        <LocalWeather />  {/* headers() for geo */}
      </Suspense>
 
      <StaticFooter />
    </main>
  );
}

TypeScript Notes

// Per-route PPR opt-in export
export const experimental_ppr: boolean = true;
 
// next.config.ts type
import type { NextConfig } from "next";
const config: NextConfig = {
  experimental: {
    ppr: true | "incremental",
  },
};

Gotchas

  • PPR is experimental -- As of Next.js 15/16, PPR requires the experimental.ppr flag. The API may change. Fix: Pin your Next.js version and test thoroughly before production use.

  • Suspense boundary placement determines the split -- If you forget to wrap a dynamic component in Suspense, the entire page becomes dynamic. Fix: Always wrap components that use dynamic functions in a <Suspense> boundary.

  • Fallback quality matters -- The Suspense fallback is part of the static shell and is visible to all users instantly. A poor fallback creates a bad first impression. Fix: Use well-designed skeletons that match the shape and size of the real content.

  • Dynamic holes add server cost -- Each dynamic hole requires server computation at request time. Too many dynamic holes negate the CDN benefits. Fix: Consolidate related dynamic data into fewer Suspense boundaries.

  • No PPR on Edge Runtime for all providers -- PPR requires the hosting platform to support streaming and the postpone API. Fix: Verify your deployment platform supports PPR (Vercel supports it natively).

  • Client Components in the static shell still ship JavaScript -- PPR makes the HTML static, but Client Components still hydrate on the client. Fix: Keep the static shell composed primarily of Server Components for minimal JavaScript.

Alternatives

ApproachUse WhenDon't Use When
PPRMost of the page is static but some parts need request-time dataThe entire page is either fully static or fully dynamic
Full Static GenerationThe entire page can be built at deploy timeAny part needs user-specific data
Full Dynamic RenderingThe entire page depends on request-time dataMost content is the same for all users
Client-side fetchingDynamic data should load after hydration, not block SSRYou want the dynamic data in the initial HTML
ISRContent changes periodically but not per-requestContent is personalized per user
Streaming SSR (without PPR)You want progressive rendering without a static shellYou want the shell served from the CDN edge

FAQs

What is Partial Prerendering (PPR)?

PPR combines static generation and dynamic rendering in a single route. The static parts are prerendered at build time and served from the CDN. Dynamic parts are streamed into Suspense boundaries at request time.

How do I enable PPR in my Next.js project?

Add experimental: { ppr: true } (global) or experimental: { ppr: "incremental" } (per-route opt-in) to next.config.ts. For incremental mode, also export experimental_ppr = true in the page file.

What determines the boundary between the static shell and a dynamic hole?

Suspense boundaries define the split. Everything outside a Suspense boundary is part of the static shell. Components inside a Suspense boundary that call dynamic functions (cookies(), headers(), cache: "no-store") become dynamic holes.

What happens to the Suspense fallback in PPR?

The fallback is part of the static shell -- it is prerendered and served instantly from the CDN. Users see the skeleton immediately while the dynamic content streams in.

Gotcha: I forgot to wrap a dynamic component in Suspense. What happens?

The entire page becomes dynamic. Without a Suspense boundary, PPR cannot identify the static/dynamic split, so the whole route falls back to full dynamic rendering.

Gotcha: Do Client Components in the static shell still ship JavaScript?

Yes. PPR makes the HTML static, but Client Components still hydrate on the client and ship their JavaScript. Keep the static shell composed primarily of Server Components for minimal JS.

Can I have multiple independent dynamic holes on one page?

Yes. Each Suspense boundary is an independent dynamic hole with its own data source:

<Suspense fallback={<UserBarSkeleton />}>
  <UserBar />       {/* cookies() */}
</Suspense>
<Suspense fallback={<PricingSkeleton />}>
  <LivePricing />   {/* cache: "no-store" */}
</Suspense>
How do I opt a single route into PPR with incremental mode?
// next.config.ts
export default { experimental: { ppr: "incremental" } };
 
// app/products/[slug]/page.tsx
export const experimental_ppr = true;
What is the TypeScript type for the experimental_ppr export?
export const experimental_ppr: boolean = true;

In next.config.ts, the PPR option is typed as true | "incremental" inside the experimental object of NextConfig.

Does PPR work with generateStaticParams?

Yes. generateStaticParams pre-renders product pages at build time as the static shell. Dynamic holes inside those pages stream in at request time.

What hosting requirements does PPR have?

PPR requires a hosting platform that supports streaming and the postpone API. Vercel supports it natively. Verify your deployment platform before enabling PPR in production.

Why should I limit the number of dynamic holes?

Each dynamic hole requires server computation at request time. Too many dynamic holes negate the CDN performance benefits of the static shell. Consolidate related dynamic data into fewer Suspense boundaries when possible.