I am a...
Learn more
How it worksPricingFAQ
Account
May 8, 2026 · 10 min read · Cadence Editorial

How to choose ISR vs SSR vs SSG in Next.js

isr vs ssr vs ssg — How to choose ISR vs SSR vs SSG in Next.js
Photo by [Brett Sayles](https://www.pexels.com/@brett-sayles) on [Pexels](https://www.pexels.com/photo/server-racks-on-data-center-5480781/)

How to choose ISR vs SSR vs SSG in Next.js

Pick SSG for content that rarely changes (marketing pages, docs), SSR for per-request data (dashboards, anything reading cookies), and ISR (revalidate=N) for content that updates on a known cadence (blog posts, product catalogs). In the Next.js 15 App Router, this is no longer a decision per page; it is a decision per fetch, controlled by force-static, force-dynamic, or revalidate, with PPR letting you mix both inside one route.

That is the answer. The rest of this post is the decision tree, the working code, and the cache-invalidation patterns nobody publishes.

Why this decision changed in Next.js 15

The old Pages Router gave you three functions (getStaticProps, getServerSideProps, getStaticProps + revalidate) and a clean mental model: one page, one strategy. The App Router blew that up.

Two things shifted that most articles still get wrong:

  1. fetch() is no longer cached by default. Next.js 14 cached every fetch. Next.js 15 reversed it. If you migrated a project and your origin traffic 10x'd, this is why.
  2. Rendering is per-segment, not per-page. A single route can have a static shell, a dynamic user menu, and an ISR-cached product list, all rendered together via Partial Prerendering (PPR). The question "is this page SSG or SSR" stops making sense.

So the correct framing in 2026 is: every fetch and every Server Component has a caching posture. Your job is to set it deliberately, not let the framework guess.

The decision tree (use this, not vibes)

Start with the content. Ask three questions in order.

Question 1: Does this response depend on the request (cookies, headers, search params, user session)? If yes, SSR. You have no choice. Reading cookies(), headers(), or searchParams automatically opts the route into dynamic rendering. Don't fight it.

Question 2: Does the data change more often than you can afford to redeploy? If no (think: a pricing page, a docs site, a marketing landing), use SSG. Pure static, served from the edge, near-zero origin cost.

Question 3: Does the data change on a predictable cadence, or via a known event (CMS publish, inventory sync, daily cron)? If yes, ISR. You get static-page performance with a freshness window you control. Pair it with revalidateTag on a webhook and you get the best of both.

That is the whole tree. Everything else (PPR, streaming, client components) is an optimization on top of one of those three baselines.

StrategyWhen to useApp Router primitiveOrigin cost
SSG (force-static)Marketing, docs, legal, blog indexexport const dynamic = 'force-static' or no dynamic APIsOne render per deploy
ISR (revalidate=N)Blog posts, product catalogs, news, leaderboardsexport const revalidate = 3600 or fetch(url, { next: { revalidate } })One render per N seconds per page
SSR (force-dynamic)Dashboards, account pages, anything reading cookiesexport const dynamic = 'force-dynamic' or call cookies()/headers()One render per request
PPRSEO + dynamic mix on the same routeexperimental_ppr = true + <Suspense> boundariesStatic shell free, dynamic islands per request

The four primitives, with code

These are the only knobs you need to know. Everything else in the docs is a derivative.

SSG: force-static

A marketing page that pulls copy from a CMS at build time:

// app/(marketing)/pricing/page.tsx
export const dynamic = 'force-static';

export default async function Pricing() {
  const tiers = await fetch('https://cms.example.com/pricing', {
    cache: 'force-cache',
  }).then(r => r.json());

  return <PricingTable tiers={tiers} />;
}

The page renders once at build time. Every visitor gets the same prerendered HTML, served from the CDN. To update, you redeploy. If you can live with that latency (and for a pricing page, you can), this is the cheapest, fastest option you have.

ISR: revalidate

A blog post that the marketing team edits in a headless CMS:

// app/blog/[slug]/page.tsx
export const revalidate = 3600; // 1 hour

export default async function Post({ params }: { params: { slug: string } }) {
  const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
    next: { revalidate: 3600 },
  }).then(r => r.json());

  return <Article post={post} />;
}

The first request after the revalidation window triggers a background regeneration. The current visitor still sees the stale HTML (instantly), and the next visitor sees the fresh version. This is the stale-while-revalidate pattern, baked into the framework.

The trick: set revalidate at both the route segment level and inside fetch. The framework uses the lower of the two. Mixing them is a common source of "why is my page stale" tickets.

SSR: force-dynamic

An account dashboard that reads the session cookie:

// app/(app)/dashboard/page.tsx
import { cookies } from 'next/headers';

export const dynamic = 'force-dynamic';

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

  return <DashboardView data={data} />;
}

You don't actually need force-dynamic here; calling cookies() already opts the route into dynamic rendering. But adding the export makes intent explicit, which matters when the next engineer reads the file.

PPR: the static shell + dynamic island pattern

The most underused primitive. A product detail page that needs to be SEO-indexable (static OG tags, static description) but also shows live inventory and a personalized "you viewed this" widget:

// next.config.js
module.exports = { experimental: { ppr: 'incremental' } };
// app/products/[slug]/page.tsx
import { Suspense } from 'react';

export const experimental_ppr = true;
export const revalidate = 600;

export default async function Product({ params }: { params: { slug: string } }) {
  const product = await fetch(`https://api.example.com/products/${params.slug}`).then(r => r.json());

  return (
    <>
      <ProductHero product={product} /> {/* static, prerendered */}
      <Suspense fallback={<InventorySkeleton />}>
        <LiveInventory sku={product.sku} /> {/* dynamic, streamed */}
      </Suspense>
      <Suspense fallback={null}>
        <RecentlyViewed /> {/* dynamic, reads cookies */}
      </Suspense>
    </>
  );
}

The static shell ships from the CDN in the first packet. Google indexes it. The dynamic islands stream in over the same response. You get SSG SEO and SSR personalization on the same URL.

