ISR vs SSG vs CSR Routing

Choosing the wrong rendering mode for a route can silently block indexation, inflate crawl budget in headless deployments, or delay time-to-first-byte to the point where Googlebot abandons the request. This page walks through the implementation workflow for each strategy β€” SSG, ISR, and CSR β€” with framework-specific configuration, a validated headers reference, and a diagnostic table for the failure modes that appear most often in production.

Prerequisites

Before configuring rendering modes, confirm the following are in place:

  • Framework version: Next.js 14+ (App Router), SvelteKit 2+, or Nuxt 3.10+ for the ISR/hybrid APIs shown here
  • CDN / edge layer: Vercel, Cloudflare Pages, or Netlify configured to respect Cache-Control and Surrogate-Control headers
  • Environment variables: REVALIDATION_SECRET set in your deployment environment for on-demand ISR webhook authentication
  • Tooling: curl available in CI, Google Search Console property verified, and Lighthouse CI installed as a dev dependency
  • Sitemap generation: XML sitemap generation must be wired to whichever rendering strategy you choose β€” static builds can output a sitemap at compile time; ISR and CSR routes require a dynamic or server-rendered sitemap endpoint

Execution Path: How Rendering Mode Is Resolved

The diagram below shows the decision path a request follows from the CDN edge through to the rendered HTML payload delivered to a crawler or user.

Rendering Strategy Execution Path Decision flow from CDN edge through SSG, ISR, and CSR execution paths to HTML delivery CDN Edge Incoming request Cache HIT? YES Serve cached HTML NO / MISS Route mode? SSG Prebuilt HTML ISR Regenerate & cache stale-while-revalidate CSR JS shell β†’ hydrate HTML delivered to crawler / user

Decision Matrix

Signal SSG ISR CSR
Content update frequency Rare (< 1/day) Moderate (hours–minutes) Real-time or user-specific
Route count < 10 000 paths 10 000 – 1 000 000 paths Unlimited (no pre-enumeration)
TTFB target Instant (CDN-served file) Near-instant (stale serve + background refresh) Slow until JS hydrates
Crawler HTML delivery Fully rendered at CDN Fully rendered (stale or fresh) Shell only unless prerendered
Build pipeline cost Paid per deploy Paid per regeneration request Negligible
Crawl budget impact Lowest Low High if JS required for link discovery
Recommended use case Marketing pages, docs Blog, product catalogue, news Dashboards, account portals, real-time tools

Step-by-Step Implementation Workflow

Step 1 β€” Map URL patterns to rendering modes

Define route-level rendering boundaries in your framework configuration before data-fetching layers initialize. This prevents framework default fallbacks from silently switching routes to CSR.

# Audit current route rendering modes
grep -r "prerender\|dynamicParams\|getStaticPaths\|swr\|isr" ./src --include="*.ts" --include="*.js" --include="*.astro"

Validation: output should list every route file annotated with an explicit rendering directive. Any route not appearing requires a manual assignment in the next step.

Step 2 β€” Assign explicit execution flags per route

Each route must carry an unambiguous directive. Do not rely on framework inference β€” implicit defaults change between minor versions and can silently shift a page from SSG to SSR.

# Next.js App Router β€” force-static audit
grep -rL "export const dynamic\|generateStaticParams" ./app --include="page.tsx" --include="page.js"

Files returned by this command have no explicit mode and should be reviewed immediately.

Step 3 β€” Configure middleware for bot routing rules

Edge middleware intercepts requests before they reach the rendering layer. Use it to enforce canonical paths, strip tracking parameters, and β€” for ISR/CSR routes β€” serve prerendered snapshots to crawlers when needed.

// middleware.ts (Next.js App Router)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const BOT_PATTERN = /Googlebot|bingbot|Slurp|DuckDuckBot/i;

