Next.js App Router: SSR vs SSG vs ISR

Key takeaways

In the Next.js App Router there is no single "page mode" switch: whether a route is static, dynamic, or revalidated follows from its fetch options, dynamic APIs like cookies(), and Route Segment Config. This guide maps SSG, SSR, and ISR onto those controls, covers generateStaticParams and on-demand revalidation, and explains the defaults that changed in Next.js 15.

Why Rendering Strategy Matters

In the Next.js App Router, a page’s rendering mode is not declared in one place. It emerges from the fetch options in its Server Components, from whether it calls dynamic APIs such as cookies() or headers(), and from Route Segment Config exported by the page or its layouts. A single line can change whether the page:

  • is served as HTML generated at build time (fast, cheap, SSG),
  • is rendered on every request (fresh, more server work, SSR / dynamic rendering),
  • or is served from cache and regenerated in the background (ISR).

That affects TTFB, server cost, and SEO, and it is also the source of the two most painful bugs in App Router projects: pages that show stale data after an update, and per-user data that gets cached and shown to someone else.

This article assumes Next.js 15 behavior and points out where 13/14 differ, because the caching defaults changed and many older examples online no longer do what they claim.


Terminology: Pages Router Names vs App Router Reality

TermIntuitive meaningWhat controls it in App Router
SSGHTML generated at buildRoute is prerendered: no dynamic APIs, and data fetched with force-cache or at build time; generateStaticParams for dynamic segments
SSRHTML per requestDynamic rendering: cookies(), headers(), searchParams, cache: 'no-store', or dynamic = 'force-dynamic'
ISRStatic, refreshed on schedule or on demandrevalidate (segment or per-fetch), revalidatePath, revalidateTag

With React Server Components, “SSR or SSG” becomes a question of when the server component tree is rendered and how long the result is cached. Components run on the server either way; the difference is whether that happens once at build, once per revalidation window, or on every request.


The Three Strategies

SSG — Static Site Generation

Generated at build time and served from the CDN or the Next.js cache.

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params; // params is a Promise since Next.js 15
  const post = await fetch(`https://api.example.com/posts/${slug}`, {
    cache: "force-cache", // explicit: required in Next.js 15, default in 13/14
  }).then((r) => r.json());

  return <article>{post.content}</article>;
}

When to use: blog posts, marketing pages, documentation, anything that changes only when you deploy or publish.

SSR — Dynamic Rendering

Generates fresh HTML on every request.

// app/dashboard/page.tsx
import { cookies } from "next/headers";

export default async function Dashboard() {
  const token = (await cookies()).get("session")?.value; // dynamic API → dynamic route
  const data = await fetch("https://api.example.com/me", {
    cache: "no-store",
    headers: { Authorization: `Bearer ${token}` },
  }).then((r) => r.json());

  return <div>{data.name}</div>;
}

When to use: personalized content (dashboards, carts), content behind authentication, and data that must be correct to the second.

ISR — Incremental Static Regeneration

Serves cached HTML immediately and regenerates in the background once the revalidate window has passed.

// app/products/page.tsx
export const revalidate = 60; // seconds

