How to Implement Partial Prerendering in Next.js 15 with React Server Components

Partial Prerendering in Next.js 15: A Complete Guide

· 6 min read

Partial Prerendering (PPR) represents a significant evolution in Next.js rendering strategies, combining static and dynamic content within the same page. As of Next.js 15, PPR allows developers to define static shells with dynamic holes that stream in at runtime, offering an optimal balance between performance and interactivity.

Understanding Partial Prerendering

PPR works by identifying which parts of a page can be statically generated at build time and which require runtime data. The framework generates a static HTML shell containing layout, navigation, and other unchanging elements, while marking dynamic sections with Suspense boundaries. When a request arrives, Next.js immediately serves the static shell and streams dynamic content as it resolves.

This approach differs fundamentally from traditional SSR or SSG. Rather than choosing between fully static or fully dynamic rendering for an entire route, PPR enables granular control at the component level. The static portions benefit from edge caching and instant delivery, while dynamic sections maintain real-time data without blocking the initial paint.

Enabling Partial Prerendering

Next.js 15 requires explicit opt-in for PPR. The feature can be enabled at the application level through next.config.js:

1// next.config.js
2const nextConfig = {
3  experimental: {
4    ppr: 'incremental',
5  },
6}
7
8module.exports = nextConfig

The 'incremental' value allows selective adoption per route, while true would enable PPR across the entire application. For production deployments, incremental adoption provides safer rollout with the ability to test specific routes first.

Individual routes can opt into PPR by exporting a configuration constant:

1// app/dashboard/page.tsx
2export const experimental_ppr = true
3
4export default function DashboardPage() {
5  return (
6    <div>
7      {/* Page content */}
8    </div>
9  )
10}

Implementing Static Shells with Dynamic Sections

The core pattern involves wrapping dynamic components with React Suspense boundaries. Next.js analyzes these boundaries during build time to determine what should be prerendered versus what requires runtime execution.

Consider an e-commerce product page that displays static product information alongside dynamic inventory status and personalized recommendations:

1// app/products/[id]/page.tsx
2import { Suspense } from 'react'
3import { getProduct } from '@/lib/data'
4import { ProductDetails } from '@/components/ProductDetails'
5import { InventoryStatus } from '@/components/InventoryStatus'
6import { Recommendations } from '@/components/Recommendations'
7
8export const experimental_ppr = true
9
10export default async function ProductPage({ 
11  params 
12}: { 
13  params: { id: string } 
14}) {
15  // This runs at build time for PPR
16  const product = await getProduct(params.id)
17
18  return (
19    <div className="product-container">
20      {/* Static shell - prerendered */}
21      <ProductDetails product={product} />
22      
23      {/* Dynamic hole - streams at runtime */}
24      <Suspense fallback={<InventoryStatusSkeleton />}>
25        <InventoryStatus productId={params.id} />
26      </Suspense>
27
28      {/* Another dynamic section */}
29      <Suspense fallback={<RecommendationsSkeleton />}>
30        <Recommendations userId={undefined} productId={params.id} />
31      </Suspense>
32    </div>
33  )
34}

The ProductDetails component renders statically because it depends only on the product data fetched during prerendering. The inventory and recommendations components stream in after the initial response, as they require real-time data or user-specific information.

Handling Authentication and User Context

PPR becomes particularly valuable when dealing with authenticated experiences. The static shell can include public navigation and layout, while user-specific content streams in based on session data:

1// app/profile/page.tsx
2import { Suspense } from 'react'
3import { cookies } from 'next/headers'
4import { ProfileHeader } from '@/components/ProfileHeader'
5import { UserActivity } from '@/components/UserActivity'
6import { AccountSettings } from '@/components/AccountSettings'
7
8export const experimental_ppr = true
9
10async function getUserFromSession() {
11  const cookieStore = await cookies()
12  const sessionToken = cookieStore.get('session')?.value
13  // Validate and return user data
14  return validateSession(sessionToken)
15}
16
17export default async function ProfilePage() {
18  return (
19    <div>
20      {/* Static navigation and layout */}
21      <nav>
22        <ProfileHeader />
23      </nav>
24
25      <main>
26        <Suspense fallback={<div>Loading activity...</div>}>
27          <UserActivityWrapper />
28        </Suspense>
29
30        <Suspense fallback={<div>Loading settings...</div>}>
31          <AccountSettingsWrapper />
32        </Suspense>
33      </main>
34    </div>
35  )
36}
37
38async function UserActivityWrapper() {
39  const user = await getUserFromSession()
40  return <UserActivity userId={user.id} />
41}
42
43async function AccountSettingsWrapper() {
44  const user = await getUserFromSession()
45  return <AccountSettings userId={user.id} />
46}

Advertisement

This pattern ensures the page shell delivers instantly while personalized content populates progressively. The wrapper components handle authentication checks within Suspense boundaries, preventing the entire page from blocking on session validation.

Optimizing Data Fetching Patterns

PPR works best when data fetching aligns with rendering boundaries. Components should fetch their own data rather than passing props through multiple layers, enabling Next.js to accurately determine what can be prerendered.

Avoid this pattern:

1// Anti-pattern: prop drilling prevents optimal PPR
2async function ParentComponent() {
3  const userData = await fetchUser()
4  const orders = await fetchOrders(userData.id)
5  
6  return (
7    <Suspense fallback={<div>Loading...</div>}>
8      <ChildComponent orders={orders} />
9    </Suspense>
10  )
11}

Instead, colocate data fetching with consumption:

1// Better: each component fetches its own data
2async function ParentComponent() {
3  return (
4    <div>
5      <StaticHeader />
6      <Suspense fallback={<div>Loading orders...</div>}>
7        <OrdersList />
8      </Suspense>
9    </div>
10  )
11}
12
13async function OrdersList() {
14  const orders = await fetchOrders()
15  return (
16    <ul>
17      {orders.map(order => (
18        <li key={order.id}>{order.title}</li>
19      ))}
20    </ul>
21  )
22}

This approach gives Next.js clear signals about what depends on runtime data. The framework can prerender ParentComponent and StaticHeader while deferring OrdersList execution until request time.

Cache Configuration and Revalidation

PPR interacts with Next.js caching mechanisms to control how long static shells remain valid. The revalidate export defines the revalidation period for prerendered content:

1// app/blog/[slug]/page.tsx
2export const experimental_ppr = true
3export const revalidate = 3600 // Revalidate static shell every hour
4
5export default async function BlogPost({ 
6  params 
7}: { 
8  params: { slug: string } 
9}) {
10  const post = await getPost(params.slug)
11  
12  return (
13    <article>
14      {/* Static content: post body, author, publication date */}
15      <BlogContent post={post} />
16      
17      {/* Dynamic: view count, comments, related posts */}
18      <Suspense fallback={<CommentsSkeleton />}>
19        <Comments postId={post.id} />
20      </Suspense>
21    </article>
22  )
23}

The static shell (post content) regenerates hourly via ISR, while comments stream fresh on every request. This combination ensures blog posts load instantly with up-to-date engagement metrics.

Dynamic sections can implement their own caching strategies using fetch with cache options or React's cache function for deduplication across components.

Trade-offs and Considerations

PPR introduces complexity in reasoning about what renders when. Developers must understand Suspense boundaries and their impact on build output. Over-suspending creates many small chunks that increase overhead, while under-suspending forces more content into dynamic rendering.

The approach works best for pages with clear static/dynamic divisions. Highly interactive applications where most content depends on user state may not benefit significantly. Similarly, fully static marketing sites gain little from PPR since they lack dynamic sections.

Build times increase with PPR enabled, as Next.js must analyze component trees and generate static shells. For applications with thousands of routes, incremental adoption helps manage build performance.

Edge runtime compatibility requires careful attention. Components using Node.js-specific APIs must run in the Node.js runtime, limiting edge deployment benefits. The static shell can still be edge-cached, but dynamic sections execute closer to the origin.

PPR represents a sophisticated rendering model that requires understanding React Server Components, Suspense, and Next.js caching. Teams should validate that the performance gains justify the architectural complexity for their specific use case. When applied appropriately, PPR delivers exceptional user experiences by combining instant static shells with real-time dynamic content.

Advertisement

Share this page

Article by Marcus Rodriguez

Full-stack developer specializing in Next.js and modern React patterns. Creator of several open-source React libraries with over 50k downloads.

Related Content

Continue learning with these related articles