export function middleware(request: NextRequest) {
  const ua = request.headers.get('user-agent') ?? '';
  const { pathname, searchParams } = request.nextUrl;

  // Strip UTM parameters before canonical path resolution
  if (searchParams.has('utm_source')) {
    const clean = request.nextUrl.clone();
    ['utm_source','utm_medium','utm_campaign'].forEach(p => clean.searchParams.delete(p));
    return NextResponse.redirect(clean, { status: 301 });
  }

  // For bot traffic on CSR routes, rewrite to prerendered snapshot
  if (BOT_PATTERN.test(ua) && pathname.startsWith('/app/')) {
    return NextResponse.rewrite(new URL(`/snapshots${pathname}`, request.url));
  }

  return NextResponse.next();
}

export const config = { matcher: ['/((?!_next/static|_next/image|favicon).*)'] };

Validation: curl -A "Googlebot/2.1" -I https://yourdomain.com/app/dashboard should return 200 with Content-Type: text/html, not a JavaScript bundle.

Step 4 β€” Inject route-specific HTTP headers at the edge

Set caching and crawler directives at the edge or origin. Headers must be sent with every response β€” do not rely on meta-robots tags alone.

# Verify headers on a live route
curl -sI https://yourdomain.com/blog/post-slug | grep -iE "content-type|cache-control|x-robots-tag|vary|age"

Framework-Specific Code Examples

Next.js App Router β€” SSG with generateStaticParams

// app/blog/[slug]/page.js
export async function generateStaticParams() {
  const posts = await fetchAllPostSlugs(); // returns [{ slug: 'a' }, { slug: 'b' }, ...]
  return posts;
}

// Force static even if the route could infer SSR
export const dynamic = 'force-static';

export default function BlogPost({ params }) {
  // params.slug is resolved at build time
  return <Article slug={params.slug} />;
}

SEO impact: Delivers deterministic HTML at instant TTFB with no per-request server execution. Googlebot receives a fully rendered page on the first fetch. Zero JavaScript required for crawler link discovery.

Validation: curl -s https://domain.com/blog/post-slug | grep '<title>' β€” response must contain a rendered <title> tag, not a JavaScript bundle comment.


Next.js App Router β€” ISR with time-based revalidation

// app/products/[id]/page.js
export const revalidate = 3600; // regenerate at most once per hour
export const dynamicParams = true; // generate unknown slugs on first request

export async function generateStaticParams() {
  // Pre-seed the most popular products at build time
  const top = await fetchTopProducts(200);
  return top.map(p => ({ id: p.id }));
}

export default async function ProductPage({ params }) {
  const product = await fetchProduct(params.id);
  if (!product) notFound();
  return <Product data={product} />;
}
// app/api/revalidate/route.ts β€” on-demand webhook
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const secret = req.headers.get('x-revalidation-secret');
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  const { path, tag } = await req.json();
  if (tag) revalidateTag(tag);
  if (path) revalidatePath(path);
  return NextResponse.json({ revalidated: true });
}

SEO impact: Content-volatile routes stay fresh without a full rebuild. Googlebot receives stale-but-valid HTML on most requests; background regeneration keeps content within the revalidate window. For the interaction between ISR and crawl budget, see Configuring Next.js ISR for Optimal Crawl Budget.

Validation: Trigger a CMS webhook: curl https://domain.com/api/revalidate -X POST -H "x-revalidation-secret: $REVALIDATION_SECRET" -H "Content-Type: application/json" -d '{"path":"/products/123"}'. Cross-check GSC URL Inspection for an updated Last-Modified timestamp within the next crawl cycle.


SvelteKit β€” Route-level prerender

// src/routes/blog/[slug]/+page.js
export const prerender = true;

// SvelteKit builds all slugs returned by entries()
export function entries() {
  return fetchAllSlugs().then(slugs => slugs.map(s => ({ slug: s })));
}

export async function load({ params }) {
  const post = await fetchPost(params.slug);
  if (!post) error(404, 'Not found');
  return { post };
}
// vercel.json β€” Cache-Control headers for prerendered routes
{
  "headers": [
    {
      "source": "/blog/(.*)",
      "headers": [
        { "key": "Cache-Control", "value": "public, max-age=3600, stale-while-revalidate=86400" }
      ]
    }
  ]
}

SEO impact: Locks routes to static generation at build time while CDN Cache-Control headers manage freshness windows. Reduces origin load and guarantees consistent crawl responses regardless of origin availability.

Validation: Inspect the build/ output directory for .html files matching each slug. Run curl -sI https://domain.com/blog/slug and confirm Cache-Control: public, max-age=3600 appears in the response headers.


Nuxt 3 / Nitro β€” Hybrid route rules

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/products/**': { swr: 1800 },          // ISR: serve stale, regenerate in background
    '/docs/**':     { prerender: true },    // SSG: compile to static HTML at build
    '/account/**':  { ssr: false },         // CSR: client-only, no crawler HTML
    '/api/**':      { cors: true, cache: { maxAge: 60 } },
  },
});

SEO impact: Granular per-pattern assignment prevents mixed-content penalties and optimises crawler resource allocation. /docs/ returns instant CDN-served HTML; /products/ refreshes on a rolling 30-minute window without a rebuild.

Validation: After a Nuxt build and deploy, trigger a product update and monitor Nitro edge logs for SWR state transitions. Verify the Age response header increments from 0 and resets after the swr: 1800 threshold.


Astro β€” Hybrid rendering with hydration islands

---
// src/pages/blog/[slug].astro
export const prerender = true;

const { slug } = Astro.params;
const post = await fetchPost(slug);
if (!post) return Astro.redirect('/404', 301);
---

  {post.title}
  
    
    

SEO impact: Delivers fully rendered HTML for all crawler-visible content while deferring interactive JS to client:idle or client:visible hydration boundaries. Preserves LCP scores and eliminates JS-gated link discovery.

Validation: Disable JavaScript in DevTools Network settings. Confirm <article> content remains visible and all internal <a href> links resolve in the static HTML source. Run Lighthouse with CPU throttling β€” FCP must occur before any hydration script fires.


HTTP Headers & CDN Directives Reference

Header Required value Rationale
Content-Type text/html; charset=utf-8 Mandatory signal that the response is HTML, not a script bundle
Cache-Control (SSG) public, max-age=31536000, immutable Content-addressable assets never change; version via filename hashing
Cache-Control (ISR) public, max-age=0, s-maxage=3600, stale-while-revalidate=86400 CDN caches for 1 h; origin refreshes in background for 24 h
Cache-Control (CSR shell) public, max-age=0, must-revalidate Shell changes on every deploy; prevent stale hydration mismatches
Surrogate-Control max-age=1800, stale-while-revalidate=3600 Varnish/Fastly alternative to Cache-Control for edge layer
Cache-Tag product-list, category-electronics Enables granular CDN purge by tag without full cache flush
Vary Accept-Encoding Prevents cache fragmentation; do not vary on User-Agent unless serving different content
X-Robots-Tag index, follow Explicit crawler permission at HTTP layer β€” overrides meta-robots for non-HTML assets
X-Frame-Options SAMEORIGIN Prevents clickjacking during CSR hydration
Content-Security-Policy script-src 'self' Controls hydration execution; avoid 'unsafe-inline' where possible

Validation Protocol

1. HTML delivery check

# Confirm fully rendered HTML (not a JS shell) is returned
curl -s https://domain.com/blog/post-slug | grep -E '<title>|<h1>|<article'

Expected: all three tags present in the raw response with real content, not empty placeholders.

2. Status code and header audit

# Check status, Content-Type, Cache-Control, and X-Robots-Tag
curl -sI https://domain.com/blog/post-slug | grep -iE "HTTP/|content-type|cache-control|x-robots-tag|age"

Expected: HTTP/2 200, content-type: text/html; charset=utf-8, cache headers matching the table above.

3. Middleware routing verification

# Confirm no redirect chains on canonical paths
curl -sIL https://domain.com/target-route | grep -E "HTTP/|Location:"

Expected: a single 200 response with no 301/302/307 hops. Redirect chains burn crawl budget and dilute link equity; see redirect chain management for remediation.

4. GSC URL Inspection

Submit the route URL in Google Search Console β†’ URL Inspection β†’ Test Live URL. Confirm:

  • β€œPage is available to Google” status
  • Rendered HTML tab shows content (not a JS shell)
  • Detected canonical matches the submitted URL

5. Lighthouse CI thresholds

# Run Lighthouse CI against a sample of routes
npx lhci autorun --collect.url=https://domain.com/blog/post-slug \
  --assert.assertions.first-contentful-paint=warn:2500 \
  --assert.assertions.largest-contentful-paint=warn:4000

Target: FCP < 1.8 s and LCP < 2.5 s on mobile with 4G throttling for SSG and ISR routes. CSR routes that fail these thresholds should be audited for hydration blocking and potentially migrated to SSG or ISR.


Troubleshooting

Symptom Root cause Fix
Googlebot sees a blank page or JS bundle on a route expected to be static dynamic = 'auto' or missing generateStaticParams caused Next.js to fall back to SSR/CSR Add export const dynamic = 'force-static' and generateStaticParams() to the route file
ISR fallback returns 404 on first request for an unknown slug dynamicParams defaults to false in some Next.js versions Set export const dynamicParams = true in the page file; set fallback: 'blocking' in Pages Router
CDN Age header never resets; stale content served indefinitely On-demand revalidation webhook authentication failing silently Verify REVALIDATION_SECRET env var is set in the deployment environment and matches the webhook header; check edge function logs for 401 responses
Mixed rendering output: some requests return HTML, others return a JS bundle Conflicting route config between middleware.ts and route-level export const dynamic Audit middleware rewrites with curl -A "Googlebot" -v to trace the full request path; remove conflicting conditions
Internal links not discoverable in raw HTML on CSR routes Link rendering deferred to JavaScript β€” <a href> only appears after hydration Move navigation to a statically rendered layout component; ensure href attributes exist in the initial HTML payload before scripts run
Build timeout when enumerating routes for SSG generateStaticParams or getStaticPaths fetching too many paths in a single call Batch path generation across multiple async fetches; consult Indexation Limits for Decoupled Sites for pagination strategies
Vary: User-Agent header causing cache fragmentation Middleware or server config inadvertently varying on User-Agent Remove User-Agent from Vary; use separate URL namespaces or edge middleware rewriting instead of content negotiation

Implementation Guides in This Section


FAQ

How do I validate that my routing strategy delivers SEO-compliant HTML? Use GSC URL Inspection, curl -s <url> | grep '<title>', and Lighthouse CI to verify raw HTML payload, status codes, and canonical routing consistency before and after every deploy. Automate these checks in CI against a representative sample of routes β€” not just the homepage.

When should a route switch from SSG to ISR? Transition when route enumeration exceeds build timeouts, content update frequency outpaces build cycles, or when on-demand regeneration without a full rebuild becomes a requirement. As a rule of thumb: if a content editor publishes more than a dozen updates per day and expects them visible in under an hour, ISR is the right boundary.

Does CSR routing hurt crawl budget? Yes, if crawlers must execute JavaScript to resolve internal links. Googlebot does eventually render JavaScript, but deferred rendering consumes crawl budget and introduces indexation latency of days to weeks. Mitigate by implementing prerendered route shells, explicit <a href> links in static HTML, sitemap-based route discovery, and deferring heavy JS hydration until after the first crawl pass. Routes in account portals or dashboards that should never be indexed should be blocked in robots.txt rather than relying on CSR as an implicit barrier.

Can I mix ISR and SSG on the same site? Yes β€” that is exactly what hybrid routing (Nuxt routeRules, Astro output modes, Next.js per-route revalidate) enables. The key constraint is that each URL pattern must have exactly one rendering mode. Conflicting directives β€” for example, a middleware rewrite pointing to a prerendered snapshot while the route config sets dynamic = 'force-dynamic' β€” produce non-deterministic responses that alternate between HTML and JS shells.


Part of: Headless Architecture & Rendering Strategy Fundamentals

Related