Next.js App Router: SSR vs SSG vs ISR | Rendering Strategy Guide

Next.js App Router: SSR vs SSG vs ISR | Rendering Strategy Guide

이 글의 핵심

In Next.js App Router, the rendering strategy — how a page is generated and cached — determines performance, SEO, and server cost. This guide shows you how to choose the right strategy and control it precisely.

Why Rendering Strategy Matters

In Next.js App Router, a single fetch option determines whether your page:

  • Serves a cached HTML from the last build (fast, cheap)
  • Generates fresh HTML on every request (slow, expensive)
  • Serves cached HTML but regenerates in the background (balanced)

This decision directly affects TTFB, server cost, and SEO. Understanding it before you write code saves hours of debugging mysterious stale data.


The Three Strategies

SSG — Static Site Generation

Generated at build time. The fastest option — serves pre-built HTML from a CDN.

The following example demonstrates the concept in tsx:

// app/blog/[slug]/page.tsx
// No special config needed — static is the DEFAULT

export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map(post => ({ slug: post.slug }));
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  // This fetch runs at BUILD TIME
  const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json());
  
  return <article>{post.content}</article>;
}

When to use:

  • Blog posts, marketing pages, documentation
  • Content that rarely changes
  • Maximum performance + SEO

SSR — Server-Side Rendering

Generates fresh HTML on every request.

The following example demonstrates the concept in tsx:

// app/dashboard/page.tsx
export const dynamic = 'force-dynamic';  // Always SSR

// Or: a no-store fetch forces SSR automatically
export default async function Dashboard() {
  const data = await fetch('https://api.example.com/live-data', {
    cache: 'no-store',  // Opt out of caching → forces SSR
  }).then(r => r.json());
  
  return <div>{data.value}</div>;
}

When to use:

  • Personalized content (user dashboard, shopping cart)
  • Real-time data (live stock prices, live scores)
  • Content behind authentication

ISR — Incremental Static Regeneration

Serves cached HTML instantly, regenerates in the background after revalidate seconds.

The following example demonstrates the concept in tsx:

// app/products/page.tsx
export const revalidate = 60;  // Regenerate every 60 seconds

export default async function Products() {
  // This fetch is cached for 60 seconds
  const products = await fetch('https://api.example.com/products').then(r => r.json());
  
  return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

When to use:

  • Product listings, news articles, pricing pages
  • Content that changes regularly but not every second
  • You want static performance with fresher data than pure SSG

fetch Cache Semantics

In App Router, caching is primarily controlled at the fetch level:

The following example demonstrates the concept in tsx:

// ① Default: cached (SSG behavior)
const data = await fetch('https://api.example.com/data');

// ② Cached with time-based revalidation (ISR behavior)  
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 },  // 1 hour
});

// ③ Cached with tag-based revalidation
const data = await fetch('https://api.example.com/data', {
  next: { tags: ['products'] },  // Invalidate with revalidateTag('products')
});

// ④ Never cached (SSR behavior)
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store',
});

Practical Fetch Patterns

The following example demonstrates the concept in tsx:

// Blog post (SSG — static, long cache)
const post = await fetch(`/api/posts/${slug}`, {
  next: { tags: [`post-${slug}`] },  // Invalidate when post is updated
});

// Homepage featured section (ISR — refresh hourly)
const featured = await fetch('/api/featured', {
  next: { revalidate: 3600 },
});

// User profile (SSR — always fresh, personalized)
const user = await fetch(`/api/users/${userId}`, {
  cache: 'no-store',
  headers: { Authorization: `Bearer ${token}` },
});

// Config that never changes (permanent cache)
const config = await fetch('/api/config', {
  next: { revalidate: false },  // Cache forever (until next deploy)
});

Route Segment Config

Route Segment Config sets the rendering behavior for an entire route segment, overriding individual fetch settings.

// page.tsx or layout.tsx

// Force static (build-time only)
export const dynamic = 'force-static';

// Force dynamic (always SSR)
export const dynamic = 'force-dynamic';

// Set revalidation interval for the whole segment
export const revalidate = 300;  // 5 minutes

// Don't cache at all
export const revalidate = 0;

Precedence rules:

  1. dynamic = 'force-dynamic' → always SSR, ignores all fetch caches
  2. dynamic = 'force-static' → always static, even if fetch has no-store
  3. revalidate on the segment → sets the floor for all fetches in the segment

