Next.js App Router: SSR, SSG, and ISR | Rendering Strategy and Caching
이 글의 핵심
Compare SSR, SSG, and ISR in the App Router with fetch caching, revalidate, dynamic segments, and content-type selection criteria.
Introduction
In the Next.js App Router (13+), how each page is built on the server and how long it is cached drives performance, SEO, and cost. Stopping at Next.js App Router SSR vs SSG names alone makes it easy to hit unintended caching from a single fetch line.
This post maps static generation, server rendering, and incremental revalidation (ISR) on top of the default server components model, and shows how to control behavior consistently with revalidate and Route Segment Config. It assumes App Router + fetch cache semantics (2026).
In production, CDN, data cache, and server component caches stack—“docs say X but I see old data” is common. Below: concepts → code → advanced settings → selection criteria → cases → troubleshooting.
After reading this post
- Distinguish what SSR, SSG, and ISR mean in the App Router
- Design per-fetch caching with
fetchoptions andrevalidate - Split strategies for dynamic routes, personalization, and admin UIs
Table of contents
- Concepts
- Hands-on implementation
- Advanced: Route Segment Config
- Performance comparison
- Real-world cases
- Troubleshooting
- Wrap-up
Concepts
Terminology (vs Pages Router intuition)
| Term | Intuitive meaning | App Router reality |
|---|---|---|
| SSG | HTML at build (or regen) time | Paths where server component trees can be cached statically—e.g. fetch(..., { cache: 'force-cache' }) |
| SSR | HTML per request | Dynamic rendering—closer to cache: 'no-store' or dynamic = 'force-dynamic' |
| ISR | Refresh static output on a schedule or on demand | fetch revalidate seconds or revalidatePath / revalidateTag |
The App Router creates cache boundaries from fetch calls and segment settings, not a single “page mode” switch.
React Server Components (RSC)
Server components run on the server by default; the client receives serialized output. “SSR or SSG” becomes when and how that result is cached.
Hands-on implementation
2-1. SSG-like: fixed data at build
// app/posts/page.tsx — cache at build time (force-cache)
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache',
});
const posts = await res.json();
return (
<ul>
{posts.map((p: { id: string; title: string }) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
2-2. SSR: fresh data every request
// app/dashboard/page.tsx
export default async function DashboardPage() {
const res = await fetch('https://api.example.com/me', {
cache: 'no-store',
});
const user = await res.json();
return <div>{user.name}</div>;
}
2-3. ISR: time-based revalidation
// app/blog/[slug]/page.tsx
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 }, // background revalidate every hour
});
if (!res.ok) notFound();
const post = await res.json();
return <article>{post.body}</article>;
}
2-4. Tag-based invalidation (operations)
// on fetch
await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
// in a Server Action or Route Handler
import { revalidateTag } from 'next/cache';
export async function POST() {
revalidateTag('posts');
return Response.json({ ok: true });
}
Summary: one route can mix strategies if different components use different fetch settings. A per-data-source cache policy table reduces mistakes.
Advanced: Route Segment Config and static/dynamic boundaries
Force dynamic at segment level
// app/admin/layout.tsx
export const dynamic = 'force-dynamic';
export const fetchCache = 'force-no-store';
dynamic:'auto' | 'force-dynamic' | 'error' | 'force-static'— default tendency for the routerevalidate: segment-level default ISR interval (understand together with route-wide revalidate)
generateStaticParams for SSG scope
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const slugs = await getAllSlugs(); // build-time list
return slugs.map((slug) => ({ slug }));
}
If a CMS exports tens of thousands of slugs, do not prerender all at build—top N static, rest on-demand ISR.
Performance comparison
| Scenario | Direction | Why |
|---|---|---|
| Marketing landing, docs, legal | SSG + long revalidate or fully static | Max CDN hit rate, stable TTFB |
| Product dashboard, cart | SSR (no-store) or client split | Per-user data; avoid cache poisoning |
| Blog, catalog | ISR + revalidateTag | Balance traffic vs freshness |
| Real-time stock/price | SSR + short TTL or Edge + external cache | Next cache alone may not be enough |
Key: prefer agreeing “how stale can this fetch be?” over labeling a whole page “SSG.”
Real-world cases
- E-commerce listing: list
revalidate: 300+ tagproducts; on price changerevalidateTag('products'). - Post-login header:
cache: 'no-store'for user info; keep shared nav static in a separate slice to minimize personalization surface. - Docs site: mostly SSG; search via client or separate API—split rendering from search indexing.
Troubleshooting
“Build is fresh; production shows stale data”
Check fetch cache vs revalidate, and host/CDN data cache settings.
“revalidatePath did nothing”
Verify the same segment tree and cache keys; tag-based invalidation is often more reliable.
“Almost leaked personal data across users”
Never force-cache per-user APIs. Session/cookie-sensitive fetch should use no-store or a separate permission boundary.
Wrap-up
SSR vs SSG in the App Router is best understood as the cache story from fetch and segment config, not labels alone. Document per-layer cache standards (TTL, tags, invalidation triggers) so teams spend less time re-debating performance vs freshness. Pair with the Node.js performance post for async and server load.