We use cookies to enhance your experience on the site
CodeWorlds

Partial Prerendering (PPR)

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 14 and newer, PPR can be enabled through configuration in the

next.config.js
file:

1// next.config.js
2module.exports = {
3  experimental: {
4    ppr: true
5  }
6}

Alternatively, you can enable PPR for specific pages using directives in the page component:

1export const runtime = 'nodejs';
2export const preferredRegion = 'auto';
3export const dynamic = 'force-dynamic';
4export const dynamicParams = true;
5
6export const generateStaticParams = async () => {
7  //
8}

2. Basic PPR Example

Here is an example of PPR implementation in Next.js 15:

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

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({ categorySlug }: { categorySlug: string }) {
18  // Get current filter parameters from URL
19  const searchParams = useSearchParams();
20  const filterParams = Object.fromEntries(searchParams.entries());
21
22  // Fetch products with filters applied
23  const products = await getProducts(categorySlug, filterParams);
24
25  return (
26    <div className="grid grid-cols-3 gap-4">
27      {products.map(product => (
28        <ProductCard key={product.id} product={product} />
29      ))}
30    </div>
31  );
32}

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.

Go to CodeWorlds