Wise Hustlers — Digital Product & App Development Studio Logo
Get Consultation
By Wise Hustler Admin9/8/20269 min read

ISR vs SSR vs SSG in Next.js: A Decision Table, Not a Religion

ISR vs SSR vs SSG in Next.js: A Decision Table, Not a Religion

# ISR vs SSR vs SSG in Next.js: A Decision Table, Not a Religion

TL;DR: Use SSG for content that changes rarely (docs, marketing pages), ISR for content that changes on a schedule you can tolerate being briefly stale (product catalogs, blog indexes, pricing pages), and SSR for content that must be correct on every single request (authenticated dashboards, checkout, anything personalized or permission-gated). Pick by update frequency and per-user variance, not by which one sounds more modern.

Every few months someone frames this as SSR being "slower" and SSG being "faster," as if the choice were a performance knob. It isn't. It's a correctness-and-freshness tradeoff. The question is never "which is fastest" — SSG and ISR both serve from cache and will always win a raw latency comparison against SSR. The real question is: how wrong can this page be allowed to be, for how long, before that's a problem?

The three models, briefly

  • SSG (Static Site Generation) — HTML is generated once, at build time. Every visitor gets the same file from the CDN edge until you rebuild and redeploy. Zero server work per request.
  • ISR (Incremental Static Regeneration) — HTML is generated at build time (or on first request), served from cache like SSG, but Next.js regenerates it in the background after a revalidate window expires or when you trigger it on demand. Visitors get stale-while-revalidate behavior, not a live render.
  • SSR (Server-Side Rendering) — HTML is generated fresh on every request, on the server, before it's sent to the browser. No caching by default; you pay the render cost every time (or add your own caching layer).

Next.js's App Router doesn't ask you to declare "I want SSG" the way the old Pages Router did with getStaticProps versus getServerSideProps. Instead, a route's rendering mode falls out of what you do inside it — whether you read cookies()/headers(), whether you set dynamic = 'force-dynamic', and how your fetch() calls are cached. That's the part that trips people up, so it's worth being precise about the current mechanics.

Two caching models exist right now — know which one you're on

This matters more than it used to. As of Next.js 16, there are two caching models, and code that's correct in one is wrong in the other:

1. The previous model (default unless you opt in) — the same mental model from Next.js 13–15: fetch() requests are uncached by default, and you opt into caching per-request with fetch(url, { next: { revalidate: N } }), or set export const revalidate = N at the route segment level to apply it to the whole route.

2. Cache Components — an opt-in flag (cacheComponents: true in next.config.ts) that replaces the old dynamicIO, ppr, and useCache experimental flags with one unified model built around a "use cache" directive and cacheLife()/cacheTag() from next/cache. The revalidate route export is gone in this model — revalidation windows are set inside a cached function instead.

If you started a project before mid-2026 and haven't touched next.config.ts, you're almost certainly on the previous model. Check for cacheComponents: true before copying code samples from a blog post (including this one) into your project.

Previous model: `revalidate` at the fetch or route level

// app/blog/[slug]/page.tsx
export const revalidate = 3600 // whole route revalidates at most once/hour

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const res = await fetch(`https://api.example.com/posts/${slug}`)
  const post = await res.json()
  return <Article post={post} />
}

Or scope it to a single fetch instead of the whole segment:

const res = await fetch(`https://api.example.com/products/${id}`, {
  next: { revalidate: 300 }, // this fetch is fresh for at most 5 minutes
})

If a route has multiple fetches with different revalidate values, Next.js uses the lowest one for the page's regeneration cadence — a page with one fetch at revalidate: 60 and another at revalidate: 3600 behaves like the whole page is set to 60.

Cache Components model: `"use cache"` + `cacheLife`

// app/blog/[slug]/page.tsx
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'

async function getPost(slug: string) {
  'use cache'
  cacheLife('hours') // built-in profile; or pass a custom object
  cacheTag(`post-${slug}`)

  const res = await fetch(`https://api.example.com/posts/${slug}`)
  return res.json()
}

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)
  return <Article post={post} />
}

Invalidate on demand from a webhook or Server Action with revalidateTag('post-my-slug') — same idea as before, but now paired with cacheTag instead of fetch's built-in tag option.

On-demand revalidation (both models)

Scheduled revalidation is the ceiling on staleness, not the only way to refresh. For a CMS save or webhook, trigger it explicitly:

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  const { path, tag, secret } = await req.json()
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ message: 'Invalid token' }, { status: 401 })
  }
  if (path) revalidatePath(path)
  if (tag) revalidateTag(tag)
  return NextResponse.json({ revalidated: true, now: Date.now() })
}

This is what makes ISR usable for editorial content: set a long revalidate as a safety net (say, once a day) and let your CMS call this endpoint the moment an editor hits publish, so you're not waiting on the window.

The decision table

Content typeUpdate frequencyPer-user varianceUseWhy
Marketing/landing pagesRarely (weeks)NoneSSGNo reason to pay render cost when the output never changes between deploys
Docs, changelogsOn deployNoneSSGRebuild is already part of your release process
Blog posts, articlesOccasional editsNoneISRContent is public and shared, but editors need updates without a full redeploy
Product listing/catalog pagesHourly–daily (price/stock drift)LowISR with on-demand revalidationStale-for-a-few-minutes is acceptable; a full SSR round trip per visitor isn't worth it at catalog scale
Search/filter results pagesDepends on queryHigh (query-driven)SSROutput is a function of request params, not cacheable per-URL in any useful way
Authenticated dashboardsContinuousHigh (per-user)SSRMust reflect the requesting user's data; caching risks leaking one user's view to another
Checkout, cart, paymentContinuousHighSSR (often force-dynamic)Correctness matters more than latency; stale inventory or pricing is a real bug, not a UX nit
Pricing pages with A/B tests or geo-pricingRare content, but per-request logicMediumSSR or ISR + client-side personalizationIf the variance is in which static content to show, consider Partial Prerendering instead of full SSR

The pattern in that table: frequency drives the ISR-vs-SSG line, variance drives the ISR-vs-SSR line. If content changes but is the same for everyone, ISR. If content differs per request regardless of how often it changes, SSR.

Where teams get this wrong

The most common mistake isn't picking the wrong strategy — it's picking force-dynamic (full SSR) everywhere out of caution about staleness, then wondering why time-to-first-byte is bad under load. A product listing page doesn't need a live database read on every visitor; it needs a cache that's never more than a few minutes stale, refreshed on demand when inventory actually changes. That's exactly the case ISR was built for, and it's underused relative to how often teams reach for SSR by default.

The second most common mistake is the reverse: applying a long revalidate to a route that reads cookies() or otherwise depends on the request. Next.js will render that route dynamically regardless of your revalidate setting, silently, because request-dependent data can't be statically cached — so the caching config does nothing and the team doesn't find out until someone asks why "the cache isn't working."

If your rendering strategy is inconsistent across a large app — some routes stuck on SSR that don't need to be, others caching data that shouldn't be shared between users — that's usually a sign the app grew route-by-route without anyone auditing the caching model as a whole. That's a fairly mechanical audit (Wise Hustlers' web development team does this kind of Next.js performance pass regularly), but you don't need outside help to do the first pass yourself: for each route, ask "does this vary per user or per request?" If no, it should not be SSR.

FAQ

Does ISR work with dynamic routes and `generateStaticParams`?

Yes. Pair generateStaticParams() with a revalidate export (or cacheLife under Cache Components) to pre-render known paths at build time and regenerate them on the same schedule. Paths not returned by generateStaticParams are generated on first request and then cached the same way, as long as dynamicParams isn't set to false.

Is SSR always slower than ISR?

For the request itself, yes — SSR does the work live, ISR serves a cached file and (at most) revalidates in the background without blocking the response. But "slower" only matters if the page doesn't need per-request accuracy. A dashboard rendered with ISR would be fast and wrong.

Can I mix strategies on the same page?

That's what Partial Prerendering is for: a static shell renders instantly while dynamic, per-request pieces (inside <Suspense>) stream in afterward. Under the Cache Components model, PPR is the default behavior once the flag is enabled, rather than a separate opt-in.

What happens if I forget to set a `revalidate` value at all?

Under the previous (pre–Cache Components) model, a route with no dynamic APIs and no explicit revalidate is treated as static and cached indefinitely until the next deploy — effectively SSG. If any fetch or the route uses cookies(), headers(), or similar, Next.js switches that route to fully dynamic (SSR) automatically, regardless of caching intent.

Sources