Next.js course Β· Module 5: Rendering Strategies

Partial Prerendering (PPR)

13 min read
In this lesson11

In previous modules, we discussed various rendering strategies in Next.js, including data streaming and implementing loading states. In this module, we will focus on one of the newest and most exciting Next.js features - Partial Prerendering (PPR), which combines the advantages of static generation and dynamic rendering in a single hybrid approach.

What Is Partial Prerendering?

Partial Prerendering (PPR) is a rendering pattern introduced in Next.js 14 that allows statically generating parts of a page during the build, while other parts can be dynamically rendered during the request.

Simply put, PPR allows for:

  1. Instant rendering of static parts of the page (e.g., layout, template, headers)
  2. Deferred rendering of dynamic parts that require fresh data

Such a hybrid architecture combines the best of both worlds: the speed of static generation with the freshness of dynamic rendering.

Problems That Partial Prerendering Solves

Before PPR was introduced, developers had to choose between several approaches, each with its own limitations:

  1. Static Site Generation (SSG) - fast content delivery, but problems with data freshness
  2. Server-Side Rendering (SSR) - always fresh data, but slower TTFB (Time To First Byte)
  3. Incremental Static Regeneration (ISR) - a compromise between SSG and SSR, but with time-based limitations
  4. Client-Side Rendering (CSR) - interactivity at the cost of SEO performance

PPR aims to solve these problems by introducing a new hybrid model where these approaches can coexist on a single page in a more integrated way.

How Does Partial Prerendering Work?

PPR introduces a new rendering unit - "holes" or "slots" that are placed in statically generated content. These holes are later filled with dynamically rendered content on the client side.

This process can be divided into four stages:

  1. Static prerendering - during the build, Next.js generates a static HTML Shell with "holes" for dynamic parts
  2. Instant response - when a user requests the page, the server immediately responds with the prerendered Shell content
  3. Dynamic filling - "holes" are asynchronously filled with dynamic data on the server
  4. Content streaming - dynamic content is streamed to the browser as it's rendered

Implementing Partial Prerendering in Next.js

1. PPR Configuration in Next.js

In Next.js 16, PPR is part of Cache Components, and you enable it with a single option in the next.config.ts file:

1// next.config.ts
2import type { NextConfig } from 'next';
3
4const nextConfig: NextConfig = {
5  cacheComponents: true,
6};
7
8export default nextConfig;

With this option on, PPR works on every route: everything that can be rendered at build time goes into the static shell, including data from functions marked with the 'use cache' directive, while dynamic parts wrapped in <Suspense> stream in at request time. In Next.js 14 and 15, PPR was experimental (in Next.js 15 only in canary releases) and was enabled with the experimental.ppr flag, which Next.js 16 removed:

1// next.config.js - Next.js 14-15, this flag no longer works in Next.js 16
2module.exports = {
3  experimental: {
4    ppr: true
5  }
6}

In those versions you could also enable PPR only for selected routes with the experimental_ppr export (with ppr: 'incremental'). Next.js 16 removed this export as well, and with cacheComponents enabled, segment options such as dynamic, dynamicParams, revalidate and fetchCache are no longer available:

1// Next.js 15 canary (removed in Next.js 16)
2export const experimental_ppr = true;

2. Basic PPR Example

Here is an example of PPR implementation in Next.js 16 (with cacheComponents enabled):

1// app/products/[category]/page.tsx
2import { Suspense } from 'react';
3import { cacheLife } from 'next/cache';
4import { CategoryHeader } from '@/components/category-header';
5import { ProductGrid } from '@/components/product-grid';
6import { ProductFilters } from '@/components/product-filters';
7import { LoadingSkeleton } from '@/components/loading-skeleton';
8
9// Static parameters for prerendered pages
10export async function generateStaticParams() {
11  const categories = await fetchTopCategories();
12  return categories.map(category => ({ category: category.slug }));
13}
14
15// Static data known at build time
16async function getCategoryData(categorySlug: string) {
17  'use cache';
18  cacheLife('days');
19
20  // Fetch basic category data that rarely changes.
21  // Thanks to 'use cache' this data is fetched during the build
22  // and becomes part of the static Shell
23  const data = await fetch(`https://api.example.com/categories/${categorySlug}`);
24
25  return data.json();
26}
27
28export default async function CategoryPage({
29  params,
30  searchParams,
31}: {
32  params: Promise<{ category: string }>;
33  searchParams: Promise<Record<string, string>>;
34}) {
35  // Fetch static category data (rendered during build)
36  const categoryData = await getCategoryData((await params).category);
37
38  return (
39    <div className="container mx-auto py-8">
40      {/* Statically rendered header - part of the Shell */}
41      <CategoryHeader category={categoryData} />
42
43      <div className="grid grid-cols-4 gap-6 mt-8">
44        <div className="col-span-1">
45          {/* Dynamically rendered filters with session-dependent data */}
46          <Suspense fallback={<LoadingSkeleton type="filters" />}>
47            <ProductFilters categorySlug={(await params).category} />
48          </Suspense>
49        </div>
50
51        <div className="col-span-3">
52          {/* Dynamically rendered product list with fresh data */}
53          <Suspense fallback={<LoadingSkeleton type="products" />}>
54            <ProductGrid categorySlug={(await params).category} searchParams={searchParams} />
55          </Suspense>
56        </div>
57      </div>
58    </div>
59  );
60}

