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


Introduction
In the quest for ever-faster web experiences, developers constantly balance the benefits of static rendering (speed, SEO, caching) with the necessity of dynamic, personalized content. Traditional approaches often forced a compromise: either accept slower load times for highly dynamic pages or sacrifice real-time interactivity for static speed.
Next.js 15 introduces a groundbreaking feature that aims to resolve this dilemma: Partial Prerendering (PPR). PPR redefines how we think about rendering by allowing developers to ship an instant static shell of a page while dynamically streaming in highly interactive or personalized content. This hybrid approach promises the best of both worlds: the immediate perceived performance of static sites combined with the rich, up-to-date user experience of dynamic applications.
This comprehensive guide will dive deep into Next.js 15's Partial Prerendering, exploring its core concepts, implementation details, real-world use cases, and best practices. By the end, you'll have a solid understanding of how to leverage PPR to build modern web applications that are both blazing fast and incredibly dynamic.
Prerequisites
To get the most out of this guide, you should have:
- A foundational understanding of React and JavaScript.
- Familiarity with Next.js concepts, including the App Router, Server Components, and Client Components.
- Basic knowledge of data fetching in React applications.
What is Partial Prerendering (PPR)?
Partial Prerendering (PPR) is an advanced rendering optimization introduced in Next.js 15, building upon the foundations of React Server Components and Suspense. At its core, PPR allows Next.js to perform a build-time prerendering of the static parts of a page, creating an immediate, lightweight HTML shell. Simultaneously, it identifies and marks the dynamic parts of the page, which are then streamed in after the initial static shell has been delivered to the browser.
Think of it like this: when you visit a PPR-enabled page, you instantly see the layout and static content (e.g., header, footer, product description). While you're looking at this, the dynamic elements (e.g., personalized recommendations, live chat, user-specific data) are being fetched and streamed directly into their designated spots without blocking the initial render. This process uses React's Suspense boundaries to manage the loading states and seamlessly swap in the dynamic content once it's ready.
Key characteristics of PPR:
- Static Shell: The majority of the page's structure and static content is generated at build time or on the first request and served instantly.
- Dynamic Holes: Specific areas of the page, wrapped in
Suspenseboundaries, are left as "holes" to be filled with dynamic content. - Streaming: The content for these dynamic holes is fetched and streamed from the server after the static shell has been sent, without requiring a full page reload.
- Hydration: Once the dynamic content arrives, React hydrates it, making it interactive.
Why PPR? The Problem It Solves
PPR addresses a fundamental tension in web development: the desire for instantaneous load times (traditionally achieved with static sites) and the need for personalized, real-time user experiences (typically requiring server-side rendering or client-side fetching).
Here's how PPR tackles common performance and user experience challenges:
- Improved Core Web Vitals (FCP & LCP): By delivering a complete static shell immediately, PPR significantly improves First Contentful Paint (FCP) because users see meaningful content almost instantly. Largest Contentful Paint (LCP) also benefits as the primary content is often part of this static shell, or its placeholder is. This leads to a much faster perceived loading experience.
- Reduced Hydration Overhead: Traditional Server-Side Rendering (SSR) sends a fully rendered HTML page, which then needs to be re-hydrated by React on the client. If the page contains many dynamic components, this hydration can be a blocking process, leading to a "janky" experience where users can see content but can't interact with it. PPR reduces this overhead by only hydrating the dynamic parts as they arrive, allowing the static shell to become interactive much faster.
- Enhanced User Experience: Users no longer stare at a blank screen or a full-page loading spinner. They get immediate visual feedback and can begin interacting with the static parts of the page while dynamic elements load in gracefully. This creates a smoother, more engaging experience.
- SEO Benefits: Search engines primarily crawl the initial HTML response. With PPR, the static shell provides a rich, crawlable baseline of content, ensuring that critical information is immediately available to bots. Dynamic content streamed afterward is also generally crawlable by modern search engines, maintaining SEO integrity.
- Simplified Caching: The static shell can be heavily cached at CDNs or the edge, significantly reducing server load and improving delivery speed for repeat visitors. Dynamic parts can then be fetched with their own caching strategies, optimized for freshness.
How PPR Works Under the Hood
PPR leverages several core Next.js and React features to achieve its magic:
- Build-Time Analysis: During the
next buildprocess, Next.js analyzes your pages. It identifies which parts can be statically prerendered and which parts are dynamic (typically anything wrapped in aSuspenseboundary). It then generates a static HTML file for the page's shell. - Server Components: React Server Components (RSCs) are crucial. They allow you to write server-side code that renders directly to HTML on the server without being part of the client-side JavaScript bundle. When an RSC fetches data or renders dynamic content, it can be wrapped in
Suspense. - React Suspense: This is the orchestrator of PPR. When Next.js encounters a
<Suspense>boundary during the initial render, it knows that the content inside is not yet ready. Instead of waiting, it renders thefallbackprop (e.g., a skeleton UI or a loading spinner) as part of the static shell. The actual content inside theSuspenseboundary is then rendered asynchronously on the server. - HTML Streaming: When a request comes in, Next.js first sends the static HTML shell. As the server finishes rendering the content for the
Suspenseboundaries, it streams additional HTML chunks (containing the dynamic content) to the browser. These chunks are designed to seamlessly replace thefallbackcontent within the correctSuspenseboundaries. - Progressive Hydration: As the streamed HTML arrives, React progressively hydrates these new parts, making them interactive. This means different parts of your page can become interactive at different times, rather than waiting for the entire page to hydrate.
Essentially, PPR builds a static skeleton with placeholders and then uses server-side streaming to progressively fill in the dynamic data and UI.
Implementing PPR in Next.js 15
Implementing PPR involves a few key steps:
1. Enabling PPR
PPR is an experimental feature in Next.js 15, so you need to enable it in your next.config.js file:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true, // Enable Partial Prerendering
// Other experimental flags if needed
},
};
module.exports = nextConfig;2. Using Suspense Boundaries
The core mechanism for defining dynamic parts of your page is React's Suspense component. Any component that fetches data asynchronously or renders content that you want to stream in separately should be wrapped in Suspense.
// app/page.js (Server Component by default)
import { Suspense } from 'react';
import { SomeStaticComponent } from '../components/SomeStaticComponent';
import { DynamicContent } from '../components/DynamicContent';
import { LoadingSkeleton } from '../components/LoadingSkeleton';
export default function HomePage() {
return (
<div>
<SomeStaticComponent />
<h1>Welcome to Our Site!</h1>
<p>This is some static content that loads instantly.</p>
<Suspense fallback={<LoadingSkeleton />}>
{/* Content inside Suspense will be streamed in */}
<DynamicContent />
</Suspense>
<footer>
<p>Static footer content.</p>
</footer>
</div>
);
}3. Server Components for Dynamic Parts
For optimal PPR, the components inside your Suspense boundaries should ideally be Server Components. This allows their data fetching and rendering to happen on the server, and the resulting HTML to be streamed directly.
// app/components/DynamicContent.js (Server Component by default)
async function fetchUserData() {
// Simulate a network delay
await new Promise(resolve => setTimeout(resolve, 2000));
return { name: 'Alice', email: 'alice@example.com', items: ['Product A', 'Product B'] };
}
export async function DynamicContent() {
const user = await fetchUserData();
return (
<div style={{ border: '1px solid #ccc', padding: '1em', marginTop: '1em' }}>
<h2>Your Dynamic Content</h2>
<p>Hello, {user.name}!</p>
<p>Here are your items: {user.items.join(', ')}</p>
</div>
);
}Code Example 1: Basic PPR Setup
Let's set up a simple Next.js page that demonstrates PPR with a static header and footer, and a dynamic main content area.
First, enable PPR in next.config.js as shown above.
Then, create the following files:
// app/page.js
import { Suspense } from 'react';
import { Header } from '../components/Header';
import { Footer } from '../components/Footer';
import { ProductList } from '../components/ProductList';
import { SkeletonProductList } from '../components/SkeletonProductList';
export default function HomePage() {
return (
<div className="container">
<Header />
<main>
<h1>Featured Products</h1>
<p>Browse our latest and greatest items.</p>
{/* This is our dynamic section, wrapped in Suspense */}
<Suspense fallback={<SkeletonProductList />}>
<ProductList />
</Suspense>
</main>
<Footer />
</div>
);
}// components/Header.js (Server Component)
export function Header() {
return (
<header style={{ background: '#f0f0f0', padding: '1em', borderBottom: '1px solid #eee' }}>
<h1>My Awesome Store</h1>
<nav>
<a href="#">Home</a> | <a href="#">Products</a> | <a href="#">Contact</a>
</nav>
</header>
);
}// components/Footer.js (Server Component)
export function Footer() {
return (
<footer style={{ background: '#f0f0f0', padding: '1em', borderTop: '1px solid #eee', marginTop: '2em' }}>
<p>© 2023 My Awesome Store. All rights reserved.</p>
</footer>
);
}// components/ProductList.js (Server Component - fetches data dynamically)
async function fetchProducts() {
// Simulate a slow API call
await new Promise(resolve => setTimeout(resolve, 3000));
return [
{ id: 1, name: 'Laptop Pro', price: 1200 },
{ id: 2, name: 'Gaming Mouse', price: 75 },
{ id: 3, name: 'Mechanical Keyboard', price: 150 },
];
}
export async function ProductList() {
const products = await fetchProducts();
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '1em' }}>
{products.map(product => (
<div key={product.id} style={{ border: '1px solid #ddd', padding: '1em', borderRadius: '5px' }}>
<h3>{product.name}</h3>
<p>Price: ${product.price}</p>
<button>Add to Cart</button>
</div>
))}
</div>
);
}// components/SkeletonProductList.js (Server Component - fallback UI)
export function SkeletonProductList() {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '1em' }}>
{[1, 2, 3].map(i => (
<div key={i} style={{ border: '1px solid #eee', padding: '1em', borderRadius: '5px', background: '#f9f9f9' }}>
<div style={{ height: '1.2em', background: '#eee', marginBottom: '0.5em', width: '70%' }}></div>
<div style={{ height: '1em', background: '#eee', marginBottom: '1em', width: '50%' }}></div>
<div style={{ height: '2em', background: '#eee', width: '100px' }}></div>
</div>
))}
</div>
);
}When you run this, you'll immediately see the header, footer, and "Featured Products" title. The SkeletonProductList will appear for 3 seconds, after which the actual ProductList will seamlessly stream in and replace it, without the page visibly reloading.
Code Example 2: Data Fetching with PPR for User-Specific Content
This example demonstrates a product detail page where the main product information is static (or fetched quickly), but user-specific reviews or recommendations are streamed in.
// app/products/[slug]/page.js (Server Component)
import { Suspense } from 'react';
import { ProductDetails } from '../../../components/ProductDetails';
import { UserReviews } from '../../../components/UserReviews';
import { RelatedProducts } from '../../../components/RelatedProducts';
import { SkeletonReviews } from '../../../components/SkeletonReviews';
import { SkeletonRelatedProducts } from '../../../components/SkeletonRelatedProducts';
// This page is a Server Component, meaning it runs on the server.
// The `slug` param is passed from the URL.
export default async function ProductPage({ params }) {
const { slug } = params;
// Assume ProductDetails fetches its own data quickly or from cache
// This part will be part of the static shell
return (
<div className="product-page">
<ProductDetails slug={slug} />
<section style={{ marginTop: '2em', borderTop: '1px solid #eee', paddingTop: '2em' }}>
<h2>Customer Reviews</h2>
<Suspense fallback={<SkeletonReviews />}>
{/* UserReviews might fetch personalized data or take longer */}
<UserReviews productId={slug} />
</Suspense>
</section>
<section style={{ marginTop: '2em', borderTop: '1px solid #eee', paddingTop: '2em' }}>
<h2>Related Products</h2>
<Suspense fallback={<SkeletonRelatedProducts />}>
{/* RelatedProducts might involve complex recommendations logic */}
<RelatedProducts currentProductId={slug} />
</Suspense>
</section>
</div>
);
}// components/ProductDetails.js (Server Component - basic info)
export async function ProductDetails({ slug }) {
// Simulate fetching core product data (fast or cached)
const product = await new Promise(resolve => setTimeout(() => {
resolve({ id: slug, name: `Product ${slug.toUpperCase()}`, description: `Detailed description for ${slug}. This is static content.` });
}, 500));
return (
<div style={{ marginBottom: '2em' }}>
<h1>{product.name}</h1>
<p>{product.description}</p>
<img src={`/images/${slug}.jpg`} alt={product.name} style={{ maxWidth: '100%', height: 'auto' }} />
</div>
);
}// components/UserReviews.js (Server Component - dynamic, potentially slow)
export async function UserReviews({ productId }) {
// Simulate fetching personalized user reviews (e.g., from a slow API)
await new Promise(resolve => setTimeout(resolve, 4000));
const reviews = [
{ id: 1, user: 'Alice', rating: 5, comment: `Amazing product ${productId}!` },
{ id: 2, user: 'Bob', rating: 4, comment: `Good value for ${productId}.` },
];
return (
<div>
{reviews.map(review => (
<div key={review.id} style={{ border: '1px solid #f0f0f0', padding: '1em', marginBottom: '1em' }}>
<p><strong>{review.user}</strong> - Rating: {review.rating}/5</p>
<p>{review.comment}</p>
</div>
))}
</div>
);
}// components/RelatedProducts.js (Server Component - dynamic, potentially slow)
export async function RelatedProducts({ currentProductId }) {
// Simulate fetching complex related products logic
await new Promise(resolve => setTimeout(resolve, 2500));
const related = [
{ id: 'item-x', name: 'Related Item X' },
{ id: 'item-y', name: 'Related Item Y' },
];
return (
<ul>
{related.map(item => (
<li key={item.id}><a href={`/products/${item.id}`}>{item.name}</a></li>
))}
</ul>
);
}// components/SkeletonReviews.js (Fallback for reviews)
export function SkeletonReviews() {
return (
<div style={{ background: '#f9f9f9', padding: '1em', borderRadius: '5px' }}>
<div style={{ height: '1em', background: '#eee', width: '60%', marginBottom: '0.5em' }}></div>
<div style={{ height: '2em', background: '#eee', width: '100%', marginBottom: '1em' }}></div>
<div style={{ height: '1em', background: '#eee', width: '60%', marginBottom: '0.5em' }}></div>
<div style={{ height: '2em', background: '#eee', width: '100%' }}></div>
</div>
);
}// components/SkeletonRelatedProducts.js (Fallback for related products)
export function SkeletonRelatedProducts() {
return (
<ul>
{[1, 2].map(i => (
<li key={i} style={{ height: '1.2em', background: '#eee', marginBottom: '0.5em', width: '80%' }}></li>
))}
</ul>
);
}In this setup, the main ProductDetails will load quickly. The UserReviews and RelatedProducts sections will initially display their respective skeletons and then stream in their full content as soon as their data is ready, independently.
Real-world Use Cases for PPR
PPR shines in scenarios where a significant portion of a page is static or generally consistent, but specific sections require dynamic, personalized, or frequently updated content.
- E-commerce Product Pages: The product name, description, images, and basic specifications can be part of the static shell. Dynamic elements like personalized recommendations ("Customers also bought..."), real-time stock availability, user reviews, or a live Q&A section can be streamed in.
- News and Blog Articles: The article's main content, author, and publish date form the static shell. Dynamic components like comment sections, related articles (based on user behavior), live popularity metrics, or personalized ad placements can be streamed.
- User Dashboards/Portals: A user's dashboard might have a static layout (navigation, overall structure). Individual widgets displaying personalized data (e.g., recent activity, analytics charts, notification feeds, account balance) can be streamed independently as they fetch their respective data.
- Event Listings/Ticketing: The event name, date, venue, and static description can be immediate. Real-time ticket availability, user-specific pricing, or social proof (e.g., "X people are viewing this event") can be streamed.
- Marketing Landing Pages: Core sales copy, images, and brand elements are static. Dynamic elements might include A/B tested personalized calls-to-action, user testimonials loaded from an external service, or dynamic pricing based on geo-location.
In all these cases, PPR ensures the user gets a usable page instantly, improving engagement and reducing bounce rates, even if some dynamic content takes longer to load.
Best Practices for PPR
To effectively leverage PPR, consider these best practices:
- Granular
SuspenseBoundaries: Don't wrap your entire dynamic section in one largeSuspense. Break down your page into smaller, logical units. This allows independent streaming and reduces the impact of a slow component on others. For instance, on an e-commerce page,UserReviewsandRelatedProductsshould be in separateSuspenseboundaries. - Prioritize Static Content: Ensure that the most critical content for user understanding and SEO is part of the static shell. This content should load instantly and not depend on any asynchronous operations within a
Suspenseboundary. - Meaningful Loading States (
fallback): Thefallbackprop inSuspenseis crucial. Provide clear, non-disruptive skeleton UIs or loading indicators that match your brand. Avoid abrupt layout shifts when the dynamic content replaces the fallback. - Leverage Server Components for Data Fetching: For components inside
Suspensethat fetch data, make them Server Components. This keeps data fetching on the server, closer to your data source, and prevents client-side waterfalls, allowing their HTML to be streamed directly. - Optimize Network Requests: While PPR helps with perceived performance, optimizing the underlying data fetches for your dynamic components is still vital. Use caching, batching, and efficient API designs.
- Test and Monitor Performance: Use Next.js's built-in performance tools, Lighthouse, and real user monitoring (RUM) to measure the impact of PPR. Pay attention to FCP, LCP, and Interaction to Next Paint (INP).
- Consider Client Components for Interactivity: If a dynamic section requires client-side interactivity (e.g., a complex chart with zoom, an interactive map), it should be a Client Component. However, the data for that Client Component can still be fetched by a Server Component parent wrapped in
Suspense.
Common Pitfalls and How to Avoid Them
While powerful, PPR has its nuances. Be aware of these common pitfalls:
- Over-suspending: Wrapping every tiny piece of dynamic content in its own
Suspenseboundary can lead to increased overhead. Each boundary has a slight performance cost. Group related dynamic elements into logicalSuspenseboundaries. - Misunderstanding Server vs. Client Components: Placing a
use clientcomponent that performs its own slow data fetching directly inside aSuspenseboundary, without a Server Component parent handling the data, can negate some PPR benefits. The static shell might load, but the client-side hydration for that component could still be slow. - Inadequate Fallback UI: A poor
fallback(e.g., an empty div or just a spinner) can lead to a jarring user experience or significant layout shifts. Invest time in creating good skeleton UIs. - SEO Concerns with Dynamic Content: While modern search engines can execute JavaScript, ensuring that the critical content is available in the initial static HTML response is paramount. Don't hide essential information solely behind a slow
Suspenseboundary if it's crucial for indexing. - Complex Hydration Issues: If your dynamic components have complex client-side state or effects, ensure they are designed to hydrate gracefully. Test thoroughly for flickering or unexpected behavior during the transition from fallback to full content.
- Ignoring Server Component
asyncBoundaries: Remember thatawaitcalls in Server Components automatically createSuspenseboundaries if not explicitly wrapped. Be mindful of where yourawaits are and what the resulting fallback will be.
Advanced Considerations
- Caching Strategies: PPR works beautifully with caching. The static shell can be cached aggressively at the CDN level. The dynamic parts, being streamed, can have their own, potentially shorter, caching directives. Vercel's Edge Network is particularly optimized for this.
- Error Handling: React's
Error Boundariescan be used in conjunction withSuspenseto gracefully handle errors within streamed components. Wrap yourSuspenseboundary with anError Boundaryto catch rendering or data-fetching errors in the dynamic content. - Data Fetching Libraries: PPR integrates well with data fetching libraries like SWR or React Query, especially when they are used in Client Components that are children of a Server Component parent. For Server Components, direct
async/awaitis often the most straightforward approach. - A/B Testing: PPR makes it easier to A/B test dynamic sections of your page without compromising the core page load speed. Different variations of a component can be streamed to different user segments.
Conclusion
Partial Prerendering in Next.js 15 represents a significant leap forward in web performance and developer experience. By enabling the seamless combination of static speed with dynamic interactivity, PPR empowers developers to build applications that are both incredibly fast and highly personalized.
Understanding the interplay between static shells, Suspense boundaries, and Server Components is key to unlocking PPR's full potential. As you embark on implementing PPR, remember to prioritize critical content for the static shell, design meaningful loading states, and leverage Server Components for efficient data fetching.
The web is moving towards more dynamic, personalized experiences without sacrificing speed. Next.js 15's PPR is a powerful tool in this evolution, allowing you to deliver state-of-the-art applications that delight users from the very first paint. Start experimenting with PPR today and witness the transformation in your application's performance and user experience.
For further reading and the latest updates, always refer to the official Next.js documentation.

Written by
CodewithYohaFull-Stack Software Engineer with 7+ years of experience in Java, Spring Boot, and cloud architecture across AWS, Azure, and GCP. Writing production-grade engineering patterns for developers who ship real software.

