Skip to main content
Ganesh Joshi
Back to Blogs

Partial Prerendering in Next.js: static shell, dynamic holes

February 18, 20265 min read
Tips
Rendering or build output on screen

Partial Prerendering (PPR) is a rendering strategy in Next.js that combines static and dynamic content in a single request. The page's static shell loads instantly from the CDN, while dynamic "holes" stream in as they resolve. This gives users immediate content without sacrificing personalization.

The problem PPR solves

Traditional rendering approaches force a choice:

Approach Trade-off
Static (SSG) Fast but no dynamic content
Server (SSR) Dynamic but slower TTFB
ISR Periodic updates, entire page regenerates
Client-side Fast shell but layout shift, SEO concerns

Many pages are mostly static with small dynamic sections: a marketing page with a personalized header, a product page with dynamic pricing, a dashboard with a static sidebar and live data.

PPR lets you serve the static parts instantly while streaming the dynamic parts.

How PPR works

When you build with PPR enabled:

  1. Next.js identifies static content (outside Suspense)
  2. Static shell is prerendered at build time
  3. Suspense boundaries mark dynamic holes
  4. At request time, static shell serves immediately
  5. Dynamic content streams as it resolves
Build time:
┌─────────────────────────────┐
│ Static Header               │  ← Prerendered
├─────────────────────────────┤
│ [Dynamic User Widget]       │  ← Hole (placeholder)
├─────────────────────────────┤
│ Static Product Info         │  ← Prerendered
├─────────────────────────────┤
│ [Dynamic Recommendations]   │  ← Hole (placeholder)
├─────────────────────────────┤
│ Static Footer               │  ← Prerendered
└─────────────────────────────┘

Request time:
1. Static shell sent immediately
2. Dynamic widgets stream as they resolve

Enabling PPR

Configure in next.config.js:

// next.config.js
module.exports = {
  experimental: {
    ppr: true,
  },
};

PPR is experimental. Check the Next.js changelog for current status and known issues.

Creating dynamic holes with Suspense

Wrap dynamic content in Suspense boundaries:

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
import { UserWidget } from './UserWidget';
import { ActivityFeed } from './ActivityFeed';
import { Skeleton } from '@/components/ui/Skeleton';

export default function DashboardPage() {
  return (
    <div className="flex">
      {/* Static: prerendered */}
      <Sidebar />

      <main className="flex-1">
        {/* Static: prerendered */}
        <Header />

        {/* Dynamic: streams */}
        <Suspense fallback={<Skeleton className="h-32" />}>
          <UserWidget />
        </Suspense>

        {/* Static: prerendered */}
        <h2>Recent Activity</h2>

        {/* Dynamic: streams */}
        <Suspense fallback={<Skeleton className="h-64" />}>
          <ActivityFeed />
        </Suspense>
      </main>
    </div>
  );
}

The sidebar, header, and section titles render instantly. User widget and activity feed stream in.

What makes content dynamic

Content becomes dynamic when it:

Trigger Example
Reads cookies cookies() from next/headers
Reads headers headers() from next/headers
Uses searchParams Accessing URL query parameters
Uses noStore() Opting out of caching
Dynamic data fetching fetch without cache config
// This component is dynamic
async function UserWidget() {
  const session = await getSession(); // reads cookies
  const user = await getUser(session.userId);

  return <div>Welcome, {user.name}</div>;
}

Fallback UI design

The fallback shows while dynamic content loads. Design it to:

  • Match the final layout dimensions (prevents CLS)
  • Provide visual feedback (skeletons, spinners)
  • Feel fast (animation, not static placeholder)
function ActivityFeedSkeleton() {
  return (
    <div className="space-y-4">
      {[1, 2, 3].map((i) => (
        <div key={i} className="animate-pulse">
          <div className="h-4 bg-gray-200 rounded w-3/4" />
          <div className="h-3 bg-gray-200 rounded w-1/2 mt-2" />
        </div>
      ))}
    </div>
  );
}

<Suspense fallback={<ActivityFeedSkeleton />}>
  <ActivityFeed />
</Suspense>

Nested Suspense boundaries

You can nest Suspense for granular streaming:

<Suspense fallback={<DashboardSkeleton />}>
  <Dashboard>
    <Suspense fallback={<WidgetSkeleton />}>
      <Widget1 />
    </Suspense>
    <Suspense fallback={<WidgetSkeleton />}>
      <Widget2 />
    </Suspense>
  </Dashboard>
</Suspense>

Each widget streams independently. Fast widgets appear first.

Combining with loading.tsx

loading.tsx creates an implicit Suspense boundary for the page:

// app/dashboard/loading.tsx
export default function Loading() {
  return <DashboardSkeleton />;
}

With PPR, loading.tsx becomes the fallback for the entire page's dynamic content. For more granular control, use explicit Suspense boundaries.

Error handling

Add error.tsx alongside dynamic content:

// app/dashboard/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="p-4 bg-red-50 rounded">
      <h2>Failed to load dashboard</h2>
      <button onClick={reset}>Retry</button>
    </div>
  );
}

Errors in dynamic holes don't break the static shell.

Use cases for PPR

Page type Static Dynamic
Marketing + personalization Hero, features, footer User greeting, recommendations
E-commerce Product description, images Price, inventory, reviews
Dashboard Navigation, layout Widgets, notifications, user data
Blog Content, layout View count, related posts, comments
News Article content Trending sidebar, personalized ads

Performance benefits

Metric Without PPR With PPR
TTFB Waits for all data Instant static shell
FCP Delayed by dynamic content Immediate from cache
LCP Depends on slowest query Static LCP elements load fast
CLS Risk if content sizes unknown Skeletons reserve space

PPR optimizes for perceived performance by showing content progressively.

Limitations and considerations

Limitation Workaround
Experimental API Monitor Next.js releases for changes
Complex data dependencies Split into independent Suspense boundaries
SEO for dynamic content Search engines see streamed content
Testing Test both static shell and streaming

Comparing rendering strategies

Strategy When to use
Static (SSG) Fully static content, no user data
ISR Content updates periodically, entire page
SSR Fully dynamic, can't separate static/dynamic
PPR Mixed static/dynamic, want instant shell
Client-side Data depends on client state (auth, etc.)

Debugging PPR

Check build output to see what's static vs dynamic:

Route (app)                              Size     First Load JS
┌ ○ /                                   5.2 kB         92 kB
├ ◐ /dashboard                          8.1 kB         95 kB
└ ○ /about                              3.1 kB         90 kB

○  (Static)   prerendered as static content
◐  (Partial)  prerendered with dynamic holes

The symbol indicates PPR pages.

Summary

Partial Prerendering serves a static shell instantly while streaming dynamic content. Enable it with experimental.ppr, wrap dynamic sections in Suspense, and design fallbacks that match final layout. PPR shines for pages mixing static marketing content with personalized data. It improves perceived performance without sacrificing dynamic features. Check Next.js docs for current experimental status and best practices.

Frequently Asked Questions

Partial Prerendering (PPR) renders pages with a static shell at build time and dynamic 'holes' that stream at request time. Users see the static content instantly while dynamic content loads.

ISR regenerates entire pages periodically. PPR serves a prerendered static shell immediately and streams only the dynamic parts. PPR gives faster first paint for mixed static/dynamic pages.

Set experimental.ppr to true in next.config.js. Wrap dynamic content in Suspense boundaries. Static content outside Suspense is prerendered; content inside streams at request time.

Put user-specific content, real-time data, and personalized sections in Suspense. Keep navigation, headers, static marketing content, and layout outside Suspense for instant loading.

PPR is experimental as of Next.js 15. It works for many use cases but may have edge cases. Test thoroughly before using in production. Check Next.js docs for current status.

Related Posts