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.
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:
Such a hybrid architecture combines the best of both worlds: the speed of static generation with the freshness of dynamic rendering.
Before PPR was introduced, developers had to choose between several approaches, each with its own limitations:
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.
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:
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}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}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}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}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}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}Optimal data caching is crucial for PPR performance. Here are some caching patterns:
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}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 is ideal for e-commerce product pages where we can:
For information websites, PPR allows:
For analytical dashboards:
Debugging PPR can be challenging due to the hybrid nature of rendering. Here are some tips:
React DevTools allows inspecting components and their rendering state.
1NEXT_DEBUG=1 next buildThis command shows detailed information about which pages are generated statically.
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.
Clearly define which data can be rendered statically and which must be dynamic:
Use nested
Suspense to prioritize content loading:Carefully manage data caching to avoid unnecessary re-renders:
fetchAPI with React memoizationDesign loading skeletons that match the dimensions and proportions of the final content to minimize layout shifts.
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:
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.