export default async function Products() {
  const products = await fetch("https://api.example.com/products", {
    next: { revalidate: 60 },
  }).then((r) => r.json());

  return (
    <ul>
      {products.map((p: { id: string; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

The semantics are stale-while-revalidate: the first request after the window expires still gets the old page, and triggers a regeneration; later requests get the new one. If regeneration fails (the API is down), Next.js keeps serving the last good version rather than an error, which is a useful property for pages backed by flaky upstreams.

When to use: product listings, news, pricing pages — content that changes regularly but where being a minute behind is acceptable.


fetch Cache Semantics

Per-request caching is set on fetch itself:

// ① Cache indefinitely (until revalidated or redeployed)
const a = await fetch("https://api.example.com/data", { cache: "force-cache" });

// ② Time-based revalidation (ISR behavior)
const b = await fetch("https://api.example.com/data", { next: { revalidate: 3600 } });

// ③ Tag-based revalidation
const c = await fetch("https://api.example.com/data", { next: { tags: ["products"] } });

// ④ Never cache (dynamic behavior)
const d = await fetch("https://api.example.com/data", { cache: "no-store" });

The default changed. In Next.js 13 and 14, a bare fetch() in a Server Component was treated as force-cache, which surprised many people with stale data. Since Next.js 15, a bare fetch() is not cached. Code written for 14 that relied on the old default can silently become slower after an upgrade, and code written for 15 that leaves options off can become unexpectedly stale if someone copies it into a 14 project. Being explicit about cache options on every fetch removes that ambiguity.

Only fetch is cached this way. Direct database calls or SDK clients (Prisma, Drizzle, a CMS SDK) are not touched by fetch caching; for those, the segment-level revalidate/dynamic settings decide whether the route is static, and unstable_cache (or the newer "use cache" directive where enabled) caches individual functions.

Practical fetch patterns

// Blog post: static, invalidated when that post is edited
const post = await fetch(`https://api.example.com/posts/${slug}`, {
  next: { tags: [`post-${slug}`] },
});

// Homepage featured section: refresh hourly
const featured = await fetch("https://api.example.com/featured", {
  next: { revalidate: 3600 },
});

// User profile: always fresh, personalized
const user = await fetch(`https://api.example.com/users/${userId}`, {
  cache: "no-store",
  headers: { Authorization: `Bearer ${token}` },
});

// Reference data that only changes on deploy
const config = await fetch("https://api.example.com/config", {
  cache: "force-cache",
});

Note that fetch in Server Components needs an absolute URL; relative paths like /api/posts only work in the browser. Calling your own Route Handlers from Server Components is also usually unnecessary: import the data function directly instead of making an HTTP round trip to yourself.


Route Segment Config

Route Segment Config sets behavior for an entire route segment from page.tsx or layout.tsx. A layout’s settings apply to every page below it.

// Force dynamic rendering (equivalent to no-store on every fetch, dynamic APIs allowed)
export const dynamic = "force-dynamic";

// Force static rendering: cookies()/headers() return empty values, fetches are cached
export const dynamic = "force-static";

// Fail the build if anything in this segment would make it dynamic
export const dynamic = "error";

// Default revalidation interval for the segment
export const revalidate = 300; // 5 minutes

// Override the cache behavior of every fetch in the segment
export const fetchCache = "force-no-store";

// What happens for dynamic params not returned by generateStaticParams
export const dynamicParams = true; // generate on first request (default); false → 404

How the settings interact:

  1. dynamic = 'force-dynamic' renders on every request regardless of fetch options.
  2. dynamic = 'force-static' prerenders even if code calls cookies() or uses no-store; dynamic APIs return empty values instead of forcing dynamic rendering. That is safe for public pages and dangerous for anything personalized.
  3. When several revalidate values apply (layout, page, and individual fetches), the lowest one determines how often the route regenerates. A single revalidate: 10 fetch deep in a shared component makes every page that renders it regenerate every 10 seconds.
  4. revalidate = 0 means the route is always dynamically rendered.

dynamic = 'error' is underused. On pages that must stay static (landing pages, docs), it turns an accidental cookies() call in a shared component into a build error instead of a silent switch to per-request rendering and a jump in server load.


generateStaticParams: Controlling What Gets Prerendered

For dynamic segments like [slug], generateStaticParams returns the list of params to prerender at build time:

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

Anything not in that list is handled according to dynamicParams: with the default true, the page is rendered on the first request and then cached like any other static page (on-demand ISR); with false, it returns 404.

That combination matters once the list grows. If a CMS has tens of thousands of entries, prerendering all of them makes every build slow and hammers the CMS API during the build. The common approach is to return only the most-visited N entries (say, the top few hundred by traffic) and let the long tail render on first request. Returning an empty array with dynamicParams = true is also valid and means “render everything on demand, but cache it once rendered”.


On-Demand Revalidation

Don’t wait for the timer — invalidate as soon as the data changes.

// 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 so random callers cannot flush your cache
  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  if (tag) revalidateTag(tag); // invalidates every fetch tagged with it
  if (path) revalidatePath(path); // invalidates this route

  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"}'

Or from a Server Action after a mutation:

"use server";
import { revalidateTag } from "next/cache";

export async function updateProduct(id: string, data: FormData) {
  await db.products.update(id, data);
  revalidateTag("products"); // pages using this tag regenerate on their next request
}

Tags are usually more reliable than paths. revalidatePath("/products") only covers that route (pass "layout" as the second argument to include everything under it), while a products tag covers every page that fetched product data, including the homepage widget and category pages you might forget to list. Revalidation marks cached data as stale; the page is regenerated on its next visit, not immediately.


React Server Components vs Client Components

App Router components are Server Components by default: they run on the server and ship no JavaScript for themselves.

// app/page.tsx — Server Component (default)
export default async function Home() {
  const posts = await db.posts.findMany(); // direct DB access, secrets stay on the server
  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 ? "Liked" : "Like"}</button>;
}

Rule of thumb:

  • Need useState, useEffect, or event handlers → "use client"
  • Fetching data, accessing the database or secrets, no interactivity → Server Component
  • Push "use client" to the leaves: a client component makes everything it imports part of the client bundle

Whether a Server Component is static or dynamic is independent of this split. A static page can contain client components (they hydrate in the browser), and a dynamic page can be all Server Components.


Choosing the Right Strategy

Content typeStrategyConfig
Marketing page, legal, docsSSGforce-cache / no dynamic APIs; dynamic = 'error' to enforce
Blog post from a CMSSSG + on-demandgenerateStaticParams + tags + webhook
Product listingISR + on-demandrevalidate = 300 + revalidateTag('products')
News feedISRrevalidate = 60
User dashboard, cartDynamiccookies() + cache: 'no-store'
Admin panelDynamicdynamic = 'force-dynamic' in the admin layout
Real-time pricesDynamic or client pollingno-store; Next.js caching alone is not enough

How this looks in real applications

  • E-commerce listing: the product list uses revalidate: 300 plus a products tag. When an admin changes a price, the Server Action calls revalidateTag('products'), so the listing is correct on the next visit instead of up to five minutes later. The cart and checkout stay dynamic.
  • Logged-in header on otherwise public pages: reading the session in the root layout makes every page dynamic. Keep the page shell static and move the user menu into a small client component that fetches /api/me, or into a separate dynamic subtree, so the personalized surface stays as small as possible.
  • Documentation site: pages are fully static with dynamic = 'error' in the docs layout; search runs through a client component and a separate API, so indexing and rendering are decoupled and a search outage cannot break page rendering.

Common Pitfalls

Caching user-specific data

// ❌ WRONG — one cached response shared by every user
const cart = await fetch("https://api.example.com/cart", {
  cache: "force-cache",
  headers: { Authorization: `Bearer ${token}` },
});

// ✅ CORRECT
const cart = await fetch("https://api.example.com/cart", {
  cache: "no-store",
  headers: { Authorization: `Bearer ${token}` },
});

This is the bug I worry about most in App Router code reviews, because it does not show up in development (where caching behaves differently) or in a test with one user. It appears in production when a second user sees the first user’s name or cart. The rule I follow: any fetch that sends a session token or cookie is no-store, and any segment under an authenticated layout gets dynamic = 'force-dynamic' so a stray force-cache cannot turn it static. Adding force-static to “speed up” such a page is the same bug, since it makes cookies() return nothing and caches the anonymous result.

Dynamic APIs make the whole route dynamic

import { cookies, headers } from "next/headers";

// Any of these in a page, layout, or component it renders makes the route dynamic:
const cookieStore = await cookies();
const headersList = await headers();
// ...as does reading searchParams in a page

(In Next.js 15 these APIs are async; in 14 they were synchronous.) The call can hide in a shared component such as an analytics wrapper or a locale detector in the root layout, and switch the entire site to dynamic rendering. The build output is where you notice.

Mixed intentions in one segment

const config = await fetch("https://api.example.com/config", { cache: "force-cache" });
const user = await fetch("https://api.example.com/user", { cache: "no-store" });
// The no-store fetch makes the route dynamic; config is still served from the data cache.

Mixing is legitimate (cached reference data inside a dynamic page is a good pattern), but make the route’s intent explicit with dynamic = 'force-dynamic' or 'error' so the next person does not have to infer it.

Stale data after navigation, not after reload

If a hard reload shows fresh data but client-side navigation shows old data, the server cache is fine and the culprit is the client Router Cache, which keeps recently visited route payloads in the browser. Calling router.refresh() after a mutation, or revalidatePath/revalidateTag inside the Server Action (which also clears the relevant client cache), fixes it.


Debugging Cache Behavior

# Build and read the route table
npm run build
# ○  (Static)   prerendered as static content
# ●  (SSG)      prerendered as static HTML (uses generateStaticParams)
# ƒ  (Dynamic)  server-rendered on demand
# ISR routes show their revalidate interval next to the route

# Inspect a production response
curl -I https://yourapp.com/blog/my-post
# x-nextjs-cache: HIT / MISS / STALE (when served by next start)

Test caching with next build && next start, not next dev: the dev server renders on every request and does not reproduce production caching. The build table is the fastest sanity check after any change to layouts, because a route flipping from ○ to ƒ is exactly the “someone added cookies() to a shared component” regression.