0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · Lesson

Partial Prerendering: Static Shell, Dynamic Holes

Combine a prerendered static shell with streamed dynamic regions using the PPR model.

Partial Prerendering: Static Shell, Dynamic Holes is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

What Is Partial Prerendering?

Partial Prerendering (PPR) is a rendering model introduced in Next.js 14+ and refined in Next.js 15 that lets a single route serve both static and dynamic content simultaneously.

With traditional rendering you had to pick one:

  • Static (SSG/SSR with full cache) — fast, but stale for personalized data
  • Dynamic (SSR on every request) — fresh, but slower Time-To-First-Byte

PPR breaks that trade-off. At build time Next.js pre-renders a static shell (layout, chrome, above-the-fold content) and punches out holes where dynamic data will stream in at request time — all from one route file.

Enabling PPR in Next.js 15

PPR is an opt-in feature. Enable it in next.config.ts with the experimental.ppr flag.

In Next.js 15 you can also enable it incrementally — per route — using the experimental_ppr route-segment config export instead of turning it on globally.

Once enabled, the framework analyses your route tree at build time, separating the static shell from any subtrees that contain dynamic APIs (cookies(), headers(), searchParams, noStore(), etc.).

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    ppr: true, // enable globally
    // OR use 'incremental' to opt in per-route
    // ppr: 'incremental',
  },
};

export default nextConfig;

Opting a Route Into PPR Incrementally

When you set ppr: 'incremental' in next.config.ts, individual route segments must explicitly opt in by exporting the experimental_ppr constant set to true.

This is ideal for migrating large apps gradually — only the pages you annotate get the PPR treatment, while the rest behave as before.

The export must live directly in the page or layout file of the segment you want to prerender partially.

// app/dashboard/page.tsx
// Opt this route into Partial Prerendering
export const experimental_ppr = true;

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      {/* static shell renders at build time */}
      {/* dynamic holes are defined below with Suspense */}
    </main>
  );
}

The Static Shell

The static shell is everything in your route that does not depend on dynamic APIs. Next.js pre-renders and caches this at build time (CDN-ready) so it is delivered instantly — before any database call fires.

Typical static shell content includes:

  • Page layout and navigation chrome
  • Hero sections with hardcoded or statically-fetched copy
  • Skeleton / placeholder UI for the dynamic holes
  • Server Components that only read from the filesystem or static fetch with full cache

The key insight: the static shell is served as a prerendered HTML stream while the dynamic holes continue loading in parallel.

// app/store/page.tsx
import { Suspense } from 'react';
import ProductListSkeleton from '@/components/ProductListSkeleton';
import DynamicCart from '@/components/DynamicCart';
import StaticHero from '@/components/StaticHero'; // no dynamic APIs

export const experimental_ppr = true;

export default function StorePage() {
  return (
    <div>
      {/* Static shell — prerendered at build time */}
      <StaticHero />
      <nav>Store Navigation</nav>

      {/* Dynamic hole — streams in at request time */}
      <Suspense fallback={<ProductListSkeleton />}>
        <DynamicCart />
      </Suspense>
    </div>
  );
}

Defining Dynamic Holes with Suspense

A dynamic hole is any subtree wrapped in a <Suspense> boundary that contains components using dynamic Next.js APIs.

Next.js uses the Suspense boundary as the boundary between static and dynamic. Everything inside the boundary is excluded from the prerendered shell and instead streamed as a separate chunk once data is ready.

Important rules:

  • The dynamic Server Component must be the async component doing the data fetch — not just a wrapper
  • You must provide a fallback to the Suspense boundary; this fallback is baked into the static shell so users see it immediately
  • Multiple independent Suspense boundaries create multiple independent holes that stream in parallel
// components/DynamicCart.tsx
import { cookies } from 'next/headers'; // marks this subtree as dynamic

async function DynamicCart() {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get('session_id')?.value;

  const cart = sessionId
    ? await fetchCart(sessionId) // DB call at request time
    : null;

  return (
    <aside>
      <h2>Your Cart</h2>
      {cart ? (
        <ul>
          {cart.items.map((item) => (
            <li key={item.id}>{item.name} — ${item.price}</li>
          ))}
        </ul>
      ) : (
        <p>Your cart is empty.</p>
      )}
    </aside>
  );
}

export default DynamicCart;

async function fetchCart(sessionId: string) {
  // simulated DB call
  return { items: [{ id: 1, name: 'Widget', price: 9.99 }] };
}

Multiple Independent Dynamic Holes

One of PPR's biggest advantages is that you can have multiple independent dynamic holes in a single route. Each Suspense boundary streams its content independently, so a slow database call in one region does not block another.

Think of a dashboard with:

  • A user greeting (reads cookies())
  • A live stats widget (reads from a slow analytics DB)
  • A recent activity feed (reads from a different table)

All three stream in parallel after the static shell is delivered, giving users a progressively enhanced experience rather than a single long wait.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import UserGreeting from '@/components/UserGreeting';
import LiveStats from '@/components/LiveStats';
import ActivityFeed from '@/components/ActivityFeed';
import {
  GreetingSkeleton,
  StatsSkeleton,
  FeedSkeleton,
} from '@/components/Skeletons';

export const experimental_ppr = true;

export default function DashboardPage() {
  return (
    <main className="dashboard-grid">
      <h1>Welcome to your Dashboard</h1> {/* static */}

      {/* Three independent holes — stream in parallel */}
      <Suspense fallback={<GreetingSkeleton />}>
        <UserGreeting />
      </Suspense>

      <Suspense fallback={<StatsSkeleton />}>
        <LiveStats />
      </Suspense>

      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </main>
  );
}

What Makes a Component Dynamic?

Next.js automatically detects dynamic usage by scanning for specific APIs. A Server Component is considered dynamic (excluded from the static shell) when it uses:

  • cookies() or headers() from next/headers
  • searchParams prop on a page
  • unstable_noStore() or fetch with cache: 'no-store'
  • Any API that opts out of the Data Cache at request time

If a dynamic API is used outside a Suspense boundary, the entire route falls back to full dynamic rendering — PPR is disabled for that route. Always wrap dynamic components in Suspense to preserve the static shell.

// components/UserGreeting.tsx
import { cookies, headers } from 'next/headers';

export async function UserGreeting() {
  // Both of these mark this component as dynamic
  const cookieStore = await cookies();
  const headersList = await headers();

  const userId = cookieStore.get('user_id')?.value;
  const userAgent = headersList.get('user-agent') ?? 'Unknown';

  const user = userId ? await fetchUser(userId) : null;

  return (
    <div>
      <p>Hello, {user?.name ?? 'Guest'}!</p>
      <small>Browsing via: {userAgent.split('/')[0]}</small>
    </div>
  );
}

async function fetchUser(id: string) {
  // imagine a real DB query here
  return { name: 'Mehmet' };
}

Skeleton Fallbacks Are Part of the Static Shell

The fallback prop of each Suspense boundary is included in the prerendered static shell. This means users receive the skeleton UI with zero delay — the same latency as a fully static page.

Design your skeletons to closely match the final content's dimensions and layout. This minimises Cumulative Layout Shift (CLS) when the dynamic content streams in and replaces the skeleton.

A good skeleton strategy:

  • Match width and height of the real content as closely as possible
  • Use CSS animations (pulse/shimmer) to communicate loading state
  • Avoid placeholders that look like real data — users may read them as content
// components/Skeletons.tsx
export function StatsSkeleton() {
  return (
    <div className="animate-pulse space-y-2 p-4 rounded-lg bg-gray-100">
      <div className="h-4 bg-gray-300 rounded w-1/3" />
      <div className="h-8 bg-gray-300 rounded w-1/2" />
      <div className="h-4 bg-gray-300 rounded w-2/3" />
    </div>
  );
}

export function FeedSkeleton() {
  return (
    <ul className="animate-pulse space-y-3">
      {Array.from({ length: 5 }).map((_, i) => (
        <li key={i} className="flex gap-3 items-center">
          <div className="h-8 w-8 rounded-full bg-gray-300" />
          <div className="flex-1 h-4 bg-gray-300 rounded" />
        </li>
      ))}
    </ul>
  );
}

export function GreetingSkeleton() {
  return <div className="animate-pulse h-6 bg-gray-300 rounded w-40" />;
}

PPR vs Traditional Streaming SSR

