Frontend Architecture
Part 3 of 11Rendering Strategies in Next.js: SSR, SSG, ISR, PPR — When to Use What
Rendering Strategies in Next.js: SSR, SSG, ISR, PPR — When to Use What
Introduction
Next.js gives you four rendering strategies, and the documentation makes each one sound great. Static Site Generation for speed! Server-Side Rendering for dynamic data! Incremental Static Regeneration for the best of both! Partial Prerendering for... wait, what's that one again?
The problem isn't understanding what each strategy does—it's knowing which one to pick for your specific page. And with the App Router, the mental model has shifted again.
This guide cuts through the confusion. We'll cover what each strategy actually does under the hood, when to use each one, and how to make the decision quickly for any page in your application.
The Rendering Landscape
What We're Choosing Between
THE FOUR RENDERING STRATEGIES:
════════════════════════════════════════════════════════════════════
┌─────────────────────────────────────────────────────────────────┐
│ │
│ SSG (Static Site Generation) │
│ ──────────────────────────── │
│ Built at build time. Same HTML served to everyone. │
│ Fastest possible response. No server computation. │
│ │
│ Build Time ──────► Static HTML ──────► CDN ──────► User │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SSR (Server-Side Rendering) │
│ ─────────────────────────── │
│ Built on every request. Fresh data every time. │
│ Slower, but always current. │
│ │
│ Request ──────► Server Renders ──────► HTML ──────► User │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ISR (Incremental Static Regeneration) │
│ ────────────────────────────────────── │
│ Static, but revalidates periodically. │
│ Stale-while-revalidate pattern. │
│ │
│ Request ──► Cached HTML ──► User │
│ │ │
│ └──► (Background) Revalidate if stale │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ PPR (Partial Prerendering) │
│ ───────────────────────────── │
│ Static shell with dynamic holes. │
│ Instant static + streaming dynamic. │
│ │
│ Request ──► Static Shell (instant) ──► User sees page │
│ │ │
│ └──► Dynamic parts stream in │
│ │
└─────────────────────────────────────────────────────────────────┘
The Trade-Off Triangle
FRESHNESS
▲
/│\
/ │ \
/ │ \
/ │ \
/ │ \
/ SSR│ \
/ │ \
/ │ \
/ ISR │ PPR \
/ │ \
/ │ \
/───────────┼───────────\
/ SSG │ \
▼─────────────┴─────────────▼
SPEED PERSONALIZATION
Every rendering decision is a trade-off between:
• SPEED: How fast can users see content?
• FRESHNESS: How current is the data?
• PERSONALIZATION: Is content user-specific?
There's no "best" strategy—only the right one for your use case.
SSG: Static Site Generation
How It Works
SSG BUILD AND SERVING FLOW:
════════════════════════════════════════════════════════════════════
BUILD TIME (npm run build):
────────────────────────────
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Fetch │────►│ Render │────►│ Write │
│ Data │ │ HTML │ │ .html │
└────────────┘ └────────────┘ └────────────┘
│ │ │
▼ ▼ ▼
API calls React runs Static files
DB queries Components in .next/
File reads to HTML
RUNTIME (user requests page):
─────────────────────────────
┌────────────┐ ┌────────────┐ ┌────────────┐
│ User │────►│ CDN │────►│ User │
│ Request │ │ Cache │ │ Receives │
└────────────┘ └────────────┘ └────────────┘
│
│ No server
│ computation!
▼
Pre-built
.html file
RESULT:
• TTFB: ~50-100ms (CDN edge)
• No origin server needed
• Infinitely scalable
• Cannot show user-specific content
App Router Implementation
// app/blog/[slug]/page.tsx
// This function runs at BUILD TIME
// It tells Next.js which pages to pre-generate
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
// This component runs at BUILD TIME (once per slug)
export default async function BlogPost({
params
}: {
params: { slug: string }
}) {
// This fetch happens at build time
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// Optional: Generate metadata at build time too
export async function generateMetadata({
params
}: {
params: { slug: string }
}) {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
};
}
When to Use SSG
SSG IS PERFECT FOR:
════════════════════════════════════════════════════════════════════
✓ MARKETING PAGES
Homepage, pricing, features, about
Content changes rarely, same for everyone
✓ BLOG POSTS / DOCUMENTATION
Content is known at build time
Doesn't change based on who's viewing
✓ PRODUCT CATALOG (basic)
Product details pages where price/availability
doesn't need real-time accuracy
✓ LEGAL PAGES
Terms, privacy policy, etc.
Changes very rarely
✓ LANDING PAGES
Campaign pages that don't need personalization
SSG IS WRONG FOR:
════════════════════════════════════════════════════════════════════
✗ USER DASHBOARDS
Content is different per user
✗ REAL-TIME DATA
Stock prices, live scores, inventory counts
✗ PERSONALIZED CONTENT
Recommendations, "for you" sections
✗ FREQUENTLY UPDATING CONTENT
News sites, social feeds
(Consider ISR instead)
✗ PAGES WITH MANY VARIANTS
10,000+ product pages = long build times
(Consider ISR with on-demand generation)
The Build Time Problem
THE SCALING ISSUE WITH PURE SSG:
════════════════════════════════════════════════════════════════════
Number of pages │ Build time
───────────────────┼──────────────────
100 pages │ ~1-2 minutes
1,000 pages │ ~10-15 minutes
10,000 pages │ ~1-2 hours
100,000 pages │ ~10+ hours ← Problem!
SOLUTIONS:
1. DON'T PRE-BUILD EVERYTHING
Only pre-build most popular pages.
Generate others on-demand with ISR.
export async function generateStaticParams() {
// Only top 100 posts
const posts = await getPopularPosts(100);
return posts.map(p => ({ slug: p.slug }));
}
2. USE dynamicParams
Allow pages not in generateStaticParams to be
generated on-demand.
// app/blog/[slug]/page.tsx
export const dynamicParams = true; // default
// First request: generates and caches
// Subsequent requests: served from cache
3. PARALLEL BUILD WORKERS
Next.js 13+ parallelizes static generation.
More CPU cores = faster builds.
SSR: Server-Side Rendering
How It Works
SSR REQUEST FLOW:
════════════════════════════════════════════════════════════════════
EVERY REQUEST:
──────────────
┌────────────┐ ┌────────────┐ ┌────────────┐
│ User │────►│ Server │────►│ Fetch │
│ Request │ │ Receives │ │ Data │
└────────────┘ └────────────┘ └────────────┘
│
▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ User │◄────│ Send │◄────│ Render │
│ Receives │ │ HTML │ │ React │
└────────────┘ └────────────┘ └────────────┘
TIME BREAKDOWN:
───────────────
User Request ─────────────────────────────────────► User Sees Page
│ │
▼ │
Network to server (~50ms) │
│ │
▼ │
Data fetching (~100-500ms) ◄── The bottleneck
│ │
▼ │
React rendering (~50-200ms) │
│ │
▼ │
Network to user (~50ms) │
│ │
└───────────────────────────────────┘
Total: 250-800ms
Compare to SSG: ~50-100ms (just CDN lookup)
App Router Implementation
// app/dashboard/page.tsx
// Force dynamic rendering
export const dynamic = 'force-dynamic';
// Or use cookies/headers which auto-trigger SSR
import { cookies } from 'next/headers';
export default async function Dashboard() {
// Reading cookies makes this page dynamic
const session = cookies().get('session');
// These fetches happen on EVERY request
const user = await getUser(session?.value);
const stats = await getUserStats(user.id);
const notifications = await getNotifications(user.id);
return (
<div>
<h1>Welcome, {user.name}</h1>
<StatsPanel stats={stats} />
<NotificationList notifications={notifications} />
</div>
);
}
What Triggers SSR in App Router
AUTOMATIC SSR TRIGGERS:
════════════════════════════════════════════════════════════════════
Using any of these makes a route dynamic (SSR):
┌─────────────────────────────────────────────────────────────────┐
│ │
│ cookies() │
│ ───────── │
│ import { cookies } from 'next/headers'; │
│ const session = cookies().get('session'); │
│ │
│ headers() │
│ ───────── │
│ import { headers } from 'next/headers'; │
│ const userAgent = headers().get('user-agent'); │
│ │
│ searchParams │
│ ──────────── │
│ export default function Page({ │
│ searchParams │
│ }: { │
│ searchParams: { q: string } │
│ }) { ... } │
│ │
│ Uncached fetch │
│ ────────────── │
│ fetch(url, { cache: 'no-store' }); │
│ // or │
│ fetch(url, { next: { revalidate: 0 } }); │
│ │
│ Explicit opt-in │
│ ─────────────── │
│ export const dynamic = 'force-dynamic'; │
│ │
└─────────────────────────────────────────────────────────────────┘
If none of these are present, Next.js will try to statically
render the page.
When to Use SSR
SSR IS PERFECT FOR:
════════════════════════════════════════════════════════════════════
✓ USER-SPECIFIC PAGES
Dashboards, account pages, personalized feeds
Content depends on who's logged in
✓ REAL-TIME DATA REQUIREMENTS
Stock tickers, live inventory, auction prices
Data must be current on every load
✓ AUTHENTICATION-DEPENDENT PAGES
Pages that show different content based on auth state
(Though consider middleware for redirects)
✓ SEARCH RESULTS
Query-dependent content from searchParams
Can't pre-build all possible queries
✓ FREQUENTLY CHANGING DATA
When even ISR's revalidation isn't fast enough
SSR IS WRONG FOR:
════════════════════════════════════════════════════════════════════
✗ STATIC CONTENT
Wasting server resources for content that's
the same for everyone
✗ HIGH-TRAFFIC PAGES
Every request hits your server
Scaling becomes expensive
✗ CONTENT THAT CAN BE SLIGHTLY STALE
News articles, blog posts
ISR gives better performance for acceptable staleness
✗ PAGES WHERE SPEED IS CRITICAL
Landing pages, marketing
SSG is always faster
SSR Performance Patterns
// PATTERN 1: Parallel Data Fetching
// ─────────────────────────────────
// BAD: Sequential (slow)
export default async function Dashboard() {
const user = await getUser(); // 200ms
const posts = await getPosts(user.id); // 300ms (waits for user)
const stats = await getStats(user.id); // 200ms (waits for posts)
// Total: 700ms
}
// GOOD: Parallel (fast)
export default async function Dashboard() {
const user = await getUser(); // 200ms
// These run in parallel
const [posts, stats] = await Promise.all([
getPosts(user.id), // 300ms ─┐
getStats(user.id), // 200ms ─┴─► 300ms total
]);
// Total: 500ms
}
// PATTERN 2: Streaming with Suspense
// ──────────────────────────────────
// Instead of waiting for everything, stream as ready
export default async function Dashboard() {
const user = await getUser(); // Need this first
return (
<div>
<h1>Welcome, {user.name}</h1>
{/* These stream in as they resolve */}
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel userId={user.id} />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<RecentPosts userId={user.id} />
</Suspense>
</div>
);
}
// Each component fetches its own data
async function StatsPanel({ userId }: { userId: string }) {
const stats = await getStats(userId);
return <div>...</div>;
}
ISR: Incremental Static Regeneration
How It Works
ISR: STALE-WHILE-REVALIDATE PATTERN:
════════════════════════════════════════════════════════════════════
INITIAL REQUEST (page not cached):
──────────────────────────────────
┌────────────┐ ┌────────────┐ ┌────────────┐
│ User │────►│ Server │────►│ Generate │
│ Request │ │ │ │ Page │
└────────────┘ └────────────┘ └────────────┘
│
▼
┌────────────┐
│ Cache │
│ HTML │
└────────────┘
│
▼
┌────────────┐
│ Return │
│ to User │
└────────────┘
SUBSEQUENT REQUESTS (within revalidation window):
─────────────────────────────────────────────────
┌────────────┐ ┌────────────┐ ┌────────────┐
│ User │────►│ Cache │────►│ Instant │
│ Request │ │ Hit │ │ Response │
└────────────┘ └────────────┘ └────────────┘
No server work! Just like SSG.
REQUEST AFTER REVALIDATION PERIOD:
──────────────────────────────────
┌────────────┐ ┌────────────┐ ┌────────────┐
│ User │────►│ Serve │────►│ Instant │
│ Request │ │ Stale │ │ Response │
└────────────┘ └────────────┘ └────────────┘
│
│ (Background)
▼
┌────────────┐
│ Regenerate │
│ Page │
└────────────┘
│
▼
┌────────────┐
│ Update │
│ Cache │
└────────────┘
User gets stale page instantly.
Next user gets fresh page.
App Router Implementation
// app/products/[id]/page.tsx
// Generate popular products at build time
export async function generateStaticParams() {
const products = await getPopularProducts(100);
return products.map((p) => ({ id: p.id }));
}
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
// This fetch is cached with revalidation
const product = await fetch(
`https://api.example.com/products/${params.id}`,
{ next: { revalidate: 3600 } } // Revalidate every hour
);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
</div>
);
}
// Alternative: Route segment config
export const revalidate = 3600; // Revalidate this route every hour
Time-Based vs On-Demand Revalidation
// TIME-BASED REVALIDATION
// ═══════════════════════════════════════════════════════════════
// Automatically revalidate after X seconds
// Option 1: Per-fetch
const data = await fetch(url, {
next: { revalidate: 60 } // 60 seconds
});
// Option 2: Per-route
export const revalidate = 60;
// ON-DEMAND REVALIDATION
// ═══════════════════════════════════════════════════════════════
// Revalidate when YOU decide (webhook, CMS publish, etc.)
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const { path, tag, secret } = await request.json();
// Verify secret to prevent abuse
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 });
}
// Option 1: Revalidate specific path
if (path) {
revalidatePath(path);
return Response.json({ revalidated: true, path });
}
// Option 2: Revalidate by tag
if (tag) {
revalidateTag(tag);
return Response.json({ revalidated: true, tag });
}
}
// Using tags for granular revalidation
// ────────────────────────────────────
// In your page/component:
const product = await fetch(
`https://api.example.com/products/${id}`,
{ next: { tags: ['products', `product-${id}`] } }
);
// In your CMS webhook handler:
// When product 123 is updated:
revalidateTag('product-123'); // Just that product
// When all products change (price update):
revalidateTag('products'); // All products
When to Use ISR
ISR IS PERFECT FOR:
════════════════════════════════════════════════════════════════════
✓ E-COMMERCE PRODUCT PAGES
Price/inventory can be slightly stale
Revalidate every few minutes or on-demand
✓ NEWS / BLOG ARTICLES
Content updates, but not every second
Revalidate on publish via CMS webhook
✓ DOCUMENTATION SITES
Content from CMS/Git
On-demand revalidation on merge
✓ CATEGORY / LISTING PAGES
Product lists, search results pages
Can be a few minutes stale
✓ PAGES WITH MANY VARIANTS
Too many to build all at once
Generate on-demand, cache with ISR
ISR IS WRONG FOR:
════════════════════════════════════════════════════════════════════
✗ USER-SPECIFIC CONTENT
ISR caches ONE version for everyone
Use SSR for personalized pages
✗ REAL-TIME ACCURACY REQUIRED
Stock prices, live inventory, auctions
"Slightly stale" isn't acceptable
✗ CONTENT THAT NEVER CHANGES
Static pages, legal docs
Just use SSG—simpler
✗ HIGHLY PERSONALIZED RECOMMENDATIONS
Different for every user
ISR can't help
ISR REVALIDATION TIME GUIDE:
════════════════════════════════════════════════════════════════════
Content Type │ Suggested Revalidate
──────────────────────────┼─────────────────────────
Blog posts │ On-demand (CMS webhook)
Product pages │ 60-300 seconds
Category pages │ 300-900 seconds
Documentation │ On-demand (Git webhook)
News articles │ 60 seconds
Landing pages │ 3600+ seconds (or SSG)
User-generated content │ 60-300 seconds
ISR Gotchas
COMMON ISR MISTAKES:
════════════════════════════════════════════════════════════════════
1. FORGETTING THAT FIRST USER WAITS
─────────────────────────────────
If page isn't cached, first visitor waits for generation.
Solution: Pre-generate popular pages with generateStaticParams
export async function generateStaticParams() {
// Pre-generate top 1000 products
const products = await getPopularProducts(1000);
return products.map(p => ({ id: p.id }));
}
2. SETTING REVALIDATE TOO LOW
───────────────────────────
revalidate: 1 means you're basically doing SSR
with extra caching complexity.
If you need data THAT fresh, just use SSR.
3. NOT USING ON-DEMAND FOR KNOWN UPDATES
──────────────────────────────────────
Time-based: "Maybe the data changed"
On-demand: "I know the data changed"
If you control when data changes (CMS publish),
use on-demand revalidation.
4. CACHING USER-SPECIFIC DATA
───────────────────────────
ISR caches ONE version served to ALL users.
// WRONG: This caches user A's data for everyone
export const revalidate = 60;
export default async function Dashboard() {
const user = await getCurrentUser();
return <div>{user.name}'s Dashboard</div>;
}
User B sees User A's dashboard! Use SSR instead.
5. MIXING REVALIDATION TIMES BADLY
────────────────────────────────
// Page revalidates every 60 seconds
export const revalidate = 60;
// But this fetch revalidates every 3600 seconds
const data = await fetch(url, {
next: { revalidate: 3600 }
});
The page regenerates, but uses cached (stale) fetch data.
Be consistent with your revalidation strategy.
PPR: Partial Prerendering
The New Mental Model
PPR: THE BEST OF BOTH WORLDS:
════════════════════════════════════════════════════════════════════
TRADITIONAL APPROACH: Whole page is static OR dynamic
───────────────────────────────────────────────────────
Option A: SSG (fast, but no dynamic content)
┌─────────────────────────────────────────────────────────────────┐
│ ████████████████████████████████████████████████████████████ │
│ ████████████████████████████████████████████████████████████ │
│ ████████████████ ALL STATIC ████████████████████████████████ │
│ ████████████████████████████████████████████████████████████ │
└─────────────────────────────────────────────────────────────────┘
Option B: SSR (dynamic, but slower)
┌─────────────────────────────────────────────────────────────────┐
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ALL DYNAMIC ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
└─────────────────────────────────────────────────────────────────┘
PPR APPROACH: Static shell with dynamic holes
──────────────────────────────────────────────
┌─────────────────────────────────────────────────────────────────┐
│ ████████████████████████████████████████████████████████████ │
│ ████████████████████ ┌──────────────┐ ████████████████████ │
│ ████████████████████ │ DYNAMIC │ ████████████████████ │
│ ████ STATIC █████████ │ (streams) │ ████████████████████ │
│ ████████████████████ └──────────────┘ ████████████████████ │
│ ███████████████ ┌───────────────────────────┐ ████████████ │
│ ███████████████ │ DYNAMIC │ ████████████ │
│ ███████████████ │ (streams) │ ████████████ │
│ ███████████████ └───────────────────────────┘ ████████████ │
│ ████████████████████████████████████████████████████████████ │
└─────────────────────────────────────────────────────────────────┘
Static parts: Served instantly from CDN
Dynamic parts: Stream in as they're ready
How PPR Works
PPR REQUEST FLOW:
════════════════════════════════════════════════════════════════════
1. USER REQUESTS PAGE
───────────────────
┌────────────┐ ┌────────────┐
│ User │────►│ CDN │
│ Request │ │ │
└────────────┘ └────────────┘
2. INSTANT STATIC SHELL (from CDN, ~50ms)
───────────────────────────────────────
┌────────────┐ ┌─────────────────────────────────────────┐
│ CDN │────►│ <html> │
│ │ │ <head>...</head> │
│ │ │ <body> │
│ │ │ <nav>Logo | Home | About</nav> │
│ │ │ <main> │
│ │ │ <h1>Product Name</h1> │
│ │ │ <div id="price">Loading...</div> │ ← Placeholder
│ │ │ <p>Description...</p> │
│ │ │ <div id="cart">Loading...</div> │ ← Placeholder
│ │ │ </main> │
│ │ │ </body> │
│ │ │ </html> │
│ │ └─────────────────────────────────────────┘
│ │
│ │ User sees page structure IMMEDIATELY
3. DYNAMIC PARTS STREAM IN (from server)
──────────────────────────────────────
┌────────────┐ ┌────────────┐
│ Server │────►│ Streams │
│ │ │ dynamic │
│ │ │ content │
└────────────┘ └────────────┘
│
▼
Price component ready (150ms) → Replaces placeholder
Cart component ready (300ms) → Replaces placeholder
User sees instant page, then content fills in.
Much better than blank screen while SSR runs!
PPR Implementation
// next.config.js
module.exports = {
experimental: {
ppr: true, // Enable PPR (as of Next.js 14)
},
};
// app/products/[id]/page.tsx
import { Suspense } from 'react';
// This function determines WHICH pages to pre-render the shell for
export async function generateStaticParams() {
const products = await getPopularProducts(100);
return products.map((p) => ({ id: p.id }));
}
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
// Static data (pre-rendered)
const product = await getProduct(params.id);
return (
<div>
{/* STATIC: Pre-rendered at build time */}
<h1>{product.name}</h1>
<p>{product.description}</p>
<ProductImages images={product.images} />
{/* DYNAMIC: Wrapped in Suspense = streams in */}
<Suspense fallback={<PriceSkeleton />}>
<PriceDisplay productId={params.id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<CustomerReviews productId={params.id} />
</Suspense>
<Suspense fallback={<CartSkeleton />}>
<AddToCart productId={params.id} />
</Suspense>
</div>
);
}
// Dynamic component (uses cookies = dynamic)
async function PriceDisplay({ productId }: { productId: string }) {
const userCurrency = cookies().get('currency')?.value || 'USD';
const price = await getPrice(productId, userCurrency);
return (
<div className="price">
{formatPrice(price, userCurrency)}
</div>
);
}
// Another dynamic component (personalized)
async function AddToCart({ productId }: { productId: string }) {
const session = cookies().get('session');
const cartCount = session
? await getCartCount(session.value)
: 0;
return (
<button>
Add to Cart ({cartCount} items in cart)
</button>
);
}
What's Static vs Dynamic in PPR
PPR AUTOMATIC DETECTION:
════════════════════════════════════════════════════════════════════
STATIC (Pre-rendered shell):
────────────────────────────
• Components NOT wrapped in Suspense
• Data fetched without dynamic functions
• Content that's the same for everyone
DYNAMIC (Streamed in):
──────────────────────
• Components wrapped in Suspense
• Components using cookies(), headers()
• Components with uncached fetches
VISUAL GUIDE:
─────────────
// page.tsx
export default async function Page() {
const staticData = await getStaticData(); // ← STATIC
return (
<div>
{/* ══════════ STATIC SHELL ══════════ */}
<Header /> {/* Static */}
<h1>{staticData.title}</h1> {/* Static */}
<p>{staticData.description}</p> {/* Static */}
{/* ══════════ DYNAMIC HOLES ══════════ */}
<Suspense fallback={<Skeleton />}>
<UserGreeting /> {/* Dynamic - uses cookies */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<LiveInventory /> {/* Dynamic - real-time data */}
</Suspense>
{/* ══════════ MORE STATIC ══════════ */}
<Footer /> {/* Static */}
</div>
);
}
When to Use PPR
PPR IS PERFECT FOR:
════════════════════════════════════════════════════════════════════
✓ E-COMMERCE PRODUCT PAGES
Static: Product name, description, images
Dynamic: Price (currency), cart count, inventory
✓ NEWS ARTICLES WITH PERSONALIZATION
Static: Article content, author info
Dynamic: "Read next" recommendations, user reactions
✓ DASHBOARDS WITH MIXED CONTENT
Static: Page layout, navigation, labels
Dynamic: User data, stats, notifications
✓ MARKETING PAGES WITH USER STATE
Static: Hero, features, pricing
Dynamic: "Welcome back, User" header, login state
✓ ANY PAGE WHERE YOU SAID "I WISH THE SHELL LOADED FASTER"
PPR IS OVERKILL FOR:
════════════════════════════════════════════════════════════════════
✗ FULLY STATIC PAGES
No dynamic content = just use SSG
✗ FULLY DYNAMIC PAGES
Everything needs user context = just use SSR
(Though streaming with Suspense still helps)
✗ PAGES WHERE DYNAMIC DATA IS THE MAIN CONTENT
If the "shell" is just a header, benefit is minimal
Decision Framework
The Quick Decision Tree
WHICH RENDERING STRATEGY SHOULD I USE?
════════════════════════════════════════════════════════════════════
START HERE
│
▼
┌─────────────────────────────────┐
│ Is content user-specific? │
│ (different per logged-in user) │
└─────────────────────────────────┘
│ │
YES NO
│ │
▼ ▼
┌─────────────┐ ┌─────────────────────────────────┐
│ Is MOST of │ │ Does data change frequently? │
│ the page │ │ (more than once per hour) │
│ user- │ └─────────────────────────────────┘
│ specific? │ │ │
└─────────────┘ YES NO
│ │ │ │
YES NO ▼ ▼
│ │ ┌───────────┐ ┌───────────────────────┐
▼ ▼ │ Is real- │ │ Does it change at all?│
┌──────┐ ┌─────┐ │ time │ │ (ever after publish) │
│ SSR │ │ PPR │ │ accuracy │ └───────────────────────┘
└──────┘ └─────┘ │ required? │ │ │
└───────────┘ YES NO
│ │ │ │
YES NO ▼ ▼
│ │ ┌───────────┐ ┌───────┐
▼ ▼ │ ISR │ │ SSG │
┌──────┐ ┌───────┐ │ (timed or │ └───────┘
│ SSR │ │ ISR │ │ on-demand)│
└──────┘ └───────┘ └───────────┘
SUMMARY:
────────
SSG → Content never changes, same for everyone
ISR → Content changes sometimes, same for everyone
SSR → Content must be fresh OR user-specific
PPR → Mix of static + user-specific on same page
Page-Type Cheat Sheet
COMMON PAGE TYPES AND THEIR STRATEGIES:
════════════════════════════════════════════════════════════════════
PAGE TYPE │ STRATEGY │ WHY
─────────────────────────────┼───────────┼────────────────────────
Homepage (logged out) │ SSG │ Same for everyone
Homepage (logged in) │ PPR │ Static + user greeting
About / Pricing / Features │ SSG │ Never changes
Blog post │ ISR │ Changes on publish
Blog listing │ ISR │ New posts added
Documentation │ ISR │ Changes on deploy
Product page (basic) │ ISR │ Updates occasionally
Product page (e-commerce) │ PPR │ Static + live price/cart
Search results │ SSR │ Query-dependent
User dashboard │ SSR │ Fully personalized
User settings │ SSR │ User-specific
Shopping cart │ SSR │ User-specific
Checkout │ SSR │ User + real-time
Admin pages │ SSR │ Auth + fresh data
API status page │ SSR │ Real-time accuracy
News article │ ISR │ Content + reactions
Social feed │ SSR │ Personalized + real-time
Profile (public) │ ISR │ Updates on edit
Profile (own) │ SSR │ User-specific
Legal pages (terms/privacy) │ SSG │ Rarely changes
HYBRID EXAMPLES:
────────────────
Some pages benefit from multiple strategies:
E-commerce Product Page (PPR):
├── Static: Name, description, images (SSG-like)
├── Dynamic: Price in user's currency (SSR-like)
├── Dynamic: "X left in stock" (SSR-like)
└── Dynamic: Add to Cart with cart count (SSR-like)
News Article (ISR + Client):
├── Static: Article content (ISR)
├── Static: Author info (ISR)
├── Client: Comment count (client-side fetch)
└── Client: User reactions (client-side fetch)
The Performance Impact
PERFORMANCE CHARACTERISTICS:
════════════════════════════════════════════════════════════════════
│ TTFB* │ CACHE │ SERVER LOAD │ SCALE
────────────────────┼───────────┼───────┼─────────────┼──────────
SSG │ 50-100ms │ 100% │ None │ Infinite
ISR (cached) │ 50-100ms │ ~95% │ Minimal │ Very High
ISR (miss) │ 200-500ms │ - │ Spike │ -
PPR (shell) │ 50-100ms │ 100% │ None │ Infinite
PPR (dynamic) │ +100-300ms│ 0% │ Per-request │ Medium
SSR │ 200-800ms │ 0% │ Per-request │ Lowest
* Time To First Byte (network latency not included)
COST IMPLICATIONS:
──────────────────
┌─────────────────────────────────────────────────────────────────┐
│ │
│ SSG/ISR: Pay for CDN bandwidth │
│ ────────────────────────────── │
│ Vercel: Included in plan │
│ AWS: ~$0.085/GB │
│ Cloudflare: Free │
│ │
│ SSR: Pay for compute time │
│ ─────────────────────── │
│ Vercel: ~$0.18/GB-hour (serverless) │
│ AWS Lambda: ~$0.20/1M requests + $0.0000166667/GB-second │
│ │
│ For high-traffic pages, SSG/ISR is MUCH cheaper. │
│ │
│ Example: 1M pageviews/month │
│ ───────────────────────────── │
│ SSG: ~$5-10 (CDN only) │
│ SSR: ~$50-200 (compute) │
│ │
└─────────────────────────────────────────────────────────────────┘
Migration Patterns
Pages Router → App Router
// PAGES ROUTER PATTERNS AND APP ROUTER EQUIVALENTS:
// ═══════════════════════════════════════════════════════════════
// ──────────────────────────────────────────────────────────────
// SSG: getStaticProps → async component
// ──────────────────────────────────────────────────────────────
// BEFORE (pages/blog/[slug].tsx)
export async function getStaticProps({ params }) {
const post = await getPost(params.slug);
return { props: { post } };
}
export async function getStaticPaths() {
const posts = await getAllPosts();
return {
paths: posts.map(p => ({ params: { slug: p.slug } })),
fallback: false,
};
}
export default function BlogPost({ post }) {
return <article>{post.content}</article>;
}
// AFTER (app/blog/[slug]/page.tsx)
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(p => ({ slug: p.slug }));
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return <article>{post.content}</article>;
}
// ──────────────────────────────────────────────────────────────
// ISR: revalidate in getStaticProps → fetch options or route config
// ──────────────────────────────────────────────────────────────
// BEFORE
export async function getStaticProps({ params }) {
const product = await getProduct(params.id);
return {
props: { product },
revalidate: 60, // ISR: revalidate every 60 seconds
};
}
// AFTER (Option 1: route segment config)
export const revalidate = 60;
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return <div>{product.name}</div>;
}
// AFTER (Option 2: per-fetch)
export default async function ProductPage({ params }) {
const product = await fetch(`/api/products/${params.id}`, {
next: { revalidate: 60 }
}).then(r => r.json());
return <div>{product.name}</div>;
}
// ──────────────────────────────────────────────────────────────
// SSR: getServerSideProps → async component with dynamic functions
// ──────────────────────────────────────────────────────────────
// BEFORE (pages/dashboard.tsx)
export async function getServerSideProps({ req }) {
const session = req.cookies.session;
const user = await getUser(session);
return { props: { user } };
}
export default function Dashboard({ user }) {
return <div>Hello, {user.name}</div>;
}
// AFTER (app/dashboard/page.tsx)
import { cookies } from 'next/headers';
export default async function Dashboard() {
const session = cookies().get('session');
const user = await getUser(session?.value);
return <div>Hello, {user.name}</div>;
}
// ──────────────────────────────────────────────────────────────
// fallback: 'blocking' → dynamicParams
// ──────────────────────────────────────────────────────────────
// BEFORE
export async function getStaticPaths() {
return {
paths: [...],
fallback: 'blocking', // Generate on-demand if not pre-built
};
}
// AFTER
export const dynamicParams = true; // default, allows on-demand generation
export async function generateStaticParams() {
// Pre-generate popular pages
return [...];
}
Adding PPR to Existing Pages
// CONVERTING AN SSR PAGE TO PPR:
// ═══════════════════════════════════════════════════════════════
// BEFORE: Fully SSR (entire page waits for all data)
// ───────────────────────────────────────────────────
import { cookies } from 'next/headers';
export default async function ProductPage({ params }) {
const session = cookies().get('session');
// All these run before ANY HTML is sent
const product = await getProduct(params.id);
const price = await getPrice(params.id, session);
const inventory = await getInventory(params.id);
const recommendations = await getRecommendations(params.id, session);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>Price: {price}</p>
<p>In stock: {inventory}</p>
<Recommendations items={recommendations} />
</div>
);
}
// AFTER: PPR (static shell + streaming dynamic)
// ──────────────────────────────────────────────
import { Suspense } from 'react';
// Pre-render static shell for popular products
export async function generateStaticParams() {
const products = await getPopularProducts(100);
return products.map(p => ({ id: p.id }));
}
export default async function ProductPage({ params }) {
// This is STATIC (fetched at build time for pre-rendered pages)
const product = await getProduct(params.id);
return (
<div>
{/* STATIC SHELL - served instantly */}
<h1>{product.name}</h1>
<p>{product.description}</p>
<ProductImages images={product.images} />
{/* DYNAMIC - streams in */}
<Suspense fallback={<PriceSkeleton />}>
<PriceDisplay productId={params.id} />
</Suspense>
<Suspense fallback={<InventorySkeleton />}>
<InventoryStatus productId={params.id} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={params.id} />
</Suspense>
</div>
);
}
// Each dynamic component fetches its own data
async function PriceDisplay({ productId }) {
const session = cookies().get('session');
const price = await getPrice(productId, session?.value);
return <p className="price">{formatPrice(price)}</p>;
}
Common Patterns and Anti-Patterns
Anti-Patterns
RENDERING ANTI-PATTERNS:
════════════════════════════════════════════════════════════════════
1. SSR EVERYWHERE "JUST TO BE SAFE"
─────────────────────────────────
Problem: Using SSR for static marketing pages
// pages/pricing/page.tsx
export const dynamic = 'force-dynamic'; // WHY?
Impact: 5x slower, 10x more expensive, can't cache
Fix: Only use SSR when you NEED fresh/personalized data
2. CACHING USER-SPECIFIC DATA
───────────────────────────
Problem: ISR on pages with user data
export const revalidate = 60;
export default async function Dashboard() {
const user = await getCurrentUser(); // Oops
return <div>{user.name}'s data</div>;
}
Impact: User A sees User B's data
Fix: Use SSR for user-specific pages
3. WATERFALL DATA FETCHING IN SSR
───────────────────────────────
Problem: Sequential fetches
export default async function Page() {
const user = await getUser(); // 200ms
const posts = await getPosts(user.id); // 300ms
const comments = await getComments(); // 200ms
// Total: 700ms
}
Fix: Parallel fetches
const [posts, comments] = await Promise.all([
getPosts(userId),
getComments(),
]);
4. REVALIDATING TOO FREQUENTLY
────────────────────────────
Problem: revalidate: 1 (every second)
If you need data THAT fresh, just use SSR.
You're adding complexity for no benefit.
5. NOT PRE-RENDERING POPULAR PAGES
────────────────────────────────
Problem: Empty generateStaticParams
export async function generateStaticParams() {
return []; // First visitor to EVERY page waits
}
Fix: Pre-generate at least popular pages
export async function generateStaticParams() {
const popular = await getPopularProducts(1000);
return popular.map(p => ({ id: p.id }));
}
6. FORGETTING SUSPENSE WITH PPR
─────────────────────────────
Problem: Dynamic component without Suspense
// This makes the ENTIRE page dynamic
export default async function Page() {
return (
<div>
<StaticContent />
<DynamicUserData /> {/* No Suspense = whole page waits */}
</div>
);
}
Fix: Wrap dynamic components
<Suspense fallback={<Skeleton />}>
<DynamicUserData />
</Suspense>
Patterns That Work
RENDERING BEST PRACTICES:
════════════════════════════════════════════════════════════════════
1. START STATIC, ADD DYNAMISM AS NEEDED
─────────────────────────────────────
Default to SSG/ISR. Only reach for SSR when required.
Question to ask: "Does this HAVE to be fresh/personalized?"
2. USE TAGS FOR SURGICAL REVALIDATION
───────────────────────────────────
// Fetch with tags
const post = await fetch(url, {
next: { tags: ['posts', `post-${id}`] }
});
// Revalidate just what changed
revalidateTag(`post-${id}`); // Not all posts
3. COMBINE STATIC + CLIENT FOR LIVE DATA
──────────────────────────────────────
// Page: ISR for article content
export const revalidate = 3600;
export default async function Article({ params }) {
const article = await getArticle(params.id);
return (
<div>
{/* Static content */}
<h1>{article.title}</h1>
<div>{article.content}</div>
{/* Client-side for live data */}
<CommentCount articleId={params.id} />
<LikeButton articleId={params.id} />
</div>
);
}
// Client component for real-time updates
'use client';
function CommentCount({ articleId }) {
const { data } = useSWR(`/api/comments/count/${articleId}`);
return <span>{data?.count} comments</span>;
}
4. PARALLEL DATA FETCHING WITH SUSPENSE
─────────────────────────────────────
// Each component fetches independently, streams when ready
export default function Dashboard() {
return (
<div>
<Suspense fallback={<Skeleton />}>
<RevenueChart /> {/* Fetches own data */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<RecentOrders /> {/* Fetches own data */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<UserStats /> {/* Fetches own data */}
</Suspense>
</div>
);
}
// All three fetch in parallel, stream as ready
5. USE MIDDLEWARE FOR AUTH REDIRECTS
──────────────────────────────────
// Instead of checking auth in every SSR page:
// middleware.ts
export function middleware(request: NextRequest) {
const session = request.cookies.get('session');
if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// Now dashboard pages don't need to check auth
Quick Reference
┌─────────────────────────────────────────────────────────────────────┐
│ NEXT.JS RENDERING QUICK REFERENCE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ STRATEGY SELECTION │
│ ───────────────────────────────────────────────────────────────── │
│ User-specific content? │
│ • Mostly dynamic → SSR │
│ • Mix of static/dynamic → PPR │
│ Same for everyone? │
│ • Never changes → SSG │
│ • Changes sometimes → ISR │
│ • Real-time required → SSR │
│ │
│ APP ROUTER SYNTAX │
│ ───────────────────────────────────────────────────────────────── │
│ SSG: │
│ export async function generateStaticParams() { ... } │
│ │
│ ISR (time-based): │
│ export const revalidate = 60; │
│ // or │
│ fetch(url, { next: { revalidate: 60 } }); │
│ │
│ ISR (on-demand): │
│ import { revalidatePath, revalidateTag } from 'next/cache'; │
│ revalidatePath('/blog/post-1'); │
│ revalidateTag('posts'); │
│ │
│ SSR: │
│ export const dynamic = 'force-dynamic'; │
│ // or use cookies(), headers(), searchParams │
│ │
│ PPR: │
│ // next.config.js: experimental: { ppr: true } │
│ <Suspense fallback={<Skeleton />}> │
│ <DynamicComponent /> │
│ </Suspense> │
│ │
│ DYNAMIC TRIGGERS │
│ ───────────────────────────────────────────────────────────────── │
│ These make a route dynamic (SSR): │
│ • cookies() │
│ • headers() │
│ • searchParams prop │
│ • fetch with { cache: 'no-store' } │
│ • export const dynamic = 'force-dynamic' │
│ │
│ PERFORMANCE COMPARISON │
│ ───────────────────────────────────────────────────────────────── │
│ Strategy │ TTFB │ Freshness │ Personalized │ Cost │
│ ────────────┼───────────┼────────────┼──────────────┼─────────────│
│ SSG │ ~50ms │ Build-time │ No │ Lowest │
│ ISR │ ~50ms* │ Periodic │ No │ Low │
│ SSR │ ~300ms │ Real-time │ Yes │ Highest │
│ PPR │ ~50ms** │ Mixed │ Partial │ Medium │
│ * Cached │ │ │ │ │
│ ** Shell │ │ │ │ │
│ │
│ COMMON PATTERNS │
│ ───────────────────────────────────────────────────────────────── │
│ Marketing page → SSG │
│ Blog post → ISR (on-demand) │
│ Product page → PPR or ISR │
│ Dashboard → SSR │
│ Search results → SSR │
│ User profile (public) → ISR │
│ User profile (own) → SSR │
│ │
│ ANTI-PATTERNS TO AVOID │
│ ───────────────────────────────────────────────────────────────── │
│ ✗ SSR for static content │
│ ✗ ISR for user-specific data │
│ ✗ revalidate: 1 (just use SSR) │
│ ✗ Sequential fetches in SSR │
│ ✗ Empty generateStaticParams │
│ ✗ Dynamic components without Suspense in PPR │
│ │
└─────────────────────────────────────────────────────────────────────┘
Conclusion
Choosing the right rendering strategy isn't about finding the "best" one—it's about matching the strategy to your page's requirements.
Start with the simplest option that works:
- Can it be fully static? → Use SSG
- Does it update occasionally? → Use ISR
- Must it be fresh/personalized? → Use SSR
- Is it a mix? → Use PPR (or ISR + client-side)
Remember the trade-offs:
- SSG is fastest and cheapest but can't show dynamic content
- ISR gives you caching with freshness, but not real-time
- SSR is always fresh but slowest and most expensive
- PPR gives instant shells with streaming dynamic content
When in doubt:
- Default to static (SSG/ISR)
- Add dynamism only where needed
- Measure your Core Web Vitals
- Consider the cost at scale
The goal isn't to use the fanciest rendering strategy—it's to give users fast, accurate pages while keeping your infrastructure simple and affordable. Sometimes that's a plain static page. Sometimes that's streaming PPR with multiple Suspense boundaries. The right answer depends on what that specific page needs to do.
What did you think?