On-Demand Revalidation

Don’t wait for the timer — invalidate immediately when data changes.

Import the required modules and set up the dependencies:

// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const { tag, path, secret } = await request.json();
  
  // Verify the secret to prevent abuse
  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }
  
  if (tag) {
    revalidateTag(tag);  // Invalidate all fetches tagged with this
  }
  if (path) {
    revalidatePath(path);  // Regenerate this specific page
  }
  
  return Response.json({ revalidated: true });
}

Trigger from a CMS webhook:

curl -X POST https://yourapp.com/api/revalidate \
  -H "Content-Type: application/json" \
  -d '{"tag": "products", "secret": "your-secret"}'

Use with tagged fetches:

The updateProduct function implements the behavior shown:

// Fetch with a tag
const products = await fetch('/api/products', {
  next: { tags: ['products'] },
});

// In a Server Action
import { revalidateTag } from 'next/cache';

async function updateProduct(id: string, data: FormData) {
  await db.products.update(id, data);
  revalidateTag('products');  // Regenerate all pages using this tag
}

React Server Components vs Client Components

App Router’s default: Server Components — run on the server, zero JS in the bundle.

// app/page.tsx — Server Component (default)
// Can access DB directly, keeps secrets server-side
export default async function Home() {
  const posts = await db.posts.findMany();  // Direct DB access
  return <PostList posts={posts} />;
}
// components/LikeButton.tsx — Client Component
'use client';

import { useState } from 'react';

export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false);
  
  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '❤️' : '🤍'}
    </button>
  );
}

Rule of thumb:

  • Need useState, useEffect, event handlers → 'use client'
  • Fetching data, accessing DB/secrets, no interactivity → Server Component (default)
  • Minimize 'use client' — push it to the leaves of the component tree

Choosing the Right Strategy

Content TypeStrategyConfig
Marketing page, blog postSSGdefault (no config)
Product listing (refreshes hourly)ISRrevalidate = 3600
News feed (refreshes every minute)ISRrevalidate = 60
User dashboardSSRcache: 'no-store'
Admin panelSSRdynamic = 'force-dynamic'
CMS-driven pageISR + on-demandtags: ['cms-page']
Config/reference dataSSGrevalidate = false

Common Pitfalls

1. Caching user-specific data

The following example demonstrates the concept in tsx:

// ❌ WRONG — cached response shared across all users
const cart = await fetch('/api/cart', {
  cache: 'force-cache',  // Never cache user-specific data
});

// ✅ CORRECT
const cart = await fetch('/api/cart', {
  cache: 'no-store',
  headers: { Authorization: `Bearer ${token}` },
});

2. Dynamic functions force SSR

Using these in any Server Component in a segment forces SSR for the entire segment:

import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';

// Any of these opt the segment into SSR:
const cookieStore = cookies();        // forces dynamic
const headersList = headers();        // forces dynamic
redirect('/login');                   // forces dynamic

3. Mixed strategies in one segment

The following example demonstrates the concept in tsx:

// ❌ Confusing — one fetch is static, one is dynamic
const config = await fetch('/api/config');              // cached
const user = await fetch('/api/user', { cache: 'no-store' });  // dynamic

// The no-store fetch forces the entire page to SSR
// Be explicit:
export const dynamic = 'force-dynamic';  // Make intent clear

Debugging Cache Behavior

Run the following commands:

# Check if a page is static or dynamic
curl -I https://yourapp.com/blog/my-post
# Look for: Cache-Control, x-nextjs-cache: HIT/MISS/STALE

# Development — cache is disabled by default
npm run dev  # All fetches are fresh in dev

# Production build — see what's static vs dynamic
npm run build
# Output shows:
# ○ /blog/[slug]   (Static)
# λ /dashboard     (Dynamic)
# ◐ /products      (ISR — revalidate: 60)

Summary

StrategyWhen to useKey config
SSGStatic content, blogdefault
ISR (time-based)Regularly updated contentrevalidate = N
ISR (on-demand)CMS-driven, event-triggeredtags + revalidateTag()
SSRPersonalized, real-timecache: 'no-store' or dynamic = 'force-dynamic'

The key insight: agree on “how stale can this data be?” before writing code. That answer maps directly to your fetch strategy and eliminates most caching bugs.

Related posts: