EdgeCases Logo
Mar 2026
Next.js
Expert
8 min read

Partial Prerendering: Streaming + Static Shell

Static shell plus dynamic streaming slots—how Partial Prerendering in Next.js 16 delivers instant initial loads with fresh data.

nextjs
partial-prerendering
ppr
streaming
static
dynamic
performance

Partial Prerendering (PPR) is Next.js 16's default rendering model: static shell for instant initial HTML, with dynamic slots that stream in at request time. Understand how shell and slots work together, and you get the best of static and SSR without the complexity.

The PPR Rendering Model

At build time, Next.js renders your route's component tree. Each component falls into one of three categories:

  • Static: Deterministic operations (pure functions, module imports, cached data)
  • Cached: Marked with "use cache", included in shell until revalidation
  • Dynamic: Uncached fetches, runtime APIs (cookies(), headers()), wrapped in <Suspense>

The static and cached content becomes the initial HTML shell. Dynamic components are replaced with their <Suspense> fallbacks, then stream in at request time. Browser receives instant shell, then progressive updates.

// app/product/[id]/page.tsx
import { Suspense } from 'react';
import { cacheLife, cacheTag } from 'next/cache';

export default async function ProductPage({ params }: { params: { id: string } }) {
  // Static: Deterministic (runs at build time)
  const staticNav = <Header />;

  // Cached: Included in shell, revalidated on mutation
  const product = await getCachedProduct(params.id); // Has "use cache"

  return (
    <div>
      {/* Shell: Sent immediately */}
      {staticNav}
      <h1>{product.name}</h1>

      {/* Dynamic: Streams in at request time */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={params.id} />
      </Suspense>

      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedProducts categoryId={product.categoryId} />
      </Suspense>
    </div>
  );
}

What Goes Into The Shell

Static and cached content automatically becomes part of the shell. No explicit configuration required:

// ✓ Static - automatic (no async, no fetch)
export function StaticComponent() {
  return <div>Pure markup, no data fetching</div>;
}

// ✓ Cached - shell inclusion (has "use cache")
async function CachedData() {
  'use cache';
  cacheLife('hours');
  return <div>{await fetchData()}</div>;
}

// ✗ Dynamic - streaming (uncached fetch or runtime API)
async function DynamicData() {
  const data = await fetch('/api/realtime'); // No cache directive
  return <div>{data}</div>;
}

Components with uncached fetches or runtime API access must be wrapped in <Suspense>. Next.js throws an error during build if you don't—blocking route generation is the goal.

The <Suspense> Fallback Pattern

Fallback UI is the key to good PPR. It's what users see while dynamic content loads:

// Good fallback: Meaningful content
function ProductReviewsSkeleton() {
  return (
    <div>
      <h2>Customer Reviews</h2>
      <div aria-busy="true">Loading reviews...</div>
    </div>
  );
}

// Better fallback: Skeleton that matches final layout
function ProductReviewsSkeleton() {
  return (
    <div>
      <h2>Customer Reviews</h2>
      <div className="space-y-4">
        {[1, 2, 3].map((i) => (
          <div key={i} className="animate-pulse">
            <div className="h-4 w-3/4 bg-gray-200 rounded" />
            <div className="h-4 w-1/2 bg-gray-200 rounded" />
          </div>
        ))}
      </div>
    </div>
  );
}

Skeletons that mirror final layout reduce layout shift and make loading feel faster. Users perceive progress when structure matches expectation.

Runtime APIs: cookies, headers, searchParams

Request-time data requires special handling in PPR:

// ❌ Wrong: Blocks entire route
export default async function Page() {
  const theme = (await cookies()).get('theme')?.value;
  return <div>Theme: {theme}</div>;
}

// ✅ Right: Wrap in Suspense
export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ThemeSelector />
    </Suspense>
  );
}

async function ThemeSelector() {
  const theme = (await cookies()).get('theme')?.value;
  return <div>Theme: {theme}</div>;
}

Components accessing cookies(), headers(), searchParams, or uncached fetch must be wrapped in <Suspense>. The fallback goes into shell; the component streams in once runtime data is available.

Extracting Runtime Values for Cached Components

You can pass runtime data as props to cached components. The values become cache keys:

// Extract runtime value, pass to cached component
export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <PersonalizedContent />
    </Suspense>
  );
}

async function PersonalizedContent() {
  // Runtime: Must wrap in Suspense
  const userId = (await cookies()).get('userId')?.value;
  return <CachedDashboard userId={userId} />;
}

async function CachedDashboard({ userId }: { userId: string }) {
  // Cached: Different userId = different cache entry
  'use cache';
  cacheLife('minutes');
  const data = await getUserData(userId);
  return <div>{data}</div>;
}

userId becomes part of the cache key. Each unique user gets their own cached entry. Same userId across requests returns cached result—efficient per-user caching without manual cache management.

loading.js: Route-Level Suspense

loading.tsx creates an implicit <Suspense> around the route:

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

// app/dashboard/page.tsx
export default async function DashboardPage() {
  const user = await getUser(); // Slow uncached fetch
  return <Dashboard user={user} />;
}

The layout renders immediately (static shell), loading.tsx fallback shows for page content, then page streams in. This is great for route-level loading states where you don't want to break down components into granular <Suspense> boundaries.

Warning: loading.js doesn't cover the layout. If your layout accesses uncached data, it still blocks navigation. Move that data into the page or wrap in its own <Suspense>.

Opting Out of The Static Shell

Sometimes you want full SSR with no static shell. Place empty <Suspense> above body in root layout:

// app/layout.tsx
import { Suspense } from 'react';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      {/* Empty fallback: No static shell, full SSR */}
      <Suspense fallback={null}>
        <body>{children}</body>
      </Suspense>
    </html>
  );
}

Every request now blocks until fully rendered. Use this sparingly—only when your application requires real-time data and can't benefit from static shell. You can also create multiple root layouts for routes that need different rendering strategies.

Cache Invalidation: Keeping Shell Fresh

Cached data in the shell becomes stale. Invalidate it with cache tags or revalidation:

// Cache data with tags
async function BlogPosts() {
  'use cache';
  cacheLife('hours');
  cacheTag('posts'); // Tag for invalidation

  const posts = await fetch('https://api.vercel.app/posts');
  return <BlogList posts={posts.json()} />;
}

// Server Action: Invalidate on mutation
'use server';

export async function createPost(formData: FormData) {
  await db.posts.create({ data: { title: formData.get('title') } });

  // Immediate invalidation: Next shell request rebuilds cache
  updateTag('posts');
  revalidatePath('/blog');
}

updateTag() immediately expires matching cache entries. The next request rebuilds that portion of the shell. revalidatePath() invalidates all cached content under a path. Use both for fine-grained and path-level control.

Non-Deterministic Operations

Operations like Math.random(), Date.now(), crypto.randomUUID() produce different values each run. PPR requires you to handle these explicitly:

// ❌ Wrong: Fails build (non-deterministic)
export default async function Page() {
  const requestId = crypto.randomUUID(); // Different on each build
  return <div>ID: {requestId}</div>;
}

// ✅ Right: Request-time value (stream in)
async function RequestId() {
  await connection(); // Defer to request time
  const requestId = crypto.randomUUID();
  return <div>ID: {requestId}</div>;
}

export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <RequestId />
    </Suspense>
  );
}

Use connection() to defer non-deterministic operations to request time. The fallback goes into shell; the component streams in with unique value. Alternatively, cache the result if all users should see the same value.

PPR vs Full Static vs Full SSR

MetricFull StaticFull SSRPPR
Initial HTMLInstant (pre-rendered)Slow (must render)Instant (shell)
Dynamic dataStale until rebuildFresh on each requestFresh, streams in
Server loadMinimal (CDN serves)High (every request)Medium (shell + dynamic)
ComplexityLowLowMedium

Putting It Together

// Complete PPR page
export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* Static: Instant */}
      <Header />
      <Breadcrumbs category={params.category} />

      {/* Cached: Fast, revalidated on change */}
      <ProductInfo productId={params.id} />

      {/* Dynamic: Streams in */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={params.id} />
      </Suspense>

      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedProducts categoryId={category.id} />
      </Suspense>
    </div>
  );
}

PPR is the default in Next.js 16 for a reason: it gives you the best of both worlds. Static shell for instant loading, dynamic slots for fresh data. No manual configuration required—just use "use cache" and <Suspense> appropriately.

Advertisement

Related Insights

Explore related edge cases and patterns

CSS
Surface
CSS @property: Finally Animating the Un-animatable
6 min
Next.js
Deep
Next.js 'use cache': Explicit Caching with Automatic Keys
9 min
Next.js
Deep
Next.js Parallel and Intercepting Routes
8 min

Advertisement