Partial Prerendering (PPR) represents a significant shift in how Next.js handles rendering strategies, allowing developers to combine static and dynamic content in a single page without choosing between fully static or fully dynamic rendering. Next.js 15 introduces stable support for PPR, leveraging React Suspense boundaries to determine which parts of a page should be prerendered at build time and which should remain dynamic.
Understanding Partial Prerendering Architecture
PPR works by analyzing React Suspense boundaries in the component tree. Content outside Suspense boundaries gets prerendered as static HTML during the build process, while content inside Suspense boundaries remains dynamic and streams to the client at request time. This creates a "shell" of static content that loads instantly, with dynamic sections filling in as they resolve.
The key difference from traditional approaches: PPR doesn't require separate routes or API endpoints for dynamic data. The framework handles the complexity of serving static shells with dynamic holes that get filled server-side during the request.
Enabling Partial Prerendering in Next.js 15
First, enable PPR in the Next.js configuration. This feature requires the experimental flag in version 15.0, though it's expected to become stable in 15.1:
1// next.config.js
2/** @type {import('next').NextConfig} */
3const nextConfig = {
4 experimental: {
5 ppr: true,
6 },
7}
8
9module.exports = nextConfigFor gradual adoption, PPR can be enabled per-route using the route segment config:
1// app/dashboard/page.tsx
2export const experimental_ppr = true
3
4export default function DashboardPage() {
5 return (
6 // page content
7 )
8}This route-level configuration allows teams to test PPR on specific pages while maintaining existing rendering strategies elsewhere.
Implementing Basic PPR with Suspense Boundaries
Consider an e-commerce product page that needs instant SEO-friendly content but also displays personalized recommendations. The product details can be static, while recommendations require user context:
1// app/products/[id]/page.tsx
2import { Suspense } from 'react'
3import { ProductDetails } from '@/components/ProductDetails'
4import { PersonalizedRecommendations } from '@/components/PersonalizedRecommendations'
5import { RecommendationsSkeleton } from '@/components/skeletons'
6
7export const experimental_ppr = true
8
9export default async function ProductPage({
10 params
11}: {
12 params: { id: string }
13}) {
14 return (
15 <div className="product-layout">
16 {/* Static content - prerendered at build time */}
17 <ProductDetails productId={params.id} />
18
19 {/* Dynamic content - rendered at request time */}
20 <Suspense fallback={<RecommendationsSkeleton />}>
21 <PersonalizedRecommendations productId={params.id} />
22 </Suspense>
23 </div>
24 )
25}The ProductDetails component fetches data that can be cached indefinitely, while PersonalizedRecommendations accesses user-specific data like browsing history or authentication state.
Handling Multiple Dynamic Sections
Real applications often have several dynamic sections with different loading characteristics. PPR handles multiple Suspense boundaries independently, allowing each section to stream when ready:
1// app/dashboard/page.tsx
2import { Suspense } from 'react'
3import { Header } from '@/components/Header'
4import { ActivityFeed } from '@/components/ActivityFeed'
5import { UserStats } from '@/components/UserStats'
6import { RecentOrders } from '@/components/RecentOrders'
7
8export const experimental_ppr = true
9
10export default function Dashboard() {
11 return (
12 <div className="dashboard">
13 {/* Static header - prerendered */}
14 <Header />
15
16 <div className="dashboard-grid">
17 {/* Fast dynamic content */}
18 <Suspense fallback={<StatsSkeleton />}>
19 <UserStats />
20 </Suspense>
21
22 {/* Slower dynamic content */}
23 <Suspense fallback={<FeedSkeleton />}>
24 <ActivityFeed />
25 </Suspense>
26
27 {/* Independent dynamic section */}
28 <Suspense fallback={<OrdersSkeleton />}>
29 <RecentOrders />
30 </Suspense>
31 </div>
32 </div>
33 )
34}Each Suspense boundary creates an independent dynamic region. If UserStats resolves quickly while ActivityFeed takes longer, the stats appear first without waiting for the feed. The static shell (Header and layout) loads immediately.
Optimizing Data Fetching for PPR
Advertisement
PPR works best when dynamic components use efficient data fetching patterns. React Server Components allow data fetching directly in components, but caching strategies determine performance:
1// components/PersonalizedRecommendations.tsx
2import { cookies } from 'next/headers'
3import { getRecommendations } from '@/lib/api'
4
5export async function PersonalizedRecommendations({
6 productId
7}: {
8 productId: string
9}) {
10 const cookieStore = cookies()
11 const userId = cookieStore.get('user_id')?.value
12
13 // This fetch happens at request time due to cookies() usage
14 const recommendations = await getRecommendations({
15 productId,
16 userId,
17 // Cache for 60 seconds, revalidate in background
18 next: { revalidate: 60 }
19 })
20
21 return (
22 <div className="recommendations">
23 <h2>Recommended for You</h2>
24 <div className="product-grid">
25 {recommendations.map(product => (
26 <ProductCard key={product.id} product={product} />
27 ))}
28 </div>
29 </div>
30 )
31}The cookies() function marks this component as dynamic, ensuring it renders at request time within its Suspense boundary. The revalidate option allows Next.js to cache the result briefly, reducing database load for subsequent requests from the same user.
Nested Suspense for Granular Control
Complex pages benefit from nested Suspense boundaries, creating multiple layers of progressive enhancement:
1// app/blog/[slug]/page.tsx
2import { Suspense } from 'react'
3
4export const experimental_ppr = true
5
6export default async function BlogPost({
7 params
8}: {
9 params: { slug: string }
10}) {
11 return (
12 <article>
13 {/* Static article content */}
14 <BlogContent slug={params.slug} />
15
16 {/* Comments section with nested dynamic parts */}
17 <section className="comments-section">
18 <h2>Comments</h2>
19
20 <Suspense fallback={<CommentFormSkeleton />}>
21 {/* Dynamic: requires auth state */}
22 <CommentForm />
23 </Suspense>
24
25 <Suspense fallback={<CommentsListSkeleton />}>
26 {/* Nested suspense for comment list */}
27 <CommentsList slug={params.slug}>
28 <Suspense fallback={<span>Loading replies...</span>}>
29 {/* Even deeper nesting for threaded replies */}
30 <CommentReplies />
31 </Suspense>
32 </CommentsList>
33 </Suspense>
34 </section>
35
36 {/* Related posts - static */}
37 <RelatedPosts slug={params.slug} />
38 </article>
39 )
40}This structure allows the article content and related posts to load instantly, while the comment form and list stream in progressively. Nested reply threads can load independently without blocking parent comments.
Trade-offs and Considerations
PPR introduces complexity that may not suit every application. Static sites with minimal dynamic content gain little from PPR overhead, while fully dynamic applications might find the static shell too small to justify the approach.
Build times increase when prerendering many pages with PPR enabled. Each page must generate a static shell, and the build process needs to analyze Suspense boundaries. For applications with thousands of product pages, incremental static regeneration combined with PPR provides better build performance than pure static generation.
Caching strategies become more nuanced with PPR. The static shell caches at the CDN edge indefinitely, but dynamic sections require careful cache configuration to balance freshness and performance. Overly aggressive caching negates PPR benefits, while no caching creates unnecessary database load.
Debugging PPR applications requires understanding which content renders when. Browser DevTools show network waterfalls revealing when dynamic sections stream, but the mental model differs from traditional SSR or CSR patterns. Developers need to think in terms of shells and holes rather than complete page renders.
SEO considerations shift with PPR. Search engine crawlers see the static shell immediately, which includes critical content and metadata. Dynamic sections may or may not be indexed depending on crawler behavior with streaming HTML. For SEO-critical content, keeping it outside Suspense boundaries ensures indexing.
Production Deployment Patterns
PPR works best with edge-deployed applications where the static shell serves from CDN nodes close to users. The dynamic sections then render on nearby serverless functions, minimizing latency for both static and dynamic content.
Monitoring PPR applications requires tracking both static shell cache hits and dynamic section render times. A slow dynamic section doesn't prevent the page from loading, but it creates a poor user experience if skeletons display for extended periods. Setting up alerts for dynamic section P95 latency helps catch performance regressions.
Error boundaries become critical with PPR since dynamic sections can fail independently. Wrapping Suspense boundaries with error boundaries prevents one failing section from breaking the entire page:
1<ErrorBoundary fallback={<RecommendationsError />}>
2 <Suspense fallback={<RecommendationsSkeleton />}>
3 <PersonalizedRecommendations productId={params.id} />
4 </Suspense>
5</ErrorBoundary>This pattern ensures the static product details remain visible even if the recommendations service experiences issues.
PPR represents a pragmatic middle ground between static and dynamic rendering, eliminating the false choice between performance and personalization. Applications with mixed content requirements—static marketing pages with dynamic user sections, product catalogs with personalized recommendations, or blog posts with live comment threads—benefit most from this approach. The key lies in thoughtful Suspense boundary placement that aligns with actual data dependencies and user experience priorities.