In the ProductFilters and ProductGrid components, we will use the cache: 'no-store' option to ensure that data is always fresh:

1// components/product-grid.tsx
2async function getProducts(categorySlug: string, filterParams?: Record<string, string>) {
3  // Create URL with filter parameters
4  const queryParams = new URLSearchParams(filterParams);
5  const url = `https://api.example.com/products?category=${categorySlug}&${queryParams}`;
6
7  // Use no-store option to always fetch fresh data
8  const res = await fetch(url, { cache: 'no-store' });
9
10  if (!res.ok) {
11    throw new Error('Failed to fetch products');
12  }
13
14  return res.json();
15}
16
17export async function ProductGrid({
18  categorySlug,
19  searchParams,
20}: {
21  categorySlug: string;
22  searchParams: Promise<Record<string, string>>;
23}) {
24  // We read the URL filter parameters only here, inside <Suspense>
25  const filterParams = await searchParams;
26
27  // Fetch products with filters applied
28  const products = await getProducts(categorySlug, filterParams);
29
30  return (
31    <div className="grid grid-cols-3 gap-4">
32      {products.map(product => (
33        <ProductCard key={product.id} product={product} />
34      ))}
35    </div>
36  );
37}

Note that searchParams reaches ProductGrid as a Promise and is read only inside the <Suspense> boundary. This keeps the category header and the loading skeletons in the static shell, and only the product list is dynamic. You cannot use the useSearchParams hook here at all, because it works only in Client Components.

Advanced PPR Patterns

1. Avoiding Data Waterfalls with a Preloader

One of the problems of asynchronous rendering is the "data waterfall," which we can minimize with preloading:

1// app/products/[category]/page.tsx
2import { Suspense } from 'react';
3import { ProductGrid } from './product-grid';
4
5// Preloader function that initiates data loading
6function preloadProducts(categorySlug: string) {
7  // Initiates data fetching but doesn't wait for the result
8  void getProducts(categorySlug);
9}
10
11export default async function CategoryPage({ params }: { params: Promise<{ category: string }> }) {
12  // Start loading data early
13  preloadProducts((await params).category);
14
15  return (
16    <div>
17      {/* Statically prerendered header */}
18      <h1>Category: {(await params).category}</h1>
19
20      {/* Dynamic part */}
21      <Suspense fallback={<div>Loading products...</div>}>
22        <ProductGrid categorySlug={(await params).category} />
23      </Suspense>
24    </div>
25  );
26}
27
28// Here's what the getProducts function looks like in this case
29async function getProducts(categorySlug: string) {
30  // Function with result caching
31  // You can use SWR or React Query for more advanced caching
32  const cacheKey = `products-${categorySlug}`;
33  if (!cache.has(cacheKey)) {
34    const promise = fetch(`https://api.example.com/products?category=${categorySlug}`, {
35      cache: 'no-store'
36    }).then(res => res.json());
37
38    cache.set(cacheKey, promise);
39  }
40
41  return cache.get(cacheKey);
42}

2. Setting Section Loading Priority

We can set the loading priority of different page parts using nested Suspense components:

