Next.js App Router: SSR, SSG, and ISR | Rendering Strategy and Caching

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 fetch options and revalidate
  • Split strategies for dynamic routes, personalization, and admin UIs

Table of contents

  1. Concepts
  2. Hands-on implementation
  3. Advanced: Route Segment Config
  4. Performance comparison
  5. Real-world cases
  6. Troubleshooting
  7. Wrap-up

Concepts

Terminology (vs Pages Router intuition)

TermIntuitive meaningApp Router reality
SSGHTML at build (or regen) timePaths where server component trees can be cached statically—e.g. fetch(..., { cache: 'force-cache' })
SSRHTML per requestDynamic rendering—closer to cache: 'no-store' or dynamic = 'force-dynamic'
ISRRefresh static output on a schedule or on demandfetch 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 route
  • revalidate: 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

ScenarioDirectionWhy
Marketing landing, docs, legalSSG + long revalidate or fully staticMax CDN hit rate, stable TTFB
Product dashboard, cartSSR (no-store) or client splitPer-user data; avoid cache poisoning
Blog, catalogISR + revalidateTagBalance traffic vs freshness
Real-time stock/priceSSR + short TTL or Edge + external cacheNext 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 + tag products; on price change revalidateTag('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.