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
fetchoptions andrevalidateper data source - 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
- Conclusion
Concepts
Terminology (vs Pages Router intuition)
| Term | Intuitive meaning | App Router reality |
|---|---|---|
| SSG | HTML at build (or regenerate) time | Paths that can statically cache server component trees—often fetch(..., { cache: 'force-cache' }) patterns |
| SSR | HTML per request | Dynamic rendering—closer to cache: 'no-store' or dynamic = 'force-dynamic' |
| ISR | Periodic or on-demand static refresh | fetch 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 treerevalidate: 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
| Situation | Direction | Why |
|---|---|---|
| Marketing, docs, legal | SSG + long revalidate or fully static | Max CDN hit ratio, stable TTFB |
| Dashboards, carts | SSR (no-store) or split client islands | Per-user data, avoid cache poisoning |
| Blogs, catalogs | ISR + revalidateTag | Balance traffic vs freshness |
| Real-time inventory/pricing | SSR + short TTL or edge + external cache | Next 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+productstag; callrevalidateTag('products')on price updates. - Logged-in header:
no-storefor 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
fetchcache vsrevalidatevs CDN/hosting data caches.
“revalidatePath did not update”
- Verify matching segment tree and cache keys—tag-based invalidation is often more reliable.
“Almost leaked user data across sessions”
- Never
force-cacheper-user fetches—useno-storeor 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.