Close-up of JavaScript code on a laptop screen, showcasing programming in progress.

Next.js 15 Partial Prerendering: A Performance Guide

· 6 min read

Partial Prerendering (PPR) in Next.js 15 represents a significant shift in how applications can balance static and dynamic content. This feature allows developers to serve static shell content immediately while streaming dynamic portions as they become available, dramatically improving perceived performance and Core Web Vitals.

Understanding Partial Prerendering

PPR combines the benefits of static site generation with the flexibility of server-side rendering. The framework prerenders the static portions of a page at build time while deferring dynamic content until request time. This approach eliminates the traditional choice between fully static or fully dynamic pages.

The key advantage lies in how browsers handle this content. Users see a meaningful layout almost instantly—navigation, headers, footers, and skeleton screens—while dynamic data loads in parallel. This creates a significantly better user experience compared to waiting for an entire server-rendered page.

Next.js 15 implements PPR through React's Suspense boundaries. Any component wrapped in Suspense becomes a candidate for dynamic rendering, while everything outside those boundaries gets prerendered statically.

Enabling PPR in Next.js 15

PPR requires explicit opt-in at the application or route level. The feature remains experimental in Next.js 15, so configuration happens through the next.config.js file:

1/** @type {import('next').NextConfig} */
2const nextConfig = {
3  experimental: {
4    ppr: true,
5  },
6}
7
8module.exports = nextConfig

For more granular control, PPR can be enabled per layout or page using route segment config:

1// app/dashboard/layout.tsx
2export const experimental_ppr = true
3
4export default function DashboardLayout({
5  children,
6}: {
7  children: React.ReactNode
8}) {
9  return (
10    <div className="dashboard-container">
11      {children}
12    </div>
13  )
14}

This route-level configuration proves useful when migrating existing applications incrementally or when certain routes benefit more from PPR than others.

Implementing PPR with Suspense Boundaries

The implementation centers on strategic placement of Suspense boundaries. Components that fetch dynamic data or require user-specific information should be wrapped in Suspense, while static content remains outside.

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

1// app/products/[id]/page.tsx
2import { Suspense } from 'react'
3import { ProductDetails } from '@/components/ProductDetails'
4import { InventoryStatus } from '@/components/InventoryStatus'
5import { Recommendations } from '@/components/Recommendations'
6import { ProductSkeleton, InventorySkeleton } from '@/components/Skeletons'
7
8export const experimental_ppr = true
9
10export default async function ProductPage({ 
11  params 
12}: { 
13  params: { id: string } 
14}) {
15  // This data gets prerendered at build time
16  const product = await getStaticProductData(params.id)
17
18  return (
19    <div className="product-page">
20      {/* Static content - prerendered */}
21      <ProductDetails product={product} />
22      
23      {/* Dynamic content - streamed at request time */}
24      <Suspense fallback={<InventorySkeleton />}>
25        <InventoryStatus productId={params.id} />
26      </Suspense>
27
28      <Suspense fallback={<div>Loading recommendations...</div>}>
29        <Recommendations productId={params.id} />
30      </Suspense>
31    </div>
32  )
33}

The static product details render immediately from the prerendered shell, while inventory and recommendations stream in as they're fetched. This pattern works particularly well for content that changes frequently or requires authentication.

Optimizing Data Fetching for PPR

Advertisement

PPR's effectiveness depends heavily on how data fetching is structured. Components inside Suspense boundaries should use async server components with appropriate caching strategies:

1// components/InventoryStatus.tsx
2import { unstable_cache } from 'next/cache'
3
4// Cache inventory checks for 30 seconds
5const getCachedInventory = unstable_cache(
6  async (productId: string) => {
7    const response = await fetch(
8      `https://api.example.com/inventory/${productId}`,
9      { next: { revalidate: 30 } }
10    )
11    return response.json()
12  },
13  ['inventory'],
14  { revalidate: 30, tags: ['inventory'] }
15)
16
17export async function InventoryStatus({ 
18  productId 
19}: { 
20  productId: string 
21}) {
22  const inventory = await getCachedInventory(productId)
23  
24  return (
25    <div className="inventory-status">
26      {inventory.inStock ? (
27        <span className="in-stock">In Stock ({inventory.quantity} available)</span>
28      ) : (
29        <span className="out-of-stock">Out of Stock</span>
30      )}
31    </div>
32  )
33}

The unstable_cache wrapper provides fine-grained control over caching behavior. Short revalidation periods work well for frequently changing data like inventory, while longer periods suit more stable content.

Handling Authentication and User-Specific Content

PPR excels at serving personalized content without sacrificing initial load performance. The static shell loads immediately while user-specific data streams in:

1// app/dashboard/page.tsx
2import { Suspense } from 'react'
3import { auth } from '@/lib/auth'
4import { UserProfile } from '@/components/UserProfile'
5import { RecentActivity } from '@/components/RecentActivity'
6import { StaticDashboardShell } from '@/components/StaticDashboardShell'
7
8export const experimental_ppr = true
9
10export default async function DashboardPage() {
11  return (
12    <>
13      {/* Static navigation and layout - prerendered */}
14      <StaticDashboardShell />
15      
16      <div className="dashboard-content">
17        <Suspense fallback={<ProfileSkeleton />}>
18          <UserProfile />
19        </Suspense>
20
21        <Suspense fallback={<ActivitySkeleton />}>
22          <RecentActivity />
23        </Suspense>
24      </div>
25    </>
26  )
27}
28
29// components/UserProfile.tsx
30async function UserProfile() {
31  const session = await auth()
32  
33  if (!session) {
34    redirect('/login')
35  }
36
37  const userData = await fetch(
38    `https://api.example.com/users/${session.user.id}`,
39    { cache: 'no-store' }
40  )
41
42  return (
43    <div className="user-profile">
44      {/* Render user-specific content */}
45    </div>
46  )
47}

Authentication checks happen inside the Suspense boundary, ensuring the static shell renders even before authentication completes. This prevents the entire page from being blocked by auth lookups.

Trade-offs and Considerations

PPR introduces complexity that doesn't benefit every application. Static sites with minimal dynamic content gain little from PPR overhead. The feature shines when pages mix substantial static content with dynamic elements that vary by user or change frequently.

Server resource usage increases compared to pure static generation since dynamic portions render on each request. Applications with high traffic should monitor server capacity and consider implementing aggressive caching strategies for dynamic components.

Edge cases around Suspense boundaries require careful handling. Nested Suspense boundaries work correctly, but deeply nested structures can create waterfall effects where each boundary waits for its parent. Flat Suspense hierarchies generally perform better.

The experimental status means breaking changes may occur in future Next.js releases. Production applications should thoroughly test PPR behavior and maintain fallback strategies. The Next.js team has indicated PPR will stabilize in upcoming versions, but current implementations should account for potential API changes.

Client-side JavaScript bundle size doesn't decrease with PPR—the framework still ships the full React runtime. PPR optimizes initial HTML delivery and time-to-interactive, not overall payload size. Applications concerned with bundle size need additional optimization strategies.

Measuring PPR Impact

Quantifying PPR's benefits requires monitoring specific metrics. Largest Contentful Paint (LCP) typically improves since static content renders immediately. Time to First Byte (TTFB) may increase slightly due to streaming overhead, but this trades off favorably against improved LCP.

Synthetic testing tools like Lighthouse capture these improvements, but real user monitoring provides more accurate data. The Next.js Speed Insights package integrates with Vercel's analytics to track Core Web Vitals across actual user sessions.

PPR works best when static shells contain meaningful content rather than empty containers. A navigation bar, page title, and content skeleton provide value even before dynamic data arrives. Empty divs waiting for content offer no perceptual benefit.

The feature represents a pragmatic middle ground between static and dynamic rendering. Applications with mixed content requirements no longer need to choose between fast initial loads and dynamic capabilities—PPR delivers both when implemented thoughtfully.

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