It is easy to confuse PPR with regular streaming SSR (using Suspense without PPR). Here is the key difference:

  • Streaming SSR (no PPR) — The entire response is rendered on the server at request time. The static parts render fast, dynamic parts stream later. But the initial HTML is never cached at the CDN edge — every request hits your server.
  • PPR — The static shell is pre-rendered at build time and served from the CDN edge instantly. Only the dynamic holes trigger server execution at request time. You get CDN-speed static delivery and fresh dynamic data.

PPR is effectively: CDN static delivery + request-time streaming combined in one route.

Reading searchParams Without Breaking PPR

In Next.js 15, searchParams on a Page component is a dynamic value — reading it opts the entire page out of static rendering. With PPR, you must not read searchParams directly in the page component (outside a Suspense boundary).

Instead, pass searchParams as a prop into a component that is wrapped in Suspense. This keeps the outer page shell static while allowing the inner component to read request-time query params.

// app/search/page.tsx
import { Suspense } from 'react';
import SearchResults from '@/components/SearchResults';
import SearchResultsSkeleton from '@/components/SearchResultsSkeleton';

export const experimental_ppr = true;

type SearchPageProps = {
  searchParams: Promise<{ q?: string; page?: string }>;
};

export default function SearchPage({ searchParams }: SearchPageProps) {
  // Do NOT await searchParams here — that would make the shell dynamic.
  // Pass the Promise into the dynamic hole instead.
  return (
    <div>
      <h1>Search</h1> {/* static shell */}

      <Suspense fallback={<SearchResultsSkeleton />}>
        {/* SearchResults awaits searchParams inside the Suspense boundary */}
        <SearchResults searchParamsPromise={searchParams} />
      </Suspense>
    </div>
  );
}

Verifying PPR Behaviour in Development

During next dev, PPR is simulated but pages are not truly pre-built — every request re-renders. To observe actual PPR behaviour you need to run a production build:

  • next build — generates the static shell at build time
  • next start — serves the production output

After a build, check the output table printed to the terminal. Routes with PPR are marked with a symbol (half-filled circle) indicating a partial prerender — distinct from fully static (○) and fully dynamic (λ) routes.

You can also inspect the .next/server/app directory for the pre-rendered HTML file and the separate RSC payload for the dynamic holes.

Knowledge Check: PPR Boundary Rules

A developer enables PPR on a route and wraps their dynamic component in a Suspense boundary. Which of the following statements correctly describes what happens if the developer calls cookies() directly inside the Page component (outside any Suspense boundary)?

Recap: Partial Prerendering in Practice

In this lesson you explored Partial Prerendering (PPR) — the Next.js 15 model that merges CDN-speed static delivery with request-time dynamic streaming.

Key takeaways:

  • Enable PPR globally via experimental.ppr: true in next.config.ts, or per-route with export const experimental_ppr = true
  • The static shell (everything outside Suspense boundaries) is pre-rendered at build time and served instantly from the CDN
  • Each Suspense boundary defines a dynamic hole — its fallback is baked into the shell, its content streams in at request time
  • Dynamic APIs (cookies(), headers(), searchParams, no-store fetches) must live inside Suspense boundaries to preserve the shell
  • Multiple independent Suspense boundaries stream in parallel, maximising perceived performance
  • Build with next build and look for the symbol to confirm PPR is active on a route

PPR is the bridge between the all-or-nothing static vs dynamic choice — use it to deliver fast shells and fresh data from the same route file.

Frequently asked questions

Is the “Partial Prerendering: Static Shell, Dynamic Holes” lesson free?

Yes — the full text of “Partial Prerendering: Static Shell, Dynamic Holes” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.

What will I learn in “Partial Prerendering: Static Shell, Dynamic Holes”?

Combine a prerendered static shell with streamed dynamic regions using the PPR model. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?

No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Partial Prerendering: Static Shell, Dynamic Holes” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?

Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Suspense Boundaries and Component-Level Streaming
  2. Crafting Meaningful loading.tsx and Skeletons
  3. Partial Prerendering: Static Shell, Dynamic Holes
  4. Streaming Pitfalls: Layout Shift and Waterfalls
← Back to Next.js 15 Fullstack (App Router + Server Actions)