1// app/dashboard/page.tsx
2import { Suspense } from 'react';
3import { Header } from '@/components/header';
4import { UserWidget } from '@/components/user-widget';
5import { RecentActivity } from '@/components/recent-activity';
6import { PerformanceMetrics } from '@/components/performance-metrics';
7import { Recommendations } from '@/components/recommendations';
8
9import {
10  UserWidgetSkeleton,
11  RecentActivitySkeleton,
12  PerformanceMetricsSkeleton,
13  RecommendationsSkeleton
14} from '@/components/skeletons';
15
16export default function DashboardPage() {
17  return (
18    <div className="container mx-auto py-8">
19      {/* Static part - header */}
20      <Header title="Dashboard" />
21
22      <div className="grid grid-cols-12 gap-6 mt-8">
23        {/* Priority 1: User widget - loaded first */}
24        <div className="col-span-3">
25          <Suspense fallback={<UserWidgetSkeleton />}>
26            <UserWidget />
27          </Suspense>
28        </div>
29
30        <div className="col-span-9">
31          {/* Priority 2: Recent activity - loaded second */}
32          <Suspense fallback={<RecentActivitySkeleton />}>
33            <RecentActivity />
34
35            <div className="grid grid-cols-2 gap-6 mt-6">
36              {/* Priority 3: Performance metrics - loaded third */}
37              <Suspense fallback={<PerformanceMetricsSkeleton />}>
38                <PerformanceMetrics />
39              </Suspense>
40
41              {/* Priority 4: Recommendations - loaded last */}
42              <Suspense fallback={<RecommendationsSkeleton />}>
43                <Recommendations />
44              </Suspense>
45            </div>
46          </Suspense>
47        </div>
48      </div>
49    </div>
50  );
51}

3. Dynamic Parameters with Partial Prerendering

We can combine static generation and dynamic parameters:

1// app/products/[id]/page.tsx
2import { Suspense } from 'react';
3
4// We only statically generate specific product pages
5export async function generateStaticParams() {
6  // Fetch only popular products for prerendering
7  const popularProducts = await fetchPopularProducts();
8
9  return popularProducts.map(product => ({
10    id: product.id.toString()
11  }));
12}
13
14// With dynamicParams: true enabled, products without static generation
15// will be rendered on demand
16export const dynamicParams = true;
17
18export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
19  // This function fetches basic product information
20  // For popular products, this data will be fetched during build
21  // For the rest - during the request
22  const product = await getProductBasicInfo((await params).id);
23
24  if (!product) {
25    return <div>Product not found</div>;
26  }
27
28  return (
29    <div className="product-page">
30      <h1>{product.name}</h1>
31      <div className="product-image">
32        <img src={product.imageUrl} alt={product.name} />
33      </div>
34
35      <div className="product-info">
36        <p className="product-price">${product.price}</p>
37
38        {/* Dynamic part with always up-to-date information */}
39        <Suspense fallback={<div>Checking availability...</div>}>
40          <ProductAvailability productId={(await params).id} />
41        </Suspense>
42
43        <Suspense fallback={<div>Loading delivery options...</div>}>
44          <DeliveryOptions productId={(await params).id} />
45        </Suspense>
46      </div>
47
48      <div className="product-details">
49        {/* Static part - product description */}
50        <div dangerouslySetInnerHTML={{ __html: product.description }} />
51
52        {/* Dynamic part - reviews, frequently updated */}
53        <Suspense fallback={<div>Loading reviews...</div>}>
54          <ProductReviews productId={(await params).id} />
55        </Suspense>
56      </div>
57    </div>
58  );
59}
60
61async function getProductBasicInfo(id: string) {
62  // Fetching basic product information
63  // Can be cached since this information rarely changes
64  const res = await fetch(`https://api.example.com/products/${id}/basic`);
65  if (!res.ok) return null;
66  return res.json();
67}

4. Dynamic Routing with Partial Prerendering

We can also use PPR with dynamic routes, e.g., for advanced filtering:

1// app/products/[[...slug]]/page.tsx
2import { Suspense } from 'react';
3
4// Generate popular filter/category combinations for prerendering
5export async function generateStaticParams() {
6  return [
7    { slug: [] },                          // /products
8    { slug: ['category', 'electronics'] }, // /products/category/electronics
9    { slug: ['deals'] },                   // /products/deals
10    // ... other popular combinations
11  ];
12}
13
14export const dynamicParams = true; // Allows dynamic rendering of other combinations
15
16export default async function ProductsPage({ params }: { params: Promise<{ slug?: string[] }> }) {
17  // Parsing slug parameters
18  const { category, filters } = parseSlug((await params).slug || []);
19
20  // Fetching static metadata
21  const pageMetadata = await getPageMetadata(category);
22
23  return (
24    <div className="container mx-auto py-8">
25      {/* Static part - header and metadata */}
26      <h1>{pageMetadata.title}</h1>
27      <p>{pageMetadata.description}</p>
28
29      <div className="flex mt-8">
30        {/* Dynamic part - filters */}
31        <div className="w-1/4">
32          <Suspense fallback={<div>Loading filters...</div>}>
33            <ProductFilters category={category} activeFilters={filters} />
34          </Suspense>
35        </div>
36
37        {/* Dynamic part - product list */}
38        <div className="w-3/4">
39          <Suspense fallback={<div>Loading products...</div>}>
40            <ProductList category={category} filters={filters} />
41          </Suspense>
42        </div>
43      </div>
44    </div>
45  );
46}
47
48// Helper function for parsing slug parameters
49function parseSlug(slug: string[]) {
50  let category = '';
51  const filters: Record<string, string> = {};
52
53  for (let i = 0; i < slug.length; i += 2) {
54    if (slug[i] === 'category' && slug[i+1]) {
55      category = slug[i+1];
56    } else if (slug[i] && slug[i+1]) {
57      filters[slug[i]] = slug[i+1];
58    }
59  }
60
61  return { category, filters };
62}