PPR is the right answer for most ecommerce, marketplaces, and content sites with a logged-in state. It is still flagged experimental in Next.js 15 but stable enough for production at companies running it (Vercel, Notion, several of our customers' apps). Plan to use it.

Cache invalidation patterns that actually work

ISR's killer feature is on-demand invalidation. A revalidate=3600 page does not have to wait an hour if the underlying data changes. You can poke it.

Pattern 1: revalidatePath from a Server Action

When a logged-in admin publishes a post, refresh the public page immediately:

// app/admin/posts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';

export async function publishPost(slug: string) {
  await db.post.update({ where: { slug }, data: { published: true } });
  revalidatePath(`/blog/${slug}`);
  revalidatePath('/blog'); // also bust the index
}

Pattern 2: revalidateTag from a CMS webhook

For a Sanity, Contentful, or Strapi project, tag every fetch with the content type, then bust by tag from a webhook handler:

// in your page
const post = await fetch(url, { next: { tags: [`post:${slug}`, 'posts'] } });

// app/api/webhooks/cms/route.ts
import { revalidateTag } from 'next/cache';

export async function POST(req: Request) {
  const { type, slug } = await req.json();
  if (!verifySignature(req)) return new Response('forbidden', { status: 403 });
  revalidateTag(`${type}:${slug}`);
  revalidateTag(`${type}s`);
  return Response.json({ ok: true });
}

This is what makes ISR feel real-time without paying the SSR cost. The first reader after the webhook fires gets the new content. Origin renders go from "every request" to "every CMS edit," which on a typical SaaS marketing site is a 1000:1 reduction.

Pattern 3: Time-bounded freshness with a webhook fallback

Belt and suspenders. Use revalidate=3600 so even if the webhook fails, the page is at most an hour stale. This is the production default and it pairs nicely with the structure of a Next.js project for scale when you have many content types feeding many routes.

Common pitfalls (the ones that cost an afternoon)

A few that we see on every Next.js audit.

Cookies and headers force-dynamic transitively. Calling cookies() deep inside a Server Component (say, inside a shared <Header /> used in your layout) makes every route under that layout dynamic. Your "static" marketing pages quietly become SSR. Symptom: surprise origin bill.

Fetch defaults changed in v15. If you ported a v13 or v14 project, your fetches are no longer cached by default. Add cache: 'force-cache' (or next: { revalidate }) explicitly. Symptom: 10x origin requests after upgrade.

Layouts cache, but Server Actions don't bust them. revalidatePath('/blog/post') does not refresh the layout segment by default. If you mutate something the layout shows (a cart count, say), pass 'layout' as the second arg: revalidatePath('/', 'layout').

Build-time data fetches in SSG can fail silently. If your CMS returns a 500 during next build, you get a placeholder page deployed. Always wrap build-time fetches in retry logic, and gate the build on a smoke test. The same hygiene applies when you deploy a Next.js app to Vercel.

generateStaticParams returning [] is not "no params"; it's "all params dynamic." Surprises people coming from Pages Router. If you want zero prerendering, return [] and set dynamicParams = true. If you want a closed set of static pages, return the array of params and set dynamicParams = false.

When to skip the choice entirely

If you are pre-revenue with two founders and a landing page, this whole post is overkill. Use the App Router defaults. Run next build. Ship.

The decision matters when you have:

  • A CMS or admin surface generating content at unpredictable times
  • A logged-in product alongside an SEO surface
  • A content catalog (blog, products, locations) where build-time generation is too slow
  • An origin bill you can see on the AWS dashboard

Below that scale, pick "default" (which is roughly SSG with no caching of fetches) and revisit when something breaks. Best practices have ROI curves; respect them.

How Cadence engineers handle this on real projects

This is exactly the kind of decision that separates a "did the tutorial" Next.js dev from someone who has shipped it three times. The right call depends on your traffic shape, your CMS, your SEO posture, and how much origin cost you can absorb.

Every engineer on Cadence is AI-native by default (Cursor and Claude Code daily, vetted before they unlock bookings), which matters here because most ISR/PPR debugging is reading cache logs, diffing build outputs, and reasoning about request timing. AI-fluent engineers move through that loop faster. A senior tier engineer ($1,500/week) will typically audit your rendering choices, instrument cache hit rates, and ship a PPR migration in under a week. If you want a second opinion before refactoring, audit your stack with Ship or Skip and you'll get an honest grade on whether your current rendering setup is actually the bottleneck.

For deeper App Router work, the Pages to App Router migration guide covers the route-by-route transition pattern, and our notes on optimizing React performance in 2026 cover the React Compiler interplay with Server Components.

If your team is hitting Next.js 15 caching issues this week, book a senior engineer for a 48-hour free trial on Cadence. Ship one PR, see if the work moves. No notice period if it doesn't.

FAQ

Is ISR still a thing in Next.js 15?

Yes, but the API moved. Instead of getStaticProps with a revalidate field, you use export const revalidate = N at the route level, or fetch(url, { next: { revalidate: N } }) inline. The behavior is the same: stale-while-revalidate caching with a configurable freshness window.

Should I use PPR in production?

For most apps with a mix of static SEO surfaces and personalized content, yes. PPR is marked experimental in Next.js 15 but is stable enough that Vercel runs it on their own properties. Start with one route (a product page or blog post), measure the result, and expand.

What replaced getServerSideProps?

Nothing, and everything. Any async Server Component that reads cookies(), headers(), or searchParams is effectively SSR. You can also force it with export const dynamic = 'force-dynamic'. There's no single function to call; rendering posture is inferred from the APIs you use.

How do I tell which strategy a route is using?

Run next build and look at the route summary. Routes prefixed with a circle (○) are static, lambda (λ) are dynamic, and the new partial-prerender icon shows PPR routes. This summary is the source of truth; if a page you expected to be static shows up as dynamic, something in the tree is calling a dynamic API.

Does ISR work outside Vercel?

It works on any Node host that supports the Next.js custom server (or the standalone output). On non-Vercel hosts you need persistent storage for the cache (typically a shared filesystem or an external cache like Redis) and your CDN needs to honor the cache-control headers Next.js emits. Self-hosting ISR on Kubernetes is doable but requires care; if you want one provider that handles it for you, Vercel is still the path of least resistance.

All posts