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:
dynamic = 'force-dynamic'→ always SSR, ignores all fetch cachesdynamic = 'force-static'→ always static, even if fetch hasno-storerevalidateon 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 Type | Strategy | Config |
|---|---|---|
| Marketing page, blog post | SSG | default (no config) |
| Product listing (refreshes hourly) | ISR | revalidate = 3600 |
| News feed (refreshes every minute) | ISR | revalidate = 60 |
| User dashboard | SSR | cache: 'no-store' |
| Admin panel | SSR | dynamic = 'force-dynamic' |
| CMS-driven page | ISR + on-demand | tags: ['cms-page'] |
| Config/reference data | SSG | revalidate = 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
| Strategy | When to use | Key config |
|---|---|---|
| SSG | Static content, blog | default |
| ISR (time-based) | Regularly updated content | revalidate = N |
| ISR (on-demand) | CMS-driven, event-triggered | tags + revalidateTag() |
| SSR | Personalized, real-time | cache: '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: