Next.js 15 Partial Prerendering (PPR): Hybrid Streaming, Cache Life & Suspense Architecture

Table of Contents(15 sections)
Next.js 15 introduces Partial Prerendering (PPR) as a foundational architectural shift, moving beyond the traditional dichotomy of Static Site Generation (SSG) and Server-Side Rendering (SSR). PPR leverages React's Suspense and streaming capabilities to deliver a hybrid approach: a fast, static shell served instantly, with dynamic "holes" streamed into place as data becomes available. This guide dissects PPR's mechanics, its impact on caching, and practical implementation strategies.
Next.js 15 & React 19 Architecture Track
Understanding Partial Prerendering (PPR)
PPR fundamentally redefines how Next.js pages are rendered and served. Instead of waiting for all data to resolve before sending any HTML (SSR) or pre-rendering the entire page at build time (SSG), PPR identifies static and dynamic segments of a page.
During a build, Next.js identifies static parts of your application, typically components that do not depend on dynamic data or user-specific context. These static parts form the "static shell." Dynamic parts, often wrapped in Suspense boundaries, are treated as "holes."
When a request comes in:
- The static shell is served immediately from a cache (similar to SSG). This provides a near-instant Time To First Byte (TTFB) and First Contentful Paint (FCP).
- Concurrently, the server fetches data for the dynamic holes.
- As dynamic data resolves, Next.js streams the corresponding HTML into the client, replacing the
Suspensefallback.
This hybrid streaming approach ensures a fast initial load while maintaining dynamic content freshness.
Enabling PPR
PPR is an experimental feature in Next.js 15. To enable it, configure your next.config.mjs:
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true,
// Other experimental flags if needed
},
// ... other Next.js configurations
};
export default nextConfig;
With ppr: true, Next.js will automatically attempt to apply PPR optimizations to pages containing Suspense boundaries.
The Role of Suspense
React Suspense is the cornerstone of PPR. It allows you to declaratively specify loading states for parts of your UI that depend on asynchronous data. In a PPR context, Suspense boundaries delineate the dynamic "holes" that will be streamed.
Consider a product detail page:
// app/products/[slug]/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from './ProductDetails';
import { RelatedProducts } from './RelatedProducts';
import { Reviews } from './Reviews';
import { Skeleton } from '@/components/ui/skeleton'; // A simple skeleton component
export default async function ProductPage({ params }: { params: { slug: string } }) {
// Static shell content
const staticProductInfo = await getStaticProductInfo(params.slug); // Example: product name, description
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-4">{staticProductInfo.name}</h1>
<p className="text-lg mb-6">{staticProductInfo.description}</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Dynamic hole 1: Product Details (e.g., price, stock, images) */}
<Suspense fallback={<ProductDetailsSkeleton />}>
<ProductDetails slug={params.slug} />
</Suspense>
{/* Dynamic hole 2: Related Products */}
<Suspense fallback={<RelatedProductsSkeleton />}>
<RelatedProducts slug={params.slug} />
</Suspense>
{/* Dynamic hole 3: User Reviews */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews slug={params.slug} />
</Suspense>
</div>
</div>
);
}
// Example data fetching functions (un-memoized for PPR)
async function getStaticProductInfo(slug: string) {
// Simulate fetching static product data
await new Promise(resolve => setTimeout(resolve, 50));
return { name: `Product ${slug.toUpperCase()}`, description: `A detailed description for product ${slug}.` };
}
// Component for dynamic product details
async function ProductDetails({ slug }: { slug: string }) {
// Simulate fetching dynamic product details (e.g., real-time price, stock)
await new Promise(resolve => setTimeout(resolve, 1000));
return (
<div className="col-span-2 bg-gray-100 p-4 rounded-lg">
<h2 className="text-2xl font-semibold mb-2">Details</h2>
<p>Price: $99.99</p>
<p>Stock: In Stock</p>
{/* More dynamic details */}
</div>
);
}
// Component for related products
async function RelatedProducts({ slug }: { slug: string }) {
await new Promise(resolve => setTimeout(resolve, 1500));
return (
<div className="bg-gray-100 p-4 rounded-lg">
<h2 className="text-2xl font-semibold mb-2">Related Products</h2>
<ul>
<li>Related Item A</li>
<li>Related Item B</li>
</ul>
</div>
);
}
// Component for reviews
async function Reviews({ slug }: { slug: string }) {
await new Promise(resolve => setTimeout(resolve, 2000));
return (
<div className="col-span-3 bg-gray-100 p-4 rounded-lg">
<h2 className="text-2xl font-semibold mb-2">Customer Reviews</h2>
<p>No reviews yet.</p>
</div>
);
}
// Skeleton components for fallbacks
function ProductDetailsSkeleton() {
return (
<div className="col-span-2 bg-gray-100 p-4 rounded-lg animate-pulse">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-1/2 mb-1" />
<Skeleton className="h-4 w-1/3" />
</div>
);
}
function RelatedProductsSkeleton() {
return (
<div className="bg-gray-100 p-4 rounded-lg animate-pulse">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-full mb-1" />
<Skeleton className="h-4 w-full" />
</div>
);
}
function ReviewsSkeleton() {
return (
<div className="col-span-3 bg-gray-100 p-4 rounded-lg animate-pulse">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-full" />
</div>
);
}
In this example:
- The
h1andptags forstaticProductInfoare part of the static shell. ProductDetails,RelatedProducts, andReviewsare wrapped inSuspense, making them dynamic holes. Their respectiveSkeletoncomponents serve as fallbacks.
When a user requests /products/widget, they immediately receive the HTML for the product name and description. The browser then progressively receives and renders the details, related products, and reviews as their data becomes available.
Hybrid Streaming: The HTTP Response
PPR's magic lies in its HTTP streaming response. Instead of a single, monolithic HTML document, the server sends a multi-part response.
- Initial HTML (Static Shell): The first part of the response contains the static HTML, including the
Suspensefallbacks. This is sent with aContent-Type: text/htmlheader. - Streaming HTML Fragments: Subsequent parts of the response are streamed as
<template>tags containing the resolved dynamic content. These fragments are accompanied by<script>tags that instruct React to hydrate and replace the correspondingSuspensefallback with the new content.
This is not just about sending HTML; it's about sending executable instructions to the client to progressively enhance the page.
Cache Life Profiles and Data Fetching
PPR introduces cacheLife profiles, allowing fine-grained control over how long dynamic content is considered fresh and when it should be revalidated. This is crucial for balancing performance with data freshness.
cacheLife Profiles
Next.js 15 introduces a new cacheLife option for data fetching functions, allowing you to specify how long the dynamic content within a PPR hole should be cached. This is distinct from the static shell's cache, which is typically immutable after build.
// lib/data.ts
import { unstable_cache as cache } from 'next/cache';
export const getProductDetails = cache(
async (slug: string) => {
console.log(`Fetching product details for ${slug}...`);
await new Promise(resolve => setTimeout(resolve, 1000));
return { price: 99.99, stock: Math.floor(Math.random() * 100) };
},
['product-details'], // Cache key
{
tags: ['product-details'], // Invalidation tags
// cacheLife: 60, // Cache for 60 seconds
// cacheLife: 'revalidate', // Revalidate on every request (default for dynamic)
cacheLife: 'static', // Cache indefinitely (like SSG)
}
);
export const getRelatedProducts = cache(
async (slug: string) => {
console.log(`Fetching related products for ${slug}...`);
await new Promise(resolve => setTimeout(resolve, 1500));
return ['Related A', 'Related B', 'Related C'];
},
['related-products'],
{
tags: ['related-products'],
cacheLife: 3600, // Cache for 1 hour
}
);
The cacheLife option can take several values:
number(seconds): Specifies a time-based revalidation. After this duration, the cache is considered stale and will be revalidated on the next request.'revalidate': The default behavior for dynamic data. The data is revalidated on every request. This is equivalent tofetch(..., { cache: 'no-store' })orrevalidate = 0in page-levelrevalidateoptions.'static': The data is cached indefinitely, similar to SSG. This is suitable for data that changes very infrequently or is effectively static.
Important: cacheLife applies to the data itself, not the HTML fragment. The HTML fragment for a dynamic hole is generated after the data is fetched. If cacheLife is set, the data fetching function will use the cached data if available and fresh.
Un-memoized Data Fetching Patterns
A common pitfall with PPR is over-memoizing data fetches. In a traditional React component, you might use useMemo or useCallback to prevent unnecessary re-renders or re-fetches. However, with PPR, the server-side rendering phase for dynamic holes is designed to fetch data per request unless explicitly cached.
Avoid patterns that prevent data fetching functions from executing on subsequent requests within a Suspense boundary, unless you are intentionally using cache with a cacheLife profile.
// app/products/[slug]/ProductDetails.tsx
import { getProductDetails } from '@/lib/data';
export async function ProductDetails({ slug }: { slug: string }) {
// This fetch will run on every request for the dynamic hole
// unless getProductDetails itself is wrapped in unstable_cache with a cacheLife.
const details = await getProductDetails(slug);
return (
<div className="col-span-2 bg-gray-100 p-4 rounded-lg">
<h2 className="text-2xl font-semibold mb-2">Details</h2>
<p>Price: ${details.price}</p>
<p>Stock: {details.stock}</p>
</div>
);
}
If getProductDetails is not wrapped in unstable_cache, it will execute on every request that triggers the dynamic hole. This is often the desired behavior for truly dynamic data. If you want to cache it, use unstable_cache with an appropriate cacheLife.
Benchmarking TTFB vs. Fully Dynamic SSR
PPR's primary performance benefit is a significantly improved TTFB and FCP compared to traditional SSR, especially for pages with slow data dependencies.
| Feature | Traditional SSR | Partial Prerendering (PPR) |
|---|---|---|
| TTFB | High (waits for all data) | Low (static shell served instantly) |
| FCP | High (waits for all data) | Low (static shell rendered instantly) |
| LCP | Dependent on slowest data | Dependent on slowest critical dynamic data |
| Data Freshness | Always fresh (per request) | Configurable per dynamic hole (cacheLife) |
| Complexity | Simpler mental model | Requires careful Suspense and cacheLife management |
| Caching | Page-level revalidate or no-store | Static shell cached indefinitely, dynamic holes via cacheLife |
| Streaming | Basic HTML streaming (no progressive content) | Advanced HTML streaming with progressive content |
| Build Time | Minimal (no pre-rendering) | Higher (identifies static parts, pre-renders shell) |
Benchmark Scenario: Consider a page with a static header/footer and three dynamic sections, each taking 1 second to fetch data.
- Traditional SSR: TTFB would be ~3 seconds (sum of all data fetches) + server render time.
- PPR: TTFB would be ~50ms (static shell) + server render time. The dynamic sections would stream in over the next 3 seconds.
This immediate feedback to the user, even with placeholders, drastically improves perceived performance and user experience.
Production Gotchas & Troubleshooting
-
Hydration Mismatches with
SuspenseFallbacks:- Problem: You see
Warning: Prop 'className' did not match. Server: "..." Client: "..."or similar hydration errors when dynamic content streams in. This often happens if yourSuspensefallback renders different HTML than the initial client-side render before the dynamic content arrives. - Fix: Ensure your
Suspensefallback (e.g., a skeleton loader) renders identical HTML on both server and client until the dynamic content is ready. Avoid client-side-only logic or random IDs within fallbacks. If using a library likereact-loading-skeleton, ensure it's configured consistently.
- Problem: You see
-
Slow Initial Load Despite PPR:
- Problem: Your TTFB is still high, even with
ppr: true. - Fix:
- Check
Suspenseboundaries: Are your truly dynamic parts wrapped inSuspense? If the root of your page or large sections are not suspended, Next.js might still wait for all data. - Root-level
await: If yourpage.tsxdirectlyawaits a slow data fetch outside aSuspenseboundary, it will block the static shell. Move such fetches into components wrapped bySuspense. cacheLife: 'revalidate'on static content: Accidentally settingcacheLife: 'revalidate'orrevalidate = 0on data that could be static will prevent the static shell from being served from cache. Ensure static data fetches usecacheLife: 'static'or are not wrapped inunstable_cacheif they are truly static and part of the build.
- Check
- Problem: Your TTFB is still high, even with
-
Incorrect
cacheLifeUsage Leading to Stale Data:- Problem: Users are seeing stale data in dynamic sections.
- Fix: Review your
unstable_cacheconfigurations. IfcacheLifeis set too high (e.g.,3600seconds for rapidly changing stock prices), data will be served from cache. AdjustcacheLifeto reflect the actual freshness requirements. For real-time data, usecacheLife: 'revalidate'or omitunstable_cacheentirely. RememberrevalidateTagorrevalidatePathcan be used for on-demand invalidation.
-
Excessive Server Load from
cacheLife: 'revalidate':- Problem: Your backend services are hammered because every request to a PPR page triggers a re-fetch for dynamic holes.
- Fix: This is expected behavior for
cacheLife: 'revalidate'. If the data doesn't need to be absolutely real-time on every single request, consider introducing a smallcacheLife(e.g.,5or10seconds) to reduce backend load while maintaining reasonable freshness. Implement a robust caching layer in your backend services as well.
-
PPR Not Working in Development:
- Problem: PPR benefits (streaming, fast TTFB) are not apparent in
next dev. - Fix: PPR's full capabilities, especially the static shell caching and streaming, are primarily optimized for production builds (
next buildandnext start). WhileSuspenseworks in development, the caching and streaming optimizations are less pronounced or may not be fully active. Always test PPR performance in a production-like environment.
- Problem: PPR benefits (streaming, fast TTFB) are not apparent in
Frequently Asked Questions
Q1: Can I use PPR with generateStaticParams?
A1: Yes. generateStaticParams pre-renders the static shell for all specified paths at build time. When a request for one of these paths comes in, the pre-rendered static shell is served instantly, and the dynamic holes are streamed. This combines the benefits of SSG (fast initial load for known paths) with SSR (dynamic content freshness).
Q2: How does PPR interact with revalidatePath and revalidateTag?
A2: revalidatePath and revalidateTag primarily invalidate the data cache managed by unstable_cache. If you use unstable_cache with tags and cacheLife, calling revalidateTag('your-tag') will mark that cached data as stale. The next request for a dynamic hole depending on that data will trigger a re-fetch. The static shell itself is generally immutable after build, unless the entire page is re-built.
Q3: Is PPR suitable for highly personalized pages (e.g., user dashboards)?
A3: PPR can be beneficial even for highly personalized pages. The static shell can contain generic layout elements (header, navigation, empty dashboard structure). The personalized data (user's name, specific metrics, recent activity) would then be streamed into dynamic holes. This still provides a faster initial render than waiting for all personalized data. However, if every part of the page is dynamic and user-specific, the benefits of the static shell diminish, and traditional SSR might be simpler to manage.
Q4: What are the implications of PPR for SEO?
A4: PPR is generally SEO-friendly. Search engine crawlers typically execute JavaScript and wait for content to render. Since the static shell is delivered instantly, core content is available quickly. Dynamic content streamed later will also be indexed once rendered by the crawler. The key is to ensure your Suspense fallbacks provide meaningful content or clear indicators, and that your dynamic content eventually renders correctly. Fast TTFB and FCP are positive SEO signals.
Q5: How does PPR affect client-side JavaScript bundle size?
A5: PPR itself doesn't directly increase or decrease client-side JavaScript bundle size. It primarily optimizes the server-side rendering and streaming process. However, the use of Suspense and React's streaming architecture means that the client-side React runtime is essential for hydrating and progressively rendering the streamed fragments. Next.js's automatic code splitting ensures that only the necessary JavaScript for each component is loaded.
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

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
Fix Next.js Hydration Errors: React 418, Text Mismatch & suppressHydrationWarning (2026)
Copy-paste fixes for every Next.js hydration error: React #418 (browser extensions), text content mismatch, dark mode flash, suppressHydrationWarning, and Suspense #423/#425 — with real 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