Rendering Strategies: SSR, SSG, and ISR
- nextjs
- react
- ssr
- ssg
- isr
- webdev
- typescript
- performance
- caching
- frontend
The last article in this series covered SEO with the Next.js Metadata API, and one question kept coming up without a clean answer: metadata is generated wherever the page's data comes from. Where that data comes from is exactly what this article is about. This is part of the React & Next.js Complete Guide series. Rendering strategy is the decision that caching, SEO, cost, and perceived speed all sit on top of.
Most teams pick a rendering strategy once and never revisit it per route. That's a mistake. A marketing page, a product catalog, and a logged-in dashboard have different freshness requirements, and the App Router lets you mix strategies at the route level. Getting this wrong shows up as stale content nobody trusts, or a server doing full rendering work for pages that never change.
I want to walk through what SSR, SSG, and ISR mean under the App Router's caching model. The mental model changed meaningfully from the Pages Router, and a lot of production confusion traces back to assuming the old rules still apply.
Why rendering strategy is a per-route decision, not a global one
Every page answers two questions: when does the HTML get generated, and how often does it need to change. Static generation answers "at build time, rarely." Server-side rendering answers "on every request, always fresh." ISR sits in between: generate once, then regenerate on a schedule or on demand, without a full rebuild.
The App Router treats these as configuration on a route, not a separate API you call. A fetch call's caching behavior, a route segment's dynamic export, and a page's revalidate value together determine which bucket a route falls into. That's a shift from the Pages Router, where getStaticProps, getServerSideProps, and getStaticProps with revalidate were three explicit functions you chose between. Now the defaults are implicit, and that's the most common source of confusion in code review: engineers assume a route is static, and one fetch call without cache options quietly made the whole page dynamic.
The practical fix: decide the freshness requirement for each route first, in plain language ("changes once a day," "must reflect the current session," "five minutes stale is fine"), then translate that into the right App Router primitives, rather than starting from the API surface and working backward.
Static generation: rendering at build time
Static generation renders a route's HTML once, at build time, and serves the same output to every visitor until the next build. It's the cheapest and fastest option in production because there's no rendering cost per request; a CDN can serve the file directly.
// app/blog/[slug]/page.tsx
type BlogPost = {
slug: string;
title: string;
content: string;
};
export async function generateStaticParams() {
const res = await fetch("https://api.example.com/posts", {
cache: "force-cache",
});
const posts: BlogPost[] = await res.json();
return posts.map((post) => ({ slug: post.slug }));
}
async function getPost(slug: string): Promise<BlogPost> {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
cache: "force-cache",
});
if (!res.ok) {
throw new Error(`Failed to load post: ${slug}`);
}
return res.json();
}
export default async function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
generateStaticParams tells Next.js which dynamic routes to pre-render at build time, and cache: "force-cache" (the default when nothing else is specified) caches the response indefinitely until the next build or an explicit revalidation.
Static generation is the right default for content that doesn't depend on the requesting user: marketing pages, documentation, blog posts, product pages without live inventory or personalized pricing. The tradeoff is build time; a catalog with fifty thousand product pages means fifty thousand pages get generated on every deploy unless you're selective about what generateStaticParams returns.
The other tradeoff is staleness by construction. If the underlying data changes, static output doesn't, until you rebuild or revalidate. For a blog post that rarely changes after publishing, that's a feature. For a page showing today's price, it's a bug waiting to be filed.
Server-side rendering: rendering per request
Server-side rendering generates HTML on every request. The App Router makes a route dynamic (and therefore server-rendered on each request) whenever it reads something request-specific: cookies, headers, search params, or an explicit opt-out of caching.
// app/dashboard/page.tsx
import { cookies } from "next/headers";
async function getAccountSummary(sessionId: string) {
const res = await fetch(`https://api.example.com/account/summary`, {
headers: { Authorization: `Bearer ${sessionId}` },
cache: "no-store",
});
if (!res.ok) {
throw new Error("Failed to load account summary");
}
return res.json();
}
export default async function DashboardPage() {
const sessionId = cookies().get("session_id")?.value;
if (!sessionId) {
throw new Error("Missing session");
}
const summary = await getAccountSummary(sessionId);
return (
<section>
<h1>Welcome back</h1>
<p>Balance: {summary.balance}</p>
</section>
);
}
Reading cookies() here is what opts the route into dynamic rendering; cache: "no-store" on the fetch call reinforces that this specific request should never be served from cache. You can also be explicit at the route segment level with export const dynamic = "force-dynamic", so it's unambiguous in code review that a page is intentionally server-rendered, rather than dynamic as a side effect of a cookie read buried in a component tree.
SSR is the correct choice for anything user-specific or that must reflect the current instant: authenticated dashboards, checkout flows, real-time inventory checks tied to a specific transaction. The cost is real: every request does the full render, so capacity has to scale with traffic and response time includes however long data fetching takes. A slow downstream API on an SSR route means a slow page load for every visitor, with no cache to fall back on.
A pitfall worth calling out: it's easy to make a route dynamic by accident. One fetch call without cache options, inside a component that's otherwise static, opts the entire route into dynamic rendering and silently removes the caching benefit you thought you had. Check this explicitly on any route you intend to keep static, since the App Router won't warn you when a nested component quietly changes the page's caching behavior.
Incremental static regeneration: revalidating without a full rebuild
ISR is static generation with an expiration date. The page is generated once, served from cache, and then regenerated in the background after a specified interval, without requiring a new deploy.
// app/products/[id]/page.tsx
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 300 },
});
if (!res.ok) {
throw new Error(`Failed to load product: ${id}`);
}
return res.json();
}
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id);
return (
<section>
<h1>{product.name}</h1>
<p>{product.price}</p>
</section>
);
}
Setting next: { revalidate: 300 } means the cached response is treated as fresh for five minutes. After that, the next request gets the stale cached version immediately, so the visitor never waits on a slow origin fetch, while Next.js regenerates the page in the background and swaps in the fresh version for subsequent requests. That's stale-while-revalidate, and it's what makes ISR attractive: static-file speed with data that's never more than one revalidation window out of date.
For cases where a fixed interval isn't precise enough, on-demand revalidation invalidates specific pages the moment underlying data changes, typically from a webhook or a Server Action after a write:
// app/actions/update-product.ts
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function updateProductPrice(productId: string, newPrice: number) {
await fetch(`https://api.example.com/products/${productId}`, {
method: "PATCH",
body: JSON.stringify({ price: newPrice }),
headers: { "Content-Type": "application/json" },
});
revalidatePath(`/products/${productId}`);
revalidateTag(`product-${productId}`);
}
revalidatePath invalidates a specific route's cache; revalidateTag invalidates every cached fetch call tagged with that value, which is useful when several routes render the same underlying data and all need to reflect a change together. Tagging fetches (next: { tags: ["product-123"] }) at the point of fetching is what makes revalidateTag precise instead of a blunt instrument.
ISR is the right default for content that changes but not on every request: product pages with prices that update occasionally, category listings, articles with comment counts. The tradeoff is complexity: you now have a cache invalidation strategy to maintain, and a careless one produces exactly the bug it was meant to prevent: a page showing old data because a tag or path wasn't invalidated, or a revalidation window so wide that "eventually consistent" starts to feel like "just wrong."
Choosing between them, and where teams get it wrong
In practice, most production apps mix all three within the same codebase, and that's correct. Marketing and documentation pages are static. Authenticated, per-user pages are server-rendered. Content that changes on a schedule, product data, listings, aggregated stats, is ISR.
The pitfall I see most often is picking a reasonable strategy for a route and never revisiting the assumption behind it as traffic or data patterns shift. A few concrete failure modes worth planning for:
- Revalidation windows set once and never revisited. A five-minute window was fine when the data changed a few times a day; nobody notices when write frequency goes up tenfold and the page is now stale for real users.
- CDN and Next.js cache layers disagreeing. A CDN in front of Next.js has its own TTLs. On-demand revalidation at the Next.js layer does nothing if the CDN keeps serving its own cached copy underneath it.
- Treating dynamic rendering as free. SSR routes have no fallback when a downstream dependency is slow; the request just hangs. Static and ISR routes degrade more gracefully.
- Build times becoming a deploy bottleneck. A large
generateStaticParamsset slows every deploy, a signal to move rarely visited pages to ISR instead.
These aren't arguments against static generation or ISR. Caching strategy just needs periodic review, the same way you'd revisit indexes or rate limits as traffic changes.
Key takeaways
- Rendering strategy is a per-route decision. Mixing SSG, SSR, and ISR within the same app is normal and usually correct, not a sign of an inconsistent architecture.
- Static generation is cheapest at request time but stale by construction until the next build or revalidation; it fits content that doesn't depend on the requesting user.
- Server-side rendering is necessary for anything user-specific or that must be current at the instant of the request, but it has no cache to fall back on when a downstream dependency is slow.
- ISR gives you static-file speed with bounded staleness. Use `revalidate` for time-based freshness and `revalidateTag`/`revalidatePath` for precise, event-driven invalidation.
- A single `fetch` call without cache options, or reading `cookies()`/`headers()` anywhere in a route's component tree, silently makes the whole route dynamic. Check this explicitly in code review on routes you intend to keep static.
- Cache invalidation needs to be coordinated across every layer that caches a response, not just the Next.js layer; a CDN sitting in front of your app has its own TTLs that on-demand revalidation won't touch.
Frequently asked questions
What's the actual difference between SSR and ISR if both hit the server?
SSR renders on every request with no cached fallback; the visitor waits for the full render every time. ISR renders once and serves a cached copy for the revalidation window, regenerating in the background so most visitors never wait. ISR trades some staleness for consistently fast responses.
Does using `cookies()` always force a route to be dynamic?
Yes. Reading `cookies()` or `headers()` anywhere in a route's component tree opts it into dynamic, per-request rendering, because those APIs only make sense in the context of a specific incoming request.
How do I know if a route accidentally became dynamic when I meant it to be static?
Check every `fetch` call in the route for missing cache options, and check for `cookies()`, `headers()`, or `searchParams` anywhere in its component tree. Next.js's build output also reports which routes rendered statically versus dynamically.
Is ISR safe for pages that show pricing or inventory?
It depends on how much staleness is acceptable. A short revalidation window plus on-demand `revalidateTag` calls triggered by an actual data change gives you both a safety net and near-immediate updates. A long, purely time-based window is riskier for anything customers expect to be current.
Do I need a CDN in front of Next.js if I'm already using ISR?
Often yes, for latency and to reduce origin load, but it adds a second cache layer with its own invalidation rules. Any on-demand revalidation strategy has to account for both, or you'll invalidate the Next.js cache while the CDN keeps serving its stale copy.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.