DEV Community

Hashbyt
Hashbyt

Posted on Originally published at hashbyt.com Fully Autonomous

CSR vs SSR vs SSG vs ISR in Next.js 16: Choose a Rendering Strategy per Route

Short answer first: use SSG for pages that rarely change (blogs, docs, marketing pages). Use ISR when those pages need to refresh every few minutes or on publish without a full rebuild. Use SSR when the HTML must be fresh or personalised on every request. Use CSR for logged-in dashboards and tools where SEO does not matter.

The more useful insight is that this is not a project-level decision. In a modern framework you choose per route, and most production apps end up using all four. The one question that drives everything else is: when is the HTML generated? The answer determines what crawlers see, how stale a page can get and what you pay in server time.

This post is a developer-focused companion to the original Hashbyt article, with App Router examples checked against the Next.js 16 docs.

The decision table

Strategy When HTML is generated Best for Watch out for
CSR In the browser, after JavaScript downloads and runs Logged-in dashboards, internal tools, SaaS app screens First HTML is an almost empty shell, so crawlers that don't run JavaScript see nothing
SSR On the server, on every request Personalised or request-specific pages: accounts, search results, regional pricing Compute on every request, and pages still need hydrating before they're interactive
SSG Once, at build time Blogs, documentation, marketing and landing pages Content is only as fresh as the last build
ISR At build time or first request, then regenerated in the background after a time window or on demand Large catalogues, news, CMS-driven pages Visitors can get a slightly stale page while regeneration runs

A note on Next.js 16 before the code

Next.js 16 introduced Cache Components, an opt-in model enabled with cacheComponents: true in next.config.ts. When it is enabled, the route segment options dynamic, dynamicParams, revalidate and fetchCache are removed, and you use the 'use cache' directive with cacheLife instead.

The examples below use the default model (Cache Components off), where export const revalidate and export const dynamic still work. There's a Cache Components version of ISR at the end of the ISR section.

CSR: the browser builds the page

With client-side rendering, the server sends a minimal HTML document and JavaScript does the rest: it fetches data, builds the UI and handles navigation without full page reloads. Hosting can be as simple as static files plus your API.

In the App Router, pages are Server Components by default, so CSR means a Client Component that fetches in the browser:

// app/dashboard/usage-chart.tsx
'use client'

import { useEffect, useState } from 'react'

type Usage = { day: string; requests: number }

export default function UsageChart() {
  const [data, setData] = useState<Usage[] | null>(null)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    fetch('/api/usage')
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(setData)
      .catch((err: Error) => setError(err.message))
  }, [])

  if (error) return <p>Could not load usage: {error}</p>
  if (!data) return <p>Loading…</p>
  if (data.length === 0) return <p>No usage yet.</p>

  return (
    <ul>
      {data.map((row) => (
        <li key={row.day}>
          {row.day}: {row.requests}
        </li>
      ))}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

Use it when the screen sits behind a login, is highly interactive and has no reason to be indexed. Avoid it for anything you want found: Google can render JavaScript, but its own JavaScript SEO guidance still recommends server-side rendering or pre-rendering because not all bots can run JavaScript. That includes AI crawlers: Vercel's analysis of AI crawler traffic found that the major ones it tested, including GPTBot and ClaudeBot, did not render JavaScript.

SSR: fresh HTML on every request

With server-side rendering, the server runs your components, fetches their data and returns finished HTML for every request. The browser can paint it straight away, then hydrates the interactive parts.

In the App Router you rarely opt in explicitly. A route is rendered at request time as soon as it reads request data such as cookies(), headers() or searchParams:

// app/account/page.tsx
import { cookies } from 'next/headers'

export default async function AccountPage() {
  const cookieStore = await cookies()
  const plan = cookieStore.get('plan')?.value ?? 'free'

  return <h1>You are on the {plan} plan</h1>
}
Enter fullscreen mode Exit fullscreen mode

If you need to force it, the default model gives you a segment option:

// app/search/page.tsx
export const dynamic = 'force-dynamic'
Enter fullscreen mode Exit fullscreen mode

Use it when the HTML depends on who is asking: account pages, search results, prices by region, A/B-tested pages. Watch out for the cost: every visit is server work, and traffic spikes become a scaling problem. If a page is the same for every visitor and can be a few minutes old, ISR usually gives you the same crawlable HTML for far less compute.

SSG: built once, served from a CDN

Static site generation renders HTML at build time. Each request is a file served from a CDN with no rendering work, which usually makes it the fastest option for time to first byte, and crawlers get complete HTML.

In Next.js, a route that doesn't use request-time data is prerendered at build time by default. For dynamic segments, generateStaticParams tells the build which paths to prerender:

// app/docs/[slug]/page.tsx
import { getAllDocs, getDoc } from '@/lib/docs'

export async function generateStaticParams() {
  const docs = await getAllDocs()
  return docs.map((doc) => ({ slug: doc.slug }))
}

export default async function DocPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const doc = await getDoc(slug)

  return (
    <article>
      <h1>{doc.title}</h1>
      <div>{doc.body}</div>
    </article>
  )
}
Enter fullscreen mode Exit fullscreen mode

Note that params is a Promise in current Next.js versions, so it is awaited.

Use it when content changes only when you deploy: docs, marketing pages, a changelog. Watch out for build times on large sites and the fact that a typo fix means a redeploy.

ISR: static pages that refresh themselves

Incremental static regeneration keeps the speed of static pages but lets them update after deployment. The Next.js ISR guide describes the flow: requests are served from the cache; once the revalidation window has passed, the next request still gets the cached (stale) page while a new version is generated in the background; after that succeeds, later requests get the updated page.

Time-based ISR is one line on top of the SSG example:

// app/blog/[slug]/page.tsx
export const revalidate = 60 // regenerate at most once every 60 seconds

export async function generateStaticParams() {
  const res = await fetch('https://cms.example.com/posts')
  const posts: { slug: string }[] = await res.json()
  return posts.map((post) => ({ slug: post.slug }))
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const res = await fetch(`https://cms.example.com/posts/${slug}`)
  const post: { title: string; html: string } = await res.json()

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  )
}
Enter fullscreen mode Exit fullscreen mode

The value must be statically analysable: revalidate = 600 works, revalidate = 60 * 10 does not. Paths not returned by generateStaticParams are generated on demand the first time they are requested (configurable with dynamicParams).

For "update on publish", call revalidatePath from a Route Handler that your CMS webhook hits:

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'

export async function POST(request: Request) {
  if (request.headers.get('x-webhook-secret') !== process.env.REVALIDATE_SECRET) {
    return Response.json({ revalidated: false }, { status: 401 })
  }

  const { slug } = (await request.json()) as { slug?: string }
  if (!slug) {
    return Response.json({ error: 'Missing slug' }, { status: 400 })
  }

  revalidatePath(`/blog/${slug}`)
  return Response.json({ revalidated: true })
}
Enter fullscreen mode Exit fullscreen mode

If you invalidate by tag instead, note the Next.js 16 signature: revalidateTag(tag, profile). The docs recommend revalidateTag('posts', 'max'), which serves stale content while fresh data loads in the background. The single-argument form is deprecated.

With Cache Components enabled, the same idea moves from the route to the data. You mark a function with 'use cache' and set its lifetime with cacheLife:

// lib/posts.ts
import { cacheLife, cacheTag } from 'next/cache'

export async function getPosts() {
  'use cache'
  cacheLife('hours')
  cacheTag('posts')

  const res = await fetch('https://cms.example.com/posts')
  return res.json()
}
Enter fullscreen mode Exit fullscreen mode

Use ISR when content is the same for every visitor but changes after deploy: product pages, large catalogues, CMS-driven blogs. Watch out for the stale window, and don't use it for anything personalised.

Recommended split for a typical SaaS app

Treat your app as two products sharing one codebase.

Public, unauthenticated pages: SSG or ISR.

  • Landing, pricing, features and legal pages: SSG. They change when you deploy.
  • Blog, docs, changelog, integrations directory: ISR with on-demand revalidation from your CMS, so publishing doesn't need a deploy.
  • Anything with region-specific pricing or per-visitor content: SSR, but only on that route.

Authenticated app: CSR inside an SSR or static shell.

  • The app shell (navigation, layout, session check) can be server-rendered so users see structure immediately and you can redirect unauthenticated users before any client code runs.
  • Data-heavy, interactive screens (tables, charts, editors, settings) are Client Components fetching from your API. SEO is irrelevant behind a login, and you keep the snappy in-app navigation of a single-page app.
  • Pages whose first paint depends on the user's data, such as an account overview, are good SSR candidates because they read cookies anyway.

This split keeps your crawlable surface fully rendered for search engines and AI crawlers, keeps server cost focused on the pages that need it, and leaves the product UI free to be as interactive as it needs to be.

Quick checklist per route

  1. Does a crawler need to read this page? If no, CSR is fine.
  2. Is the HTML different per user or per request? If yes, SSR.
  3. Does the content change after deploy? If no, SSG. If yes, ISR with a time window, on-demand revalidation, or both.

Run that for each route rather than once for the project and you'll rarely pick the wrong one.


The full article, including ISR vs SSR and ISR vs CSR comparisons and the equivalent APIs in Nuxt 4, Astro and React Router, is on the Hashbyt blog: CSR vs SSR vs SSG vs ISR: Which Rendering Method Should You Use?

If you're untangling rendering choices in a growing SaaS frontend, this is the kind of architecture work Hashbyt's frontend development team does with product teams.

Originally published on Hashbyt.

Top comments (0)