Data Caching in PPR

Optimal data caching is crucial for PPR performance. Here are some caching patterns:

1. React-level Caching

1// utils/cache.ts
2export const dataCache = new Map();
3
4export async function getCachedData(key: string, fetcher: () => Promise<any>) {
5  if (!dataCache.has(key)) {
6    try {
7      const promise = fetcher();
8      dataCache.set(key, promise);
9      const data = await promise;
10      return data;
11    } catch (error) {
12      dataCache.delete(key);
13      throw error;
14    }
15  }
16
17  return dataCache.get(key);
18}

Usage:

1// components/product-list.tsx
2import { getCachedData } from '@/utils/cache';
3
4async function getProducts(category: string) {
5  return getCachedData(`products-${category}`, () => {
6    return fetch(`https://api.example.com/products?category=${category}`, {
7      cache: 'no-store'
8    }).then(res => res.json());
9  });
10}

2. Memoization of Data Fetching Functions

1// lib/fetch-client.ts
2import { cache } from 'react';
3
4// Using React memoization to cache query results
5export const fetchAPI = cache(async (url: string, options: RequestInit = {}) => {
6  const res = await fetch(url, options);
7
8  if (!res.ok) {
9    throw new Error(`Failed to fetch ${url}`);
10  }
11
12  return res.json();
13});

Usage:

1// components/product-data.tsx
2import { fetchAPI } from '@/lib/fetch-client';
3
4export async function ProductData({ id }: { id: string }) {
5  // The query will be executed only once per render,
6  // even if the component is used multiple times with the same id
7  const product = await fetchAPI(`https://api.example.com/products/${id}`);
8
9  return (
10    <div className="product-data">
11      <h2>{product.name}</h2>
12      <p>{product.description}</p>
13      <p className="price">${product.price}</p>
14    </div>
15  );
16}

PPR Usage Examples in Different Scenarios

1. E-commerce

PPR is ideal for e-commerce product pages where we can:

  • Prerender static elements: product name, images, description, specifications
  • Dynamically render: stock status, promotional prices, customer reviews

2. Information Portals

For information websites, PPR allows:

  • Prerendering the page template, article headers, and static sections
  • Dynamically rendering personalized recommendations and latest news

3. Application Dashboards

For analytical dashboards:

  • Prerendering the dashboard structure, controls, and user interface
  • Dynamically rendering charts and user-specific analytical data

Debugging and Monitoring PPR

Debugging PPR can be challenging due to the hybrid nature of rendering. Here are some tips:

1. Using React DevTools

React DevTools allows inspecting components and their rendering state.

2. Debugging Static Generation

1NEXT_DEBUG=1 next build

This command shows detailed information about which pages are generated statically.

3. Production Environment Monitoring

Implement performance monitoring tools such as Lighthouse, PageSpeed Insights, or Core Web Vitals monitoring tools to evaluate the impact of PPR on the end-user experience.

Limitations and Considerations

  1. Experimental feature - PPR is still an experimental feature and may change
  2. Supported environments - PPR works best with Node.js and edge runtime
  3. Mental model complexity - the hybrid rendering model can be harder to understand and debug
  4. Optimal usage - PPR is not always the best choice; for simple static pages, SSG may be simpler and more efficient

Best Practices

1. Balancing Static vs. Dynamic Content

Clearly define which data can be rendered statically and which must be dynamic:

  • Static: user interface, constant metadata, rarely updated content
  • Dynamic: personalized data, frequently updated information, user context-dependent data

2. Loading Prioritization

Use nested Suspense to prioritize content loading:

  • Load business-critical data first
  • Less important sections can be loaded later

3. Efficient Cache Management

Carefully manage data caching to avoid unnecessary re-renders:

  • Use fetchAPI with React memoization
  • Apply cache validation using revalidatePath and revalidateTag

4. Appropriate Loading Skeletons

Design loading skeletons that match the dimensions and proportions of the final content to minimize layout shifts.

Summary

Partial Prerendering (PPR) is a powerful Next.js feature that enables combining the performance of static generation with the flexibility of dynamic rendering. It allows creating fast, interactive applications with optimal performance for different content types.

The main benefits of PPR are:

  1. Faster TTFB - static parts are delivered instantly
  2. Better SEO performance - critical content is statically prerendered
  3. Optimal user experience - a stable template minimizes flickering during dynamic content loading
  4. Flexibility - easy combination of different rendering strategies in a single application

Although PPR is still in an experimental phase, it offers an exciting vision of the future of rendering in Next.js, combining the advantages of previously dispersed approaches to generating web content.

In the next module, we will discuss cache management in detail and advanced data revalidation techniques that are crucial for the optimal operation of PPR and other rendering strategies in Next.js.

Code for this lesson: App.tsx
1import React, { useState, useEffect } from 'react';
2
3// Partial Prerendering (PPR) - simulation in Next.js
4// The static part is rendered immediately, the dynamic part is streamed later
5
6function StaticShell() {
7  return (
8    <div style={{ background: '#1a2744', borderRadius: 8, padding: 16, border: '1px solid #64ffda' }}>
9      <h3 style={{ color: '#64ffda', margin: 0 }}>Static shell (prerendered)</h3>
10      <p style={{ color: '#aaa', fontSize: 13, margin: '8px 0 0' }}>
11        Navigation, layout and metadata are available immediately.
12        This part of the page is generated at build time.
13      </p>
14    </div>
15  );
16}
17
18function DynamicSection({ label, delay }: { label: string; delay: number }) {
19  const [data, setData] = useState<string | null>(null);
20
21  useEffect(() => {
22    const t = setTimeout(() => setData(`Dynamic data: ${label} (loaded after ${delay}ms)`), delay);
23    return () => clearTimeout(t);
24  }, [label, delay]);
25
26  if (!data) {
27    return (
28      <div style={{ background: '#0d1117', borderRadius: 8, padding: 16, border: '1px dashed #555' }}>
29        <div style={{ color: '#888', fontSize: 13 }}>Streaming: {label}...</div>
30        <div style={{
31          height: 10, background: 'linear-gradient(90deg, #333, #555, #333)',
32          backgroundSize: '200% 100%', borderRadius: 4, marginTop: 8,
33          animation: 'shimmer 1.5s ease-in-out infinite'
34        }} />
35      </div>
36    );
37  }
38
39  return (
40    <div style={{ background: '#1b5e20', borderRadius: 8, padding: 16, border: '1px solid #4caf50' }}>
41      <div style={{ color: '#4caf50', fontWeight: 'bold', fontSize: 13 }}>DYNAMIC</div>
42      <div style={{ color: '#e0e0e0', marginTop: 4 }}>{data}</div>
43    </div>
44  );
45}
46
47export default function PPRDemo() {
48  const [key, setKey] = useState(0);
49
50  return (
51    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
52      <style>{`@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`}</style>
53      <h1 style={{ color: '#64ffda' }}>Partial Prerendering (PPR)</h1>
54      <p style={{ color: '#888' }}>
55        The static shell loads immediately, dynamic sections stream independently.
56      </p>
57      <button onClick={() => setKey(k => k + 1)} style={{
58        background: '#9c27b0', color: '#fff', border: 'none', borderRadius: 6,
59        padding: '8px 16px', cursor: 'pointer', margin: '12px 0', fontFamily: 'monospace'
60      }}>
61        Simulate reload
62      </button>
63
64      <div key={key} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
65        <StaticShell />
66        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
67          <DynamicSection label="User cart" delay={800} />
68          <DynamicSection label="Recommendations" delay={2000} />
69        </div>
70        <DynamicSection label="Community comments" delay={3000} />
71      </div>
72    </div>
73  );
74}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Streaming in Next.js allows:

  2. 2. Suspense boundaries in React are used for:

Hands-on tasks in the game

  • Code editor

    Create loading.tsx and error.tsx components for graceful handling of loading and error states

  • Vertical ordering

    Arrange the ISR (Incremental Static Regeneration) revalidation stages in correct order:

  • Click in order

    Arrange the generateStaticParams function syntax in Next.js

  • Code editor

    Create an application combining Server Components (header, footer) with Client Components (interactive form)

Useful articles