Back to Blog

Rendering Strategies in Next.js: SSR, SSG, ISR, PPR — When to Use What

March 1, 20263 min read7 views

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:

  1. Can it be fully static? → Use SSG
  2. Does it update occasionally? → Use ISR
  3. Must it be fresh/personalized? → Use SSR
  4. 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?

© 2026 Vidhya Sagar Thakur. All rights reserved.