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-ControlandSurrogate-Controlheaders - Environment variables:
REVALIDATION_SECRETset in your deployment environment for on-demand ISR webhook authentication - Tooling:
curlavailable 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.
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
canonicalmatches 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
- How to Choose Between ISR and SSG for SEO β step-by-step decision protocol for selecting between the two static strategies based on content velocity, route count, and build pipeline constraints
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