Next.js 15 Partial Prerendering (PPR): Combining Static Shells with Dynamic Streaming

Table of Contents(16 sections)
Next.js 15 introduces Partial Prerendering (PPR), a novel optimization strategy that fundamentally alters how dynamic web applications are rendered and delivered. PPR is not merely an incremental improvement; it represents a paradigm shift, merging the best aspects of static site generation (SSG) and server-side rendering (SSR) with React's Suspense-driven streaming capabilities. This guide dissects PPR's architecture, its impact on critical performance metrics, and practical implementation considerations for high-concurrency applications.
Architectural Overview of Partial Prerendering
PPR operates on the principle of delivering an immediate, static shell of a page while concurrently streaming dynamic content into designated Suspense boundaries. This is achieved through a multi-stage rendering process:
- Build-time Static Shell Generation: During
next build, Next.js identifies static portions of a page and generates a lightweight HTML shell. This shell includes the basic layout, navigation, and any content that is not wrapped in a Suspense boundary. This static HTML is then served directly from a CDN. - Server-side Dynamic Content Streaming: When a request hits the server, Next.js renders the dynamic components (those within Suspense boundaries) on the server. Instead of waiting for all data to resolve, it streams the resolved HTML for these components directly into the client's browser over the same HTTP connection that delivered the static shell.
- Client-side Hydration: As the streamed HTML arrives, React progressively hydrates the components, making them interactive without a full page reload.
This approach ensures an extremely fast Time to First Byte (TTFB) and First Contentful Paint (FCP) by leveraging CDN-cached static assets, while still providing a fully dynamic, personalized experience without client-side data fetching waterfalls.
PPR vs. Traditional SSR/SSG/ISR
| Feature | Traditional SSR | Traditional SSG | Traditional ISR | Next.js 15 PPR |
|---|---|---|---|---|
| TTFB | High (server waits for all data) | Low (CDN-cached) | Low (CDN-cached) | Low (CDN-cached static shell) |
| FCP | High (server waits for all data) | Low (CDN-cached) | Low (CDN-cached) | Low (CDN-cached static shell) |
| Dynamic Content | Full dynamic | None (client-side fetch) | Full dynamic (revalidation) | Full dynamic (server-streamed) |
| Build Time | N/A (on-demand) | High (all pages) | High (all pages) | Moderate (static shells) |
| Cacheability | Low (per-request) | High (CDN) | High (CDN, with revalidation) | High (static shell) |
| Complexity | Moderate | Low | Moderate | Moderate-High (Suspense boundaries) |
Performance Implications: TTFB and FCP
PPR's primary advantage lies in its impact on TTFB and FCP, particularly for complex applications like e-commerce product pages or SaaS dashboards.
E-commerce Product Pages
Consider a product detail page (PDP). The core layout, product image, title, and static description are often consistent. Dynamic elements include price, stock availability, personalized recommendations, and user reviews.
With PPR:
- Initial Request: The browser receives a static HTML shell containing the product image, title, and layout almost instantly from the CDN. TTFB is minimal.
- FCP: The user sees the primary product information very quickly.
- Streaming: Concurrently, the server fetches real-time price, stock, and recommendation data. As each piece of data resolves, its corresponding HTML is streamed into the page.
- Hydration: The streamed content is progressively hydrated, making the "Add to Cart" button interactive and recommendations clickable.
This provides a superior user experience compared to traditional SSR (where the user waits for all dynamic data before seeing anything) or SSG (where dynamic data requires a client-side fetch, leading to layout shifts and delayed interactivity).
SaaS Dashboards
SaaS dashboards often feature multiple widgets displaying real-time data (e.g., user analytics, sales figures, system health).
With PPR:
- Initial Request: The dashboard layout, navigation, and static headers are delivered as a static shell.
- FCP: The user sees the dashboard structure immediately.
- Streaming: Individual data widgets (e.g., "Active Users," "Revenue Today," "System Load") are wrapped in Suspense boundaries. The server fetches data for each widget independently. As data for "Active Users" resolves, its HTML is streamed. Then "Revenue Today," and so on.
- Hydration: Each widget becomes interactive as its content arrives and is hydrated.
This prevents a single slow data fetch from blocking the entire dashboard render, improving perceived performance and user engagement.
Implementing Partial Prerendering
PPR leverages React's Suspense and async/await components. The core idea is to wrap dynamic or data-dependent parts of your page in <Suspense> boundaries.
// app/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from '@/components/ProductDetails';
import { ProductRecommendations } from '@/components/ProductRecommendations';
import { UserReviews } from '@/components/UserReviews';
import { Skeleton } from '@/components/Skeleton'; // A simple loading skeleton
interface ProductPageProps {
params: {
productId: string;
};
}
export default async function ProductPage({ params }: ProductPageProps) {
const productId = params.productId;
// Static content rendered immediately
const staticProductInfo = await getStaticProductInfo(productId);
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-4">{staticProductInfo.name}</h1>
<img src={staticProductInfo.imageUrl} alt={staticProductInfo.name} className="w-full h-64 object-cover mb-4" />
<p className="text-gray-700 mb-6">{staticProductInfo.description}</p>
{/* Dynamic content wrapped in Suspense boundaries */}
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Product Details</h2>
<Suspense fallback={<Skeleton height="h-48" />}>
{/* ProductDetails is an async component that fetches dynamic data */}
<ProductDetails productId={productId} />
</Suspense>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Recommendations</h2>
<Suspense fallback={<Skeleton height="h-32" />}>
{/* ProductRecommendations is an async component */}
<ProductRecommendations productId={productId} />
</Suspense>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Customer Reviews</h2>
<Suspense fallback={<Skeleton height="h-64" />}>
{/* UserReviews is an async component */}
<UserReviews productId={productId} />
</Suspense>
</section>
</div>
);
}
// Example of a static data fetch (could be from a CMS or build-time data)
async function getStaticProductInfo(productId: string) {
// Simulate a fast, static data fetch
return {
name: `Product ${productId} - Static Title`,
imageUrl: `/images/product-${productId}.jpg`,
description: `This is a static description for product ${productId}. It provides general information that doesn't change frequently.`,
};
}
// components/ProductDetails.tsx
import { delay } from '@/lib/utils'; // Utility for simulating network delay
interface ProductDetailsProps {
productId: string;
}
export async function ProductDetails({ productId }: ProductDetailsProps) {
// Simulate a dynamic data fetch (e.g., real-time price, stock)
await delay(1500); // Simulate network latency
const price = (Math.random() * 100 + 50).toFixed(2);
const stock = Math.floor(Math.random() * 200);
return (
<div className="border p-4 rounded-lg bg-white shadow-sm">
<p className="text-xl font-bold text-green-600 mb-2">Price: ${price}</p>
<p className="text-lg text-gray-800">In Stock: {stock} units</p>
<button className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
Add to Cart
</button>
</div>
);
}
// components/ProductRecommendations.tsx
import { delay } from '@/lib/utils';
interface ProductRecommendationsProps {
productId: string;
}
export async function ProductRecommendations({ productId }: ProductRecommendationsProps) {
await delay(2500); // Simulate longer network latency for recommendations
const recommendations = [
`Related Product A for ${productId}`,
`Related Product B for ${productId}`,
`Related Product C for ${productId}`,
];
return (
<div className="border p-4 rounded-lg bg-white shadow-sm">
<ul className="list-disc pl-5">
{recommendations.map((rec, i) => (
<li key={i} className="text-gray-700">{rec}</li>
))}
</ul>
</div>
);
}
// components/UserReviews.tsx
import { delay } from '@/lib/utils';
interface UserReviewsProps {
productId: string;
}
export async function UserReviews({ productId }: UserReviewsProps) {
await delay(3000); // Simulate even longer latency for reviews
const reviews = [
{ user: 'Alice', rating: 5, comment: `Excellent product for ${productId}!` },
{ user: 'Bob', rating: 4, comment: `Good value, but delivery was slow.` },
];
return (
<div className="border p-4 rounded-lg bg-white shadow-sm">
{reviews.map((review, i) => (
<div key={i} className="mb-4 pb-4 border-b last:border-b-0">
<p className="font-semibold">{review.user} - <span className="text-yellow-500">{'★'.repeat(review.rating)}</span></p>
<p className="text-gray-700">{review.comment}</p>
</div>
))}
</div>
);
}
// components/Skeleton.tsx
interface SkeletonProps {
height?: string;
}
export function Skeleton({ height = 'h-24' }: SkeletonProps) {
return (
<div className={`animate-pulse bg-gray-200 rounded-md ${height}`}></div>
);
}
// lib/utils.ts
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
In this setup:
ProductPageitself is anasynccomponent, but its initial render is fast becausegetStaticProductInfois assumed to be quick or pre-fetched.ProductDetails,ProductRecommendations, andUserReviewsare alsoasynccomponents, simulating data fetching. They are wrapped in<Suspense>, providing immediate fallback UI (Skeleton) while their data loads.- The static shell includes the
h1,img, andptags fromProductPage. TheSuspensefallbacks are also part of the initial static shell.
Migration Gotchas & Troubleshooting
Migrating existing applications or developing new ones with PPR requires careful consideration of data fetching patterns and React's concurrent features.
cookies() and headers() in PPR
The cookies() and headers() functions from next/headers are dynamic functions. When used within a component that is part of the static shell, they force the entire page to be dynamically rendered on the server, effectively disabling PPR for that page.
Problem: Using cookies() or headers() outside a Suspense boundary.
// app/page.tsx (BAD EXAMPLE)
import { cookies } from 'next/headers';
export default function MyPage() {
const cookieStore = cookies(); // This makes the whole page dynamic
const theme = cookieStore.get('theme')?.value || 'light';
return (
<div className={`theme-${theme}`}>
{/* ... rest of your page */}
</div>
);
}
Fix: Isolate dynamic headers/cookies usage within components wrapped by Suspense, or ensure they are only used in truly dynamic routes. For theme, consider client-side state or a dedicated client component.
// app/page.tsx (GOOD EXAMPLE)
import { Suspense } from 'react';
import { DynamicHeader } from '@/components/DynamicHeader';
export default function MyPage() {
return (
<div>
<Suspense fallback={<div>Loading Header...</div>}>
<DynamicHeader /> {/* DynamicHeader uses cookies() */}
</Suspense>
{/* ... rest of your static page content */}
</div>
);
}
// components/DynamicHeader.tsx
import { cookies } from 'next/headers';
export async function DynamicHeader() {
const cookieStore = cookies();
const userName = cookieStore.get('userName')?.value || 'Guest';
return <header>Welcome, {userName}!</header>;
}
This ensures only the DynamicHeader component is rendered dynamically on the server, while the rest of MyPage can still be part of the static shell.
Dynamic Data Fetching
Any fetch call or database query within an async Server Component that is not wrapped in a Suspense boundary will block the initial static shell generation if it's slow.
Problem: Slow data fetch outside Suspense.
// app/dashboard/page.tsx (BAD EXAMPLE)
import { getCriticalMetrics } from '@/lib/api';
export default async function DashboardPage() {
const metrics = await getCriticalMetrics(); // This fetch blocks the entire page
return (
<div>
<h1>Dashboard</h1>
<p>Critical Metric: {metrics.value}</p>
{/* ... other components, potentially in Suspense */}
</div>
);
}
If getCriticalMetrics is slow, the entire dashboard will wait, negating PPR benefits for the initial render.
Fix: Wrap all potentially slow or dynamic data fetches in Suspense boundaries.
// app/dashboard/page.tsx (GOOD EXAMPLE)
import { Suspense } from 'react';
import { CriticalMetrics } from '@/components/CriticalMetrics';
import { SalesChart } from '@/components/SalesChart';
export default function DashboardPage() {
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">Dashboard Overview</h1>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Key Performance Indicators</h2>
<Suspense fallback={<Skeleton height="h-24" />}>
<CriticalMetrics /> {/* Async component fetching data */}
</Suspense>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Sales Trends</h2>
<Suspense fallback={<Skeleton height="h-96" />}>
<SalesChart /> {/* Another async component */}
</Suspense>
</section>
</div>
);
}
// components/CriticalMetrics.tsx
import { delay } from '@/lib/utils';
export async function CriticalMetrics() {
await delay(2000); // Simulate slow API call
const value = (Math.random() * 1000).toFixed(0);
return <div className="p-4 border rounded-lg bg-green-50">Current Metric: {value}</div>;
}
This ensures the Dashboard Overview title and layout are delivered instantly, with metrics streaming in as they become available.
use client Components and PPR
Client components do not participate in server-side streaming in the same way Server Components do. If a client component needs dynamic data, it will fetch it on the client after hydration, potentially leading to waterfalls. For optimal PPR, push data fetching as high as possible into Server Components.
Problem: Client component fetching data.
// components/ClientCounter.tsx (BAD EXAMPLE for PPR)
'use client';
import { useState, useEffect } from 'react';
export function ClientCounter() {
const [count, setCount] = useState(0);
const [dynamicValue, setDynamicValue] = useState(null);
useEffect(() => {
// This fetch happens on the client after hydration
fetch('/api/dynamic-value')
.then(res => res.json())
.then(data => setDynamicValue(data.value));
}, []);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
{dynamicValue && <p>Dynamic Value: {dynamicValue}</p>}
</div>
);
}
While valid for client-side interactivity, if dynamicValue is critical content, fetching it this way delays its appearance until the client component hydrates and executes its useEffect.
Fix: Pass dynamic data as props from a parent Server Component.
// app/page.tsx
import { Suspense } from 'react';
import { ServerFetchedClientCounter } from '@/components/ServerFetchedClientCounter';
import { getInitialCount } from '@/lib/api'; // Server-side data fetch
export default async function HomePage() {
const initialCount = await getInitialCount(); // Fetch on server
return (
<div>
<h1>Home</h1>
<Suspense fallback={<div>Loading Counter...</div>}>
<ServerFetchedClientCounter initialCount={initialCount} />
</Suspense>
</div>
);
}
// components/ServerFetchedClientCounter.tsx
'use client';
import { useState } from 'react';
interface ServerFetchedClientCounterProps {
initialCount: number;
}
export function ServerFetchedClientCounter({ initialCount }: ServerFetchedClientCounterProps) {
const [count, setCount] = useState(initialCount); // Initial state from server
return (
<div className="border p-4 rounded-lg bg-blue-50">
<p className="text-lg">Server-initialized Count: {count}</p>
<button
onClick={() => setCount(c => c + 1)}
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
>
Increment
</button>
</div>
);
}
// lib/api.ts
import { delay } from './utils';
export async function getInitialCount() {
await delay(1000); // Simulate server-side data fetch
return 42;
}
Here, initialCount is fetched on the server and passed as a prop, ensuring the client component has its initial data without an additional client-side fetch.
Frequently Asked Questions
1. How does PPR affect SEO?
PPR is generally beneficial for SEO. The initial static shell, which includes critical content, is immediately available to search engine crawlers, ensuring fast indexing. Dynamic content streamed later is also part of the server-rendered HTML, so crawlers that execute JavaScript (like Googlebot) will eventually see the full content. The improved FCP and TTFB also contribute positively to search rankings.
2. Can I use PPR with generateStaticParams?
Yes. generateStaticParams is used to pre-render routes at build time. When combined with PPR, Next.js will generate a static shell for each of these pre-rendered routes. Dynamic content within Suspense boundaries will still be streamed on demand when those routes are accessed. This is ideal for pages with many similar layouts but varying dynamic data (e.g., thousands of product pages).
3. What is the impact of deeply nested Suspense boundaries on performance?
Deeply nested Suspense boundaries can increase the complexity of the streaming process and potentially lead to more granular, but potentially more frequent, network flushes. While React is optimized for this, excessive nesting without clear performance benefits might introduce overhead. It's generally recommended to use Suspense at logical content boundaries where data fetching occurs, rather than wrapping every single element. Prioritize wrapping components that fetch data independently or are likely to be slow.
4. Does PPR replace getServerSideProps or getStaticProps?
PPR, in the context of the App Router, largely supersedes the need for getServerSideProps and getStaticProps by integrating their functionalities directly into Server Components. An async Server Component can fetch data, effectively acting like getServerSideProps for its specific subtree. If no dynamic functions (cookies(), headers(), searchParams) are used and no revalidate option is set, the component behaves like getStaticProps. PPR then optimizes the delivery of these Server Components by streaming. For pages that are entirely static and require no dynamic content, a simple Server Component without async fetches or Suspense will still be fully static.
5. How does error handling work with PPR and Suspense?
Errors within a Suspense boundary can be caught by an Error Boundary component. If an error occurs during the server-side streaming of a component, the Error Boundary will render its fallback UI on the client once the stream for that segment completes or errors out. This prevents a single component failure from crashing the entire page. It's crucial to implement robust error boundaries around your Suspense-wrapped components.
// components/ErrorBoundary.tsx
'use client';
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(_: Error): ErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
// You can log the error to an error reporting service here
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// Usage:
// <ErrorBoundary fallback={<div>Something went wrong loading recommendations.</div>}>
// <Suspense fallback={<Skeleton height="h-32" />}>
// <ProductRecommendations productId={productId} />
// </Suspense>
// </ErrorBoundary>
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Next.js 15 Partial Prerendering (PPR): Hybrid Streaming, Cache Life & Suspense Architecture
Comprehensive guide covering next.js 15 partial prerendering (ppr): hybrid streaming, cache life & suspense architecture with production-grade architecture and code examples.
Read more
TanStack Query v5 with Next.js 15: Optimistic Updates, Cache Sync & Server Actions
Comprehensive guide covering tanstack query v5 with next.js 15: optimistic updates, cache sync & server actions with production-grade architecture and code examples.
Read more
Mastering Next.js 14+ Metadata & Open Graph: Dynamic Social Cards at Scale
Turn social media shares into massive organic traffic drivers. Master Next.js generateMetadata, Open Graph tags, Twitter Cards, and dynamic Edge OG image generation.
Read more