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.
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
| Metric | Full Static | Full SSR | PPR |
|---|---|---|---|
| Initial HTML | Instant (pre-rendered) | Slow (must render) | Instant (shell) |
| Dynamic data | Stale until rebuild | Fresh on each request | Fresh, streams in |
| Server load | Minimal (CDN serves) | High (every request) | Medium (shell + dynamic) |
| Complexity | Low | Low | Medium |
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
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement