Partial Prerendering (PPR): The Future of Hybrid Rendering
Partial Prerendering (PPR) is a cutting-edge rendering approach that allows you to combine static and dynamic content within the same page. It pre-renders static parts at build time while dynamically rendering interactive sections on-demand, giving you the best of both worlds.
How PPR Works
PPR Flow in Next.js 15
When to Use PPR
Perfect For:
- E-commerce Homepages: Static navigation + dynamic product recommendations
- News Sites: Static layout + dynamic article feeds
- Dashboard Applications: Static UI + dynamic data widgets
- Blog Platforms: Static content + dynamic comments and social features
- Marketing Sites: Static landing pages + dynamic personalization
- Social Media: Static profiles + dynamic feeds and interactions
Not Ideal For:
- Fully Static Content: Where all content is known at build time
- Fully Dynamic Applications: Where everything changes on every request
- Simple Single-Page Apps: Where the complexity isn't justified
Implementation with Next.js 15 App Router
Basic PPR Page
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { unstable_noStore as noStore } from 'next/cache'
import { Metadata } from 'next'
// Static components (pre-rendered at build time)
import { Header } from '@/components/Header'
import { Sidebar } from '@/components/Sidebar'
import { Footer } from '@/components/Footer'
// Dynamic components (rendered on-demand)
import { UserProfile } from '@/components/UserProfile'
import { RecentActivity } from '@/components/RecentActivity'
import { AnalyticsWidget } from '@/components/AnalyticsWidget'
export const metadata: Metadata = {
title: 'Dashboard',
description: 'Your personalized dashboard',
}
export default function DashboardPage() {
// This page will be partially pre-rendered
return (
<div className="min-h-screen bg-gray-50">
{/* Static parts - rendered at build time */}
<Header />
<div className="flex">
<Sidebar />
<main className="flex-1 p-6">
<h1 className="text-3xl font-bold mb-8">Dashboard</h1>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Dynamic parts - rendered on-demand */}
<Suspense fallback={<div className="bg-white p-6 rounded-lg shadow animate-pulse">Loading profile...</div>}>
<UserProfile />
</Suspense>
<Suspense fallback={<div className="bg-white p-6 rounded-lg shadow animate-pulse">Loading activity...</div>}>
<RecentActivity />
</Suspense>
<Suspense fallback={<div className="bg-white p-6 rounded-lg shadow animate-pulse">Loading analytics...</div>}>
<AnalyticsWidget />
</Suspense>
</div>
</main>
</div>
<Footer />
</div>
)
}
Dynamic Components
// components/UserProfile.tsx
import { headers } from 'next/headers'
interface User {
id: string
name: string
email: string
avatar: string
role: string
lastLogin: string
}
export async function UserProfile() {
const headersList = await headers()
const authToken = headersList.get('authorization')
if (!authToken) {
return (
<div className="bg-white p-6 rounded-lg shadow">
<p className="text-gray-600">Please log in to view your profile</p>
</div>
)
}
const user: User = await fetch('https://api.example.com/user/profile', {
headers: { 'Authorization': authToken },
cache: 'no-store',
}).then(res => res.json())
return (
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center space-x-4">
<img
src={user.avatar}
alt={user.name}
className="w-16 h-16 rounded-full"
/>
<div>
<h3 className="text-lg font-semibold">{user.name}</h3>
<p className="text-gray-600">{user.email}</p>
<p className="text-sm text-gray-500">{user.role}</p>
</div>
</div>
<div className="mt-4 pt-4 border-t">
<p className="text-sm text-gray-600">
Last login: {new Date(user.lastLogin).toLocaleString()}
</p>
</div>
</div>
)
}
// components/RecentActivity.tsx
import { headers } from 'next/headers'
interface Activity {
id: string
action: string
timestamp: string
type: 'order' | 'login' | 'purchase' | 'review'
}
export async function RecentActivity() {
const headersList = await headers()
const authToken = headersList.get('authorization')
if (!authToken) {
return (
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold mb-4">Recent Activity</h3>
<p className="text-gray-600">Please log in to view activity</p>
</div>
)
}
const activities: Activity[] = await fetch('https://api.example.com/user/activity', {
headers: { 'Authorization': authToken },
cache: 'no-store',
}).then(res => res.json())
return (
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold mb-4">Recent Activity</h3>
<div className="space-y-3">
{activities.slice(0, 5).map((activity) => (
<div key={activity.id} className="flex items-center space-x-3">
<div className={`w-2 h-2 rounded-full ${
activity.type === 'order' ? 'bg-blue-500' :
activity.type === 'purchase' ? 'bg-green-500' :
activity.type === 'review' ? 'bg-yellow-500' : 'bg-gray-500'
}`} />
<div className="flex-1">
<p className="text-sm">{activity.action}</p>
<p className="text-xs text-gray-500">
{new Date(activity.timestamp).toLocaleString()}
</p>
</div>
</div>
))}
</div>
</div>
)
}
// components/AnalyticsWidget.tsx
import { headers } from 'next/headers'
interface Analytics {
totalOrders: number
totalRevenue: number
averageOrderValue: number
topProducts: Array<{
name: string
sales: number
}>
}
export async function AnalyticsWidget() {
const headersList = await headers()
const authToken = headersList.get('authorization')
if (!authToken) {
return (
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold mb-4">Analytics</h3>
<p className="text-gray-600">Please log in to view analytics</p>
</div>
)
}
const analytics: Analytics = await fetch('https://api.example.com/user/analytics', {
headers: { 'Authorization': authToken },
cache: 'no-store',
}).then(res => res.json())
return (
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold mb-4">Analytics</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600">Total Orders</p>
<p className="text-2xl font-bold text-blue-600">{analytics.totalOrders}</p>
</div>
<div>
<p className="text-sm text-gray-600">Revenue</p>
<p className="text-2xl font-bold text-green-600">
${analytics.totalRevenue.toLocaleString()}
</p>
</div>
</div>
<div>
<p className="text-sm text-gray-600 mb-2">Top Products</p>
<div className="space-y-2">
{analytics.topProducts.map((product, index) => (
<div key={index} className="flex justify-between text-sm">
<span>{product.name}</span>
<span className="font-semibold">{product.sales} sales</span>
</div>
))}
</div>
</div>
</div>
</div>
)
}
E-commerce Homepage with PPR
// app/page.tsx
import { Suspense } from 'react'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { HeroBanner } from '@/components/HeroBanner'
import { FeaturedProducts } from '@/components/FeaturedProducts'
import { PersonalizedRecommendations } from '@/components/PersonalizedRecommendations'
import { RecentReviews } from '@/components/RecentReviews'
export default function HomePage() {
return (
<div className="min-h-screen">
{/* Static parts - pre-rendered */}
<Header />
<main>
{/* Hero section - static */}
<HeroBanner />
{/* Featured products - static */}
<section className="py-12 bg-gray-50">
<div className="max-w-7xl mx-auto px-4">
<h2 className="text-3xl font-bold text-center mb-8">Featured Products</h2>
<FeaturedProducts />
</div>
</section>
{/* Dynamic sections - rendered on-demand */}
<section className="py-12">
<div className="max-w-7xl mx-auto px-4">
<h2 className="text-3xl font-bold text-center mb-8">Recommended for You</h2>
<Suspense fallback={<div className="grid md:grid-cols-4 gap-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white p-4 rounded-lg shadow animate-pulse">
<div className="h-48 bg-gray-200 rounded mb-4"></div>
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
</div>
))}
</div>}>
<PersonalizedRecommendations />
</Suspense>
</div>
</section>
<section className="py-12 bg-gray-50">
<div className="max-w-7xl mx-auto px-4">
<h2 className="text-3xl font-bold text-center mb-8">Recent Reviews</h2>
<Suspense fallback={<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-white p-6 rounded-lg shadow animate-pulse">
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
))}
</div>}>
<RecentReviews />
</Suspense>
</div>
</section>
</main>
<Footer />
</div>
)
}
News Site with PPR
// app/news/page.tsx
import { Suspense } from 'react'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { BreakingNews } from '@/components/BreakingNews'
import { TrendingTopics } from '@/components/TrendingTopics'
import { UserPreferences } from '@/components/UserPreferences'
export default function NewsPage() {
return (
<div className="min-h-screen">
<Header />
<main className="max-w-7xl mx-auto px-4 py-8">
<div className="grid lg:grid-cols-4 gap-8">
{/* Main content - static */}
<div className="lg:col-span-3">
<h1 className="text-4xl font-bold mb-8">Latest News</h1>
{/* Breaking news - dynamic */}
<Suspense fallback={<div className="bg-red-50 p-4 rounded-lg animate-pulse">
<div className="h-4 bg-red-200 rounded mb-2"></div>
<div className="h-4 bg-red-200 rounded w-3/4"></div>
</div>}>
<BreakingNews />
</Suspense>
{/* Main news feed - static */}
<div className="mt-8">
{/* Static news articles would go here */}
</div>
</div>
{/* Sidebar - dynamic */}
<aside className="space-y-6">
<Suspense fallback={<div className="bg-white p-6 rounded-lg shadow animate-pulse">
<div className="h-4 bg-gray-200 rounded mb-4"></div>
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-3 bg-gray-200 rounded"></div>
))}
</div>
</div>}>
<TrendingTopics />
</Suspense>
<Suspense fallback={<div className="bg-white p-6 rounded-lg shadow animate-pulse">
<div className="h-4 bg-gray-200 rounded mb-4"></div>
<div className="space-y-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-3 bg-gray-200 rounded"></div>
))}
</div>
</div>}>
<UserPreferences />
</Suspense>
</aside>
</div>
</main>
<Footer />
</div>
)
}
Advanced PPR Patterns
Conditional Dynamic Content
// components/ConditionalWidget.tsx
import { headers } from 'next/headers'
interface User {
id: string
preferences: {
showAnalytics: boolean
showSocial: boolean
}
}
export async function ConditionalWidget() {
const headersList = await headers()
const authToken = headersList.get('authorization')
if (!authToken) {
return <div>Please log in</div>
}
const user: User = await fetch('https://api.example.com/user', {
headers: { 'Authorization': authToken },
cache: 'no-store',
}).then(res => res.json())
return (
<div className="space-y-4">
{user.preferences.showAnalytics && (
<Suspense fallback={<div>Loading analytics...</div>}>
<AnalyticsWidget />
</Suspense>
)}
{user.preferences.showSocial && (
<Suspense fallback={<div>Loading social feed...</div>}>
<SocialFeed />
</Suspense>
)}
</div>
)
}
Streaming with PPR
// app/streaming-demo/page.tsx
import { Suspense } from 'react'
import { SlowComponent } from '@/components/SlowComponent'
import { FastComponent } from '@/components/FastComponent'
export default function StreamingDemoPage() {
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<h1 className="text-3xl font-bold mb-8">Streaming Demo</h1>
{/* Fast component renders immediately */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Fast Content</h2>
<FastComponent />
</div>
{/* Slow component streams in */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Slow Content</h2>
<Suspense fallback={
<div className="bg-white p-6 rounded-lg shadow animate-pulse">
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
}>
<SlowComponent />
</Suspense>
</div>
</div>
)
}
Performance Benefits
Loading Strategy
User Experience Timeline
Best Practices
1. Identify Static vs Dynamic Content
// Static content (pre-rendered)
const staticContent = {
navigation: true,
footer: true,
heroSection: true,
layout: true,
}
// Dynamic content (rendered on-demand)
const dynamicContent = {
userProfile: false,
personalizedRecommendations: false,
realTimeData: false,
userSpecificContent: false,
}
2. Optimize Loading States
// components/LoadingSkeleton.tsx
export function LoadingSkeleton() {
return (
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
)
}
// Usage
<Suspense fallback={<LoadingSkeleton />}>
<DynamicComponent />
</Suspense>
3. Handle Errors Gracefully
// components/ErrorBoundary.tsx
'use client'
import { Component, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback: ReactNode
}
interface State {
hasError: boolean
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): State {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return this.props.fallback
}
return this.props.children
}
}
// Usage
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<LoadingSkeleton />}>
<DynamicComponent />
</Suspense>
</ErrorBoundary>
Configuration
Next.js Configuration
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true, // Enable Partial Prerendering
},
}
module.exports = nextConfig
Environment Variables
# .env.local
NEXT_PUBLIC_ENABLE_PPR=true
NEXT_PUBLIC_STATIC_PARTS_TIMEOUT=5000
PPR represents the future of web rendering, combining the performance benefits of static generation with the flexibility of dynamic content. It's perfect for modern applications that need both speed and personalization.