Technical SEO with Next.js App Router | SSR, SSG, ISR & Cache Boundaries

Technical SEO with Next.js App Router | SSR, SSG, ISR & Cache Boundaries

이 글의 핵심

Map rendering strategy to crawlability and freshness: static cache for marketing, no-store for personalized routes, and tag-based revalidation for catalogs.

Introduction

In Next.js App Router (13+), how pages are built on the server and how aggressively they cache shapes performance, SEO, and operating cost at once. Knowing SSR vs SSG vs ISR only by name is not enough—one fetch option can change caching behavior in surprising ways.

This article maps static generation, server rendering, and incremental revalidation onto server components, fetch cache semantics, and Route Segment Config. It assumes 2026-era App Router + fetch cache behavior.

After reading this post

  • Distinguish what SSG / SSR / ISR mean in App Router
  • Design fetch options and revalidate per data source
  • 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. Conclusion

Concepts

Terminology (vs Pages Router intuition)

TermIntuitive meaningApp Router reality
SSGHTML at build (or regenerate) timePaths that can statically cache server component trees—often fetch(..., { cache: 'force-cache' }) patterns
SSRHTML per requestDynamic rendering—closer to cache: 'no-store' or dynamic = 'force-dynamic'
ISRPeriodic or on-demand static refreshfetch revalidate seconds or revalidatePath / revalidateTag

App Router creates cache boundaries from fetch + segment config, not a single page-level toggle.

React Server Components (RSC)

Server components run on the server by default; the client bundle receives serialized output. “SSR vs SSG” becomes when and how that output is cached.


Hands-on implementation

SSG-like: build-time cached data

// app/posts/page.tsx — cache at build (similar to default 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>
  );
}

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>;
}

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>;
}

Tag-based invalidation (great for 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: Mixed strategies inside one route are normal—define a per-source cache policy table for the team.


Advanced: Route Segment Config

Force dynamic segments

// app/admin/layout.tsx
export const dynamic = 'force-dynamic';
export const fetchCache = 'force-no-store';
  • dynamic: 'auto' | 'force-dynamic' | 'error' | 'force-static' — default behavior for the route tree
  • revalidate: segment-level default revalidation window (use alongside fetch-level settings)

generateStaticParams for SSG scope

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const slugs = await getAllSlugs(); // list available at build time
  return slugs.map((slug) => ({ slug }));
}

If a CMS exports tens of thousands of slugs, prebuild only top pages and rely on on-demand ISR for the tail.


Performance comparison

SituationDirectionWhy
Marketing, docs, legalSSG + long revalidate or fully staticMax CDN hit ratio, stable TTFB
Dashboards, cartsSSR (no-store) or split client islandsPer-user data, avoid cache poisoning
Blogs, catalogsISR + revalidateTagBalance traffic vs freshness
Real-time inventory/pricingSSR + short TTL or edge + external cacheNext cache alone may be insufficient

Key idea: Prefer agreeing “how stale can this fetch be?” over labeling a page “SSG” in isolation.


Real-world cases

  • E-commerce listing: revalidate: 300 + products tag; call revalidateTag('products') on price updates.
  • Logged-in header: no-store for user info; keep shared nav fragments static to minimize personalized surface area.
  • Docs site: Mostly SSG; isolate search to client or a separate API—separate rendering from search indexing strategy.

Troubleshooting

“Build is fresh but production shows stale data”

  • Check fetch cache vs revalidate vs CDN/hosting data caches.

revalidatePath did not update”

  • Verify matching segment tree and cache keystag-based invalidation is often more reliable.

“Almost leaked user data across sessions”

  • Never force-cache per-user fetches—use no-store or route boundaries that separate auth contexts.

Conclusion

SSR vs SSG vs ISR in App Router is really about fetch + segment configuration telling a caching story. Document per-layer TTLs, tags, and invalidation triggers so performance and freshness debates shrink. For async load and server cost, pair with the Node.js performance guide.