Nuxt Rendering Mode Selection Guide (Deep Dive)

💡 Practitioners start here: Rendering Mode Quick Guide — 5-minute decision + matrix + anti-pattern cheatsheet.

This document is the deep-dive / risk theory companion. Read it when making architectural decisions, understanding ISR mechanics in depth, or investigating edge cases.

ISR (Incremental Static Regeneration) is Swift Web's primary rendering strategy — orders of magnitude faster than full SSR, and on cache-hit it's nearly as fast as a static site. But ISR has four big classes of pitfalls — cache fingerprint mismatches, cache content pollution, context-sensitive parameters, and Nitro defaults — and a single misstep can cause severe problems: user privacy leaks, cache explosions, SEO duplicate content.

This guide is for every Swift Web developer. It pairs with framework/CLAUDE.md Chapter 8 SSR Compatibility and the rules rendering-strategy hard rules. Whenever you're unsure how to write defineRouteRules, whether ISR is safe here, or why a list page should be CSR — start here.

Introduction

Why we treat rendering strategy as a hard skill:

  • The original framework completed the critical 0-to-1 phase and gave the team solid Nuxt SSR engineering experience; the new framework evolves on top of that, making ISR-style cache strategies the workhorse (/CLAUDE.md design intent #1) and reserving full SSR for cases that truly need it, in order to handle the performance pressure of growing traffic
  • A wrong ISR call isn't just slow — it can broadcast one user's data to everyone (A3 context-sensitive parameters + user-state leakage)
  • ISR is tightly coupled with SEO: pick the wrong URL design and you get cache explosion + duplicate content together, while losing SEO gains

Authoritative sources: Nuxt routeRules docs / Nitro Cache docs / Vercel ISR docs / nuxt#33728 (query normalization).


⚠️ Prerequisite: enable experimental.inlineRouteRules in nuxt.config.ts

defineRouteRules depends on Nuxt's experimental.inlineRouteRules. Appliers must enable it explicitly in nuxt.config.ts; otherwise every defineRouteRules call silently no-ops and every page falls back to full SSR:

export default defineNuxtConfig({
  experimental: { inlineRouteRules: true },
})

Projects scaffolded by sw create already include this. When the flag is missing, framework fails the build immediately at module setup (hard assertion — no silent failure). Background: framework#71.

1. The Four Rendering Modes at a Glance

Nuxt's defineRouteRules() exposes 4 rendering strategies. The table below is the most-consulted lookup during day-to-day mode selection.

About the swr field: This guide uses isr consistently as the rendering strategy. The lowercase swr field that appears in defineRouteRules and cache configs (see §9.1 / §11.1) is Nitro cache's stale-while-revalidate behavior switch — not a separate rendering mode. You'll meet this field while writing code, but you don't need to treat it as a standalone choice during selection.

1.1 Behavior Comparison

ModeTriggerSSR phaseClientCache locationSEOBest for
prerenderprerender: trueOnce at buildhydrateStatic files (CDN)✅ BestRarely changing content (about / terms)
isrisr: <seconds> (integer, 60–3600)First visit + periodic invalidationhydrateNitro cache (in-process memory by default, bounded LRU)✅ ExcellentList / detail / content home pages
ssrssr: true (default)Every requesthydrateNot cached✅ ExcellentReal-time (orders / live data)
csrssr: falseEmpty shellAll on clientNot cached❌ Loses SEOUser-state pages / admin

1.2 Key Terminology

  • maxAge: The cache "freshness window". After expiry the next request triggers regeneration. Nitro defaults to maxAge: 1 (1 second) — most business cases must set this explicitly.
  • staleMaxAge: The "expired but still usable" window. -1 means always serve the stale value first and refresh in the background.
  • on-demand revalidation: A business-side trigger (e.g. CMS publishes a new article) that explicitly calls a webhook to invalidate a specific URL's cache, instead of waiting for maxAge to expire.
  • prerender vs ISR boundary: prerender generates HTML for every route at build time once (suited for small static sites); ISR generates on-demand at first visit and then caches. Pages with dynamic params ([id].vue) explode if you prerender them — use ISR.

1.3 Why ISR Is Swift Web's Default

  • Performance: 10x+ CPU reduction (close to zero on high cache-hit ratios)
  • SEO: Complete HTML, crawler-friendly, equivalent to SSR
  • Business freshness: maxAge / on-demand revalidate provide flexible control
  • Progressive rollout: Switch strategy per page without affecting other routes

The price is: getting it wrong is risky. That's why this guide exists.

1.4 How isr Actually Works When Self-Hosted (no change needed, but worth knowing)

Bottom line first: keep writing isr — nothing changes for you. This section explains what happens under the hood, and why you should not rewrite it as swr / cache.

Nitro's isr is a platform-level rule: its core does not consume isr at all — only platform presets (Vercel / Netlify / Zeabur) read it, to push responses into the platform CDN. Our deployment is node-server preset + pm2, so without intervention defineRouteRules({ isr: 3600 }) would produce no caching whatsoever when self-hosted, and every request would run a full SSR.

That's why the framework translates it into an equivalent cache rule at build time (nitro:build:before), keeping both keys:

// what you write
defineRouteRules({ isr: 3600 })

// what the framework adds at build time (invisible to you)
{ isr: 3600, cache: { swr: true, maxAge: 3600 } }
//  ↑ read by platform presets (CDN)  ↑ read by node-server (server-side cache)

This keeps both targets correct: CDN semantics still hold if you later deploy to Vercel / Netlify, while self-hosting gets real server-side caching today. isr only accepts an integer between 60 and 3600 seconds; isr: true was removed and now fails the build (fw#414).

Do not write swr or cache yourself. They are valid nitro keys and do work, but writing them directly bypasses this shared convention — this site's decision tree, anti-pattern list and all four ESLint rules are built around isr. swift-web/no-ineffective-route-rules reports swr / cache / static / empty objects with the fix spelled out.

To tune the TTL, don't change the syntax: use the build-time env var NUXT_ISR_TTL=<seconds> to override every ISR page at once (1 ≈ caching disabled, handy for verifying data updates; leave it unset in production). It takes effect in both pnpm dev and pnpm build.

How to verify caching really works before shipping:

pnpm build && node .output/server/index.mjs &
curl -sD- -o /dev/null 'http://127.0.0.1:3000/<your-isr-page>/' | grep -i cache-control
# expected: cache-control: s-maxage=<TTL>, stale-while-revalidate
# a user-state page (ssr:false) must NOT have this header

⚠️ Mind the trailing slash — page-level defineRouteRules expands to /path/; without the slash the rule won't match and it will look like "caching isn't working".


2. Decision Tree: 3 Questions to Pick a Mode

Don't pick a mode upfront. Answer these three questions first.

2.1 Business-Characteristic Matrix

Business characteristicRecommended strategyPerformance reasonSEO reason
0–2 stable params (category / pagination / tag)RESTful URL paths + ISR (e.g. /products/electronics/page/2)Normalized cache key, high hit rateSemantic URL, single canonical, efficient crawl budget
3+ filter dimensions (search / advanced filter)CSR (ssr: false)Cache explosion, hit rate collapseDuplicate content risk; high-dimension filters aren't landing targets, low SEO yield
Detail page (stable content)ISR (long maxAge or on-demand revalidate)Stable content, high hit rateCore landing pages — SEO is mandatory
Content home / list homeISR (short maxAge)Balances freshness and hit rateEntry pages — SEO is mandatory
User-state pages (account / orders / admin)CSR (ssr: false)—Should not be indexed (noindex)
Error pages / 404SSR + useSeo({ robots: 'noindex,nofollow' })Avoid being baked into cachePrevent error pages from being indexed
Rarely changing (about / terms / EULA)isr: 3600Header / footer ops config is baked into the HTML too and must be refreshable within an hour; prerender is discouraged (see below)Runtime cost is comparable to prerender

2.2 Decision Flow

Ask yourself these 3 questions in order:

Q1: Does this page contain user-state data? (login state / personal info / member pricing / favorited status)

  • Yes → CSR (ssr: false) is mandatory. If only a small slice is private, consider forced-split mode
  • No → continue to Q2

Q2: Roughly how many param-dimension combinations are there?

  • 0–2 stable params (category, pagination) → ISR + RESTful URL paths
  • 3+ independent filter dimensions → CSR (ISR will explode the cache + duplicate content risk)
  • Pure detail page (indexed by id) → ISR (each id has its own cache, hit rate is high)

Q3: Content update frequency?

  • Almost never (static content) → prerender
  • Hours to days → ISR + long maxAge (with on-demand revalidate)
  • Minute-level → ISR + short maxAge
  • Real-time (orders, inventory) → SSR; if there's user state, CSR

2.3 Anti-Pattern Cheat Sheet

These are blocked at first sight in MR Review:

  • Detail page using SSR (should be ISR)
  • Account page using ISR (will leak — switch to CSR)
  • High-dimension filter using ISR (switch to CSR)
  • Error page missing noindex (prevent cache imprint + SEO indexing)
  • POST used for pagination / filter (switch to GET / RESTful)
  • Dynamic-param page using prerender (build-time explosion)

3. User-State Leakage (Most Severe Risk) ⚠️

The essence of an ISR cache is "bake the HTML the first visitor sees into a cache reused by everyone." If the first visitor is a logged-in user, their personal data is broadcast to every subsequent visitor — this is a security/privacy incident, far worse than cache explosion or stale data.

Ranked by severity, the three ISR risk classes are: user-state leakage > cache fingerprint mismatch (performance) > cache content pollution (functional). This chapter covers the most severe class first.

3.1 Why ISR Leaks User State

ISR workflow:

  1. User A visits /products/list for the first time (with login cookie)
  2. During the SSR phase, the renderer may:
    • Have useFetch automatically forward the cookie to the backend
    • Read the cookie via useRequestHeaders(['cookie'])
    • Have a server API return personalized content based on the cookie (username, member price, favorited status)
  3. The rendered output is baked into the ISR cache
  4. Users B, C, D... visit /products/list and all hit the same cached copy = they see User A's view

As long as the SSR phase reads cookies / personal headers and the response is ISR-cached, leakage is guaranteed. There is no partial leak — it's everyone-leak.

3.2 What Counts as "User State"

Any data that "differs by user identity":

CategoryExampleConsequence
Identity / sessionLogin state, user id, username, avatarIdentity impersonation
Permissions / subscriptionMembership tier, paid plan, enterprise accountPrivilege escalation
Personalized dataFavorites, reading history, orders, message countPrivacy leak
Personalized pricingMember price, new-user price, coupon discountPricing chaos
Progress stateReading progress, video progress, game savesState corruption

Inverse heuristic: HTML output should be exactly the same for every logged-out visitor = public data; output that varies by user = user-state data.

3.3 Code Patterns That Trigger Leakage (High-Risk Signals)

Why static pages use isr: 3600 rather than prerender

prerender runs at build time, when there is no real request and therefore no Host ⇒ the origin can only come from NUXT_PUBLIC_SITE_URL (framework fails the build without it). And setting that variable makes canonical / og:url / sitemap all converge on that domain.

For multi-domain / anti-block mirror sites that is harmful: every other domain's pages canonicalise to the primary one, effectively declaring themselves duplicates — once the primary domain is blocked, the mirrors become unfindable too, defeating the whole point of having them.

⇒ A single prerendered page forces the configuration, and the configuration forces convergence. isr: 3600 (revalidated in the background once an hour) costs essentially the same at runtime, needs no such variable, and still lets header / footer ops config refresh within an hour.

When prerender is still fine: when the site is confirmed to have exactly one domain and you specifically want zero Node cost at runtime. The starter template now uses isr: 3600 throughout.

For projects using SSG, such as the documentation site, dynamic page rules describe matching patterns. The framework does not enqueue parameter placeholders such as /components/:slug as URLs. Concrete pages still come from link crawling or nitro.prerender.routes, and errors generating those pages still fail the build.

Pattern A: useFetch forwards cookies by default

// ❌ Dangerous: useFetch defaults to credentials: 'include', forwarding cookies to the backend
defineRouteRules({ isr: 3600 })
const { data } = await useFetch('/api/products/list')
// If /api/products/list reads the cookie and returns personalized data → everyone sees the first visitor's view

Pattern B: Calling an API endpoint that depends on login state

// ❌ Calling private endpoints directly
defineRouteRules({ isr: 3600 })
const { data } = await useFetch('/api/user/orders')   // user orders
// or
const { data } = await useFetch('/api/manga/detail', {
  query: { id, withFavorite: true },                  // endpoint returns is_favorited
})
// ❌ Reading user-state headers during SSR
defineRouteRules({ isr: 3600 })
const headers = useRequestHeaders(['cookie', 'authorization'])
// Anything that depends on these headers → result is baked into the cache

Pattern D: server middleware injecting user state

// server/middleware/inject-user.ts ❌
export default defineEventHandler(async (event) => {
  event.context.user = await getUserFromCookie(event)
})

// ISR page reads:
const event = useRequestEvent()
const user = event.context.user        // user object enters SSR rendering

Pattern E: Forgetting ssr: false on a private page

// app/pages/mine/index.vue ❌
// missing defineRouteRules({ ssr: false })
// defaults to SSR + may be ISR-cached

3.4 Four Lines of Defense (Listed by Priority)

Defense 1: Use CSR (ssr: false) on user-state pages — simplest and safest

Account pages, order pages, admin, message center, settings — every page where "content varies by user":

defineRouteRules({ ssr: false })
useSeo({ robots: 'private', silenceLengthWarnings: true })

Cost: lose the SSR first paint. But user-state pages don't need SEO, so no impact.

Defense 2: Public / Private API separation — the core governance

Mixed-data pages (e.g. product detail body + favorited flag) should not downgrade the entire page to CSR — force a split: the body goes through a public API + ISR; the private add-ons are fetched client-side after onMounted and merged in. The criterion is the backend annotation, not a frontend directory — see §10.

See §10 Public / Private API Separation for the full design and code.

Defense 3: Nitro cache.varies — niche use only

If you really must keep certain params on cookie / headers (e.g. multi-language cookie, multi-tenant host) — add those headers to the cache key:

// nuxt.config.ts
routeRules: {
  '/products/**': {
    isr: 3600,
    cache: { varies: ['accept-language', 'host'] },
  },
}

Cost: cache entry count = path count × header combinations. Never put cookie wholesale in varies — it shards by every cookie value, equivalent to no cache at all.

Defense 4: Lift the variable into the URL path

For "quasi-user-state" params like i18n / multi-tenant, the safest move is putting them into the URL:

  • Multi-language → /zh/products / /en/products (use @nuxtjs/i18n prefix strategy)
  • Multi-tenant → subdomain tenant1.app.com or path prefix /t/<tenant>/...

Once the URL changes, the cache key naturally differs and ISR is safe.

3.5 Self-Check List

Before submitting a PR, ask yourself:

  • Is this page's HTML output exactly the same for every logged-out visitor?
  • Do useFetch / $fetch calls return the same result for every user?
  • Does the SSR execution path call a private API (marked x-sw-visibility: private)? Calls in onMounted / event handlers are fine.
  • Are you reading cookie / authorization headers during SSR?
  • Are you depending on server-side injected state like event.context.user?

If any answer is "yes" or "not sure", default to ssr: false. Losing a bit of SEO is much better than leaking user data.

3.6 Relationship to §4 Cache Fingerprint Mismatch

§4 (Class A) covers "non-identity context-sensitive params" (i18n language, UA, multi-tenant host, geolocation) — these can also cause output mismatches via ISR caching, but don't directly leak personal privacy. The fix patterns are similar to §3 Defense 3/4 (cache.varies or URL path-ification).

The reason for separating "user state" and "non-identity context" into two chapters is to make selection clearer:

  • Contains user state → §3 (this chapter, top priority)
  • Contains only non-identity context → §4 A3

4. ISR Cache Fingerprint Mismatch (Class A)

The essence of Class A risk: the cache key cannot reflect the real variables. Nitro defaults the cache key to path + searchParams; differences not encoded in the URL are ignored, so ISR shares the same cache across them.

4.1 A1 POST body Doesn't Participate in the Cache Key

Are you using method: 'POST' + body in $fetch / useFetch / useAsyncData to pass pagination / filter params on an ISR page?

// ❌ POST + body for pagination
defineRouteRules({ isr: 3600 })
const { data } = await useFetch('/api/products/list', {
  method: 'POST',
  body: { page: 2, category: 'electronics' },
})

Consequences:

  • Nitro cache key = /api/products/list (no page / category info)
  • The first visitor's page=1 result gets cached
  • Subsequent requests for page=2 hit the cache and get page=1 data back
  • All pagination breaks

Fix:

// ✅ GET + URL query
const route = useRoute()
const { data } = await useFetch('/api/products/list', {
  query: { page: route.query.page, category: route.query.category },
})

// ✅ Better: RESTful path
// pages/products/[category]/page/[page].vue
defineRouteRules({ isr: 3600 })
const { data } = await useFetch(
  `/api/products/${route.params.category}/page/${route.params.page}`
)

Related: A2 no query reads on cacheable pages, decision tree

4.2 A2 No Query Reads on the SSR Path of Cacheable Pages

Pages declaring isr / prerender / swr / cache must not read route.query on the SSR execution path. Enforced by swift-web/no-query-in-cacheable-ssr (error, in the @swift-web/framework/eslint recommended preset).

4.2.1 Why This Is a Hard Rule

The HTML cache is keyed path-only at the reverse-proxy layer (nginx proxy_cache_key "$scheme$host$uri" — all query strings ignored). That means:

  • ?utm_source=x, ?channel_code=abc and ?evil=random123 all hit the same path entry ⇒ marketing traffic benefits from the cache too, and a random-parameter attack cannot create even one new cache entry. This eliminates cache explosion rather than merely limiting it.

But the correctness of that one config line depends entirely on this rule: the moment a cacheable page reads query during SSR, ?nav=a and ?nav=b share one cache entry ⇒ content mismatch (users see content belonging to a different parameter). That failure is silent and hard to trace — worse than cache explosion.

prerender is in scope too: it generates static files at build time, when no query exists at all ⇒ reading query on a prerendered page can never return a value, which is a bug in itself. ssr: true / ssr: false are not constrained (they never enter the cache, so reading query is harmless).

4.2.2 Three Fixes

// ❌ Violation: a cacheable page reads query on the SSR path (computed is evaluated during SSR)
defineRouteRules({ isr: 3600 })
const order = computed(() => String(useRoute().query.order ?? ''))
const { data } = await useAsyncData('list', () => api.movie.list({ order: order.value }))

Fix 1 (preferred): move the parameter into a path segment

// Route pages/movie/list/[id]/index.vue + [order].vue → /movie/list/1 and /movie/list/1/hot
defineRouteRules({ isr: 3600 })
const route = useRoute()
const order = computed(() => String(route.params.order ?? ''))   // params are NOT constrained by this rule
const { data: meta } = await useAsyncData(`meta-${route.params.id}`, () => api.movie.blockDetail(...))

// 🔴 The path segment MUST be validated — skipping this just relocates the query explosion onto the path (see 4.2.3)
const allowed = new Set((meta.value?.orders ?? []).map((o) => o.code))
if (order.value && !allowed.has(order.value)) {
  throw createError({ statusCode: 404, statusMessage: 'Unknown sort order' })
}

Fix 2: read query in onMounted and backfill on the client (when the boundary is undecidable, or there are too many parameters for a path)

defineRouteRules({ isr: 3600 })
const { data } = await useAsyncData('base', () => api.movie.list({}))   // SSR fetches the default view only
const extra = ref(null)
onMounted(async () => {
  extra.value = await api.movie.list({ order: useRoute().query.order }) // client backfills per query
})

Fix 3: make the whole page ssr: false (many parameters and no SEO need)

Why fix 1 is preferred (not just for SEO) — when a user lands directly on a URL like /list/1/hot:

Fix 1 (path)Fix 2 (CSR backfill)
RequestsServed straight from cache, zero extraCached HTML + one more client request
What the user seesCorrect immediatelyDefault view → skeleton → target view (one visible content swap)
PayloadOnly the target dataSSR ships the default data, the client fetches again — transferred twice, used once

In-page tab switching behaves identically in both (one client request); the difference is concentrated on direct hits with parameters (shared links, crawlers).

Fix 2's side effect: crawlers receive the cached HTML, so query-driven content is never indexed. For filtering and sorting that is usually acceptable (canonical should point at the default view); use fix 1 for dimensions that are performance-sensitive or carry SEO value.

4.2.3 Path Segments Must Have a Decidable Boundary 🔴

[order] matches /list/1/anything-at-all. Skipping validation relocates the query explosion onto the path — and it is worse there: the path-only cache key gives zero protection against it (that very config relies on "ignore query" to prevent explosion).

Pick a boundary source by availability; at least one must apply before path-ifying:

Boundary sourceApplies toCost
Front-end enumeration (value set known client-side)sort / navigation / categoryPurely static, no origin hit
Response body (last_page / total)page numbers and other back-end-driven dimensionsOne origin hit; protects the cache, not the backend
Hard ceiling (MAX_PAGE = 500)fallback when neither of the above existsPurely static, defense in depth
❌ No boundary at allsearch keywords, external URLsMust not be path-ified — make the page uncacheable (ssr: true / ssr: false)

Correct pagination (boundary from the response body):

const { data } = await useAsyncData(...)
// Empty page 1 = no content yet (legitimate); empty page 2+ = out of range.
// The `page > 1 &&` guard is mandatory, otherwise a genuinely empty first page 404s.
if (page > 1 && page > (data.value?.last_page ?? 1)) {
  throw createError({ statusCode: 404, statusMessage: 'Page out of range' })
}
if (page > 500) throw createError({ statusCode: 404 })   // static backstop, blocks most junk without an origin hit

⚠️ A response-body boundary protects the cache but not the backend: you must hit the origin once to learn the range ⇒ nginx proxy_cache_valid 200 guarantees the 404 never enters the cache (no explosion), but the backend still gets hit — that is what rate limiting / WAF are for, and ?page=999999 hits the origin just the same without path-ification, so path-ifying does not make this worse.

💡 Prefer append-style "load more" for pagination: page 1 rendered by SSR/ISR, later pages appended client-side, page number never in the URL ⇒ pagination never enters the cache key and none of the validation above is needed.

4.2.4 Three Structural Preconditions for Path-ification (learned the hard way)

Once a parameter moves into a path segment, the parent path and the child path become cacheable at the same time (/list and /list/hot). That creates three preconditions; violating any one means a 500 at runtime or an aborted build:

① The ISR cache driver must not be fs

/scandals      → cache entry becomes the FILE       …/payload/scandals.json
/scandals/97   → cache entry would need             …/payload/scandals/97.json   ← requires a DIRECTORY
⇒ ENOTDIR, the child path 500s

Restructuring the URL does not help (/scandals/cat/97 still needs scandals to be both a file and a directory).

  • framework ≥0.92.0 ships a bounded LRU in-memory driver (??=, safe with zero configuration)
  • projects on < 0.92.0 that explicitly configured fs must change; unconfigured projects fall back to unstorage's root memory driver and are unaffected — but memory is unbounded, so mounting a bounded LRU explicitly is still recommended
  • framework ≥0.93.0 rejects this combination at build time, so you never discover it at runtime

② Do not end page routes with optional parameters (framework#407)

This applies to every rendering mode, including [[param]].vue, [[param]]/index.vue, and equivalent definePageMeta path or alias values. An optional segment in the middle, such as /a/:x?/end, is allowed.

Nuxt 4.5.2 collapses trailing optional parameters to *, but Nitro 2's radix3 cache handler does not cover the omitted segment. /list/:id/:order? becomes /list/*/*, leaving /list/10 uncached. Some shapes also lose ssr:false. A matching merged rule does not prove that the cache handler runs.

❌ pages/list/[id]/[[order]].vue
❌ definePageMeta({ path: '/list/:id/:order?' })
❌ Adding '/list/**' in nuxt.config.ts to keep those routes

✅ pages/list/[id]/index.vue    → /list/10
✅ pages/list/[id]/[order].vue  → /list/10/hot

Declare defineRouteRules in both entries and share the business component. Preserve URLs, defaults and validation. To retain component identity and KeepAlive across entries, import and export default PageView from a regular <script lang="ts">; keep route metadata in each entry and SEO in the shared view. Avoid copying business logic or adding wrapper components that change its identity.

Use a single regular <script lang="ts"> for the imports, both macros and export default PageView. Do not add <script setup>: its generated setup function would override the shared view's setup.

Trailing :slug(.*)*, :slug* and [...slug].vue also allow zero segments and are prohibited: a ** cache handler does not cover the omitted segment's root path. Use index.vue + [slug].vue for single-level documents and explicit levels for nested content. A + modifier requires at least one segment, but custom regex paths must still satisfy Nuxt's rule conversion restrictions.

For custom regex routes rejected with NUXT_B4016, use required parameters and validate values through definePageMeta.validate. [pagination].vue can still accept p2; validate its format and limit. Multiple ordinary dynamic segments work in Nuxt 4.5.2; do not add manual rendering-rule fallbacks.

Version requirement: framework ≥0.157.0. Framework 0.156.x and earlier do not provide these checks. After upgrading, ESLint swift-web/no-trailing-optional-page-route reports an error for file routes and literal path / alias values. The no-trailing-optional-page-route build guard checks resolved routes, replacing optional-segment-parent-covered. Adding a fallback no longer passes this guard.

③ A dynamic child page and a category segment cannot share the same level

[[cat]].vue (/scandals/:cat?) and [id].vue (/scandals/:id) are completely ambiguous as routes. Apply "nest the detail page one level deeper" and move it to detail/[id].vue; this also removes the "wildcard bleeds into the parent path" trap at its root (see §9.6 F4).

4.2.5 The Escape Hatch Must Be Opened on Both Sides

When a cacheable page genuinely must read query, the eslint-disable and the nginx cache key must change together, with comments pointing at each other:

// eslint-disable-next-line swift-web/no-query-in-cacheable-ssr -- nav is part of the nginx cache key (vps_nginx: zzz-anibay)
const navId = computed(() => String(useRoute().query.nav ?? ''))
location = /movie/index {
  proxy_cache_key "$scheme$host$uri|nav=$arg_nav";   # pairs with the eslint-disable in movie/index.vue
}

Opening only one side (disable without the nginx change) means immediate content mismatch, which is why the cross-referencing comments exist — they make a half-open hatch obvious in review.

Background: the community still has no official query-allowlist capability (nuxt#33728 is still open). This ecosystem solves it by moving cache-key governance to the reverse proxy as a path-only key, then using this rule to keep it correct — with the right layer chosen, the application no longer has to fight query combinatorics.

4.2.6 A Sub-path's "Don't Cache Me" Declaration Gets Swallowed by a Cacheable Wildcard

'/scandals/*':      { isr: 3600 },     // category listing, should be cached
'/scandals/search': { isr: false },    // search page, should NOT be cached ← this line does nothing

In the build output /scandals/search still carries s-maxage=3600. lint / typecheck / build are all green and the page renders fine — only the response headers reveal it.

Root cause: whether nitro wraps a path in cachedEventHandler is decided once at app init, from the route pattern of each registered handler; the routeRules merged per request are only used for headers / redirect / proxy and take no part in the caching decision. So if one cacheable wildcard exists, every path it covers gets cached.

Switching to cache: false does not help either (measured). Don't read this as "the merge overrode it" — the key actually survives (the output really does contain {"ssr":true,"isr":false,"cache":false}); the cache wrapper simply never reads it. Reasoning from the wrong root cause leads you to push on "make the sub-path rule stronger", and that whole avenue is a dead end.

Two fixes that actually work:

  1. Narrow the wildcard / restructure the routes so the wildcard cannot match it (recommended — it fixes the structure). Move the page out of that level, or give the wildcard a more specific prefix.
  2. If the page really can be cached, delete the false declaration and let it follow the wildcard (accept reality rather than leaving a lying declaration).

ssr: false is a recognized exception: such a page returns a build-time-generated SPA shell that is by definition request-independent, so caching one shell that is identical for everyone carries no content-mismatch risk (measured: two different queries on the same page produce byte-identical HTML). A CSR page swallowed by a wildcard therefore needs no action.

Why the ssr: true case is a hard error: it fools the linter at the same time — no-query-in-cacheable-ssr only fires when the file declares isr/swr/cache/prerender, and this page declares isr: false. So it may legitimately read route.query during SSR while actually being cached ⇒ ?a=1 and ?a=2 share one cache entry (the reverse-proxy cache key is path-only) ⇒ content mismatch.

framework ≥0.98.0 ships a build-time guard for this case; ssr: false is skipped, so there are no false positives.

4.3 A3 Context-Sensitive Params Not in the URL

Many params that "decide page output" aren't in the URL — they're hidden in cookies / headers / server-side state. Once ISR caches the response, the first visitor's view is broadcast to everyone.

Common "context-sensitive params":

ParamSourceRisk
i18n languagecookie / Accept-Language headerFirst visitor's language is cached
Device typeUser-AgentMobile sees desktop template (or vice versa)
Multi-tenanthost / x-forwarded-hostTenants leak into each other
Geolocationcf-ipcountry, etc.Region-restricted content mismatched

About login state / user identity: these too are "context-sensitive params not in the URL", but because the consequence is a security-level cross-tenant leak of personal data (not output mismatch), they're broken out separately in §3 User-State Leakage. This section doesn't repeat them.

::: tip theme is a counter-example — not an A3 risk in swift-web framework swift-web framework's useTheme is purely client-side toggle + CSS prefers-color-scheme fallback: during SSR it outputs the auto state (no data-theme set) and CSS media queries decide the first paint; after client hydration it reads localStorage and switches. The HTML doesn't carry theme state, so the ISR cache is safe. The trade-off is FOUC on first paint when the user has explicitly chosen light/dark.

But if a future change moves to cookie + SSR-rendered theme to eliminate FOUC, it falls back into A3 risk and must be handled with cache.varies: ['cookie']. :::

Fixes (two paths):

Path 1: Lift the variable into the URL

  • For i18n use the prefix strategy (/zh/products, /en/products); don't pick language by cookie
  • Use language-specific pages (e.g. /disclaimer-cn.vue vs /disclaimer-en.vue)

Path 2: Use Nitro cache.varies

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/products/**': {
      isr: 3600,
      cache: { varies: ['accept-language', 'cookie'] },
    },
  },
})

The headers in varies enter the cache key (by hash); the cache shards by header combinations. Cost: entry count = path count × header combinations — be careful when both are large.

4.4 A4 Nitro Strips Headers Not in varies by Default

A default trap that's easy to miss:

When resolving the cache, Nitro strips every header that's not in the varies list from the request, to keep cache hits consistent.

Consequences:

  • On an ISR path, useRequestHeaders(['cookie']) returns empty → you can't read the login state
  • Accept-Language is gone → falls back to the default locale

Fix:

  • If your SSR path needs a header, list it explicitly under cache.varies in routeRules
  • If the header carries sensitive data (cookie), the path probably shouldn't be ISR — switch to CSR or SSR

4.5 A5 Vercel ISR Auto-Adds url=<path> Query Side Effect

When deployed on Vercel, Nuxt ISR automatically appends a url=<path> query param when forwarding the request to the Lambda (Vercel community evidence).

Consequences:

  • Inside your SSR handler, useRoute().query will unexpectedly contain a url field
  • If you use JSON.stringify(query) as a cache key or fingerprint, it drifts because of this surprise field

Fix:

  • Destructure query fields explicitly when reading on the SSR path (const { page, category } = useRoute().query)
  • Don't use JSON.stringify(entire query) as a dependency

5. ISR Cache Content Pollution (Class B)

Class B's essence: the cached content isn't the intended content (or the side effect you intended never actually fires).

5.1 B1 Error Responses Get Baked Into the Cache

On an ISR page, when useFetch / $fetch first hits an error response (500 / 403 / 404), if you don't explicitly throw, the error page gets baked into the ISR cache for everyone afterwards to see.

// ❌ Error response not thrown, gets ISR-cached
defineRouteRules({ isr: 3600 })
const { data, error } = await useFetch('/api/manga/detail', { query: { id } })
// data is empty, but the page renders normally (empty state); ISR treats this empty state as "normal output" and caches it

Fix:

// ✅ Throw on error so Nitro doesn't cache it
defineRouteRules({ isr: 3600 })
const { data, error } = await useFetch('/api/manga/detail', { query: { id } })
if (error.value) {
  throw createError({
    statusCode: error.value.statusCode || 500,
    statusMessage: error.value.message,
  })
}

::: tip Nitro cache only stores 2xx responses By default, Nitro cachedEventHandler only caches 2xx responses. But Nuxt route rules go through page-render caching, and at the logical layer "I have no data so I rendered an empty state" is still 2xx — and gets cached. So you must actively throw to make the upper layer aware of the failure. :::

5.2 B2 Stale Data Drift

isr: 3600 (the upper bound) but the content clearly updates frequently (comment counts / live user counts / pricing) → users see stale data for a long time.

Two dimensions to set maxAge:

  1. Real content change frequency: business tells you "the detail page typically updates every few hours"
  2. Maximum tolerable lag: would users complain about seeing N-hour-old data?

Recommended combinations:

Content typemaxAgePair with
Long-tail content (published articles)isr: 3600 (upper bound)Background revalidation once an hour; isr: true was removed (fw#414)
List pagesisr: 1800 to isr: 3600swr default on
Short-lived (rankings / hot search)isr: 60 to isr: 300accept 1–5 min lag
Real-time (orders / inventory)Don't ISR — use SSR + CSR partial refresh—

::: tip CDN multiplies the window If ISR output is further cached by a CDN (e.g. Cloudflare), the effective freshness is ISR maxAge × CDN cache duration. Multi-layer caching needs a "window budget". :::

::: tip NUXT_ISR_TTL can override the TTL globally in test / staging environments framework supports the NUXT_ISR_TTL=<seconds> env var, which overrides the ISR TTL of all pages at build time — the isr: 3600 in a page is only the production expectation. If TTLs in a test environment don't match what pages declare, check this env first; it is not a bug.

Since framework 0.123.2 the build prints it (previously this log was dev-only, while the variable only affects caching at build time — so the log never appeared when it mattered, framework#358):

[swift-web] ISR → cache 归一:12 条路由已获得服务端缓存(maxAge 60s…)
[swift-web] ⚠️ NUXT_ISR_TTL=60 已覆盖全部 12 条 ISR 路由的 TTL
  该变量供测试 / 预发布环境临时压低 TTL 验证数据更新,**生产构建不应设置**。

⚠️ An override only turns one number into another (isr only accepts an integer between 60 and 3600, fw#414), and the build log prints the override warning. :::

⚠️ The intuition here is backwards. Nitro's cache wrapper stores the complete response headers (including Set-Cookie) in the cache entry and replays them on every HIT. It is not a no-op — the first visitor's cookie is handed to every subsequent visitor who hits the cache.

// ❌ Writing a cookie on the SSR path of a cacheable page (isr / swr / prerender)
defineRouteRules({ isr: 3600 })
const event = useRequestEvent()
setCookie(event!, 'visited', '1', { maxAge: 3600 }) // baked into the cached copy, replayed to everyone

Two consequences:

  1. Cross-visitor leakage: cookies / personalized headers written during SSR land in the shared copy, so every cache hit receives the same one.
  2. The whole page stops being cached: an nginx / CDN without proxy_ignore_headers Set-Cookie refuses to cache a response carrying Set-Cookie — a silent no-op at the reverse proxy whose symptom is a perfectly fine upstream s-maxage yet a permanent MISS.

On top of that, a cacheable page's render cannot read request cookies at all (the varies whitelist is only host / x-forwarded-proto), so whatever you write there can only be a constant — personalization was never possible in the first place.

framework's safety net (fw#374): Set-Cookie produced while rendering a cacheable page is stripped from the response, and dev-mode logs a warning naming the stripped cookie (strip-cacheable-set-cookie).

⚠️ So the symptom you actually observe is "this cookie never gets written", not a leak — the two consequences above are what happens without that stripping. Don't conclude the code is fine just because you see no cross-visitor leakage: the strip only prevents the incident; the logic sitting there is still wrong and still needs to move, per the fixes below.

The two most common accidental forms (neither is an explicit setCookie, which is why they slip through):

  • useCookie(key, { default: () => ... }) evaluated during SSR — the default triggers a write
  • the i18n detectBrowserLanguage marker cookie — that detection is structurally broken on cacheable pages anyway, see i18n

How to fix:

  • Move per-user cookie writes to the client (onMounted)
  • Or make the route non-cacheable (ssr: true + isr: false — beware that a sub-path declaration gets swallowed by a cacheable wildcard, see §5.2)

Related: User Info Store · Auth Flow

Fix:

  • Move side effects (tracking, personalization cookies) to the client (onMounted)
  • Or switch to a non-ISR mode

5.4 B4 Upstream Data Sources Have Their Own Caches

When ISR triggers a revalidate, it fetches fresh data from origin — but if your upstream API has its own cache (Sanity CDN, edge cache on a third-party API), the revalidate still gets stale data.

Example: pulling articles from Sanity CMS with useCdn: true (default) → even after ISR revalidate, you still get old versions from Sanity's CDN.

Fix:

// Disable upstream CDN on ISR paths
const data = await sanity.fetch(query, params, { useCdn: false })

6. Selection Anti-Patterns (Class C)

Class C is "wrong mode chosen", not "right mode used wrong".

6.1 C1 High-Dimension Filter Using ISR

This is the business-side framing of §4.2 A2. High-dimension (3+) filter pages on ISR have three problems at once:

  1. Cache explosion: dimension combinations = m × n × p × ..., easily thousands or tens of thousands
  2. Hit rate collapse: every pagination / filter change is a cache miss
  3. Duplicate content risk: search engines fold or down-rank multiple URLs whose query params barely differ but content is similar (Google duplicate content docs)

Stacked: you pay all of ISR's costs (cache / complexity / risk) but get none of its benefits (high hit rate / SEO value). Completely bad trade.

Fix: just ssr: false (CSR). The SEO loss is contained — high-dimension filter pages aren't search landing targets to begin with.

6.2 C2 Detail Page Using SSR — Wastes CPU

Detail pages have stable content; re-running SSR every request is a performance waste. 1 million requests vs 10k SSR + 990k cache hits is a 100x CPU difference.

Fix: isr: 3600 (the upper bound; background revalidation once an hour).

6.3 C3 Account Pages / Admin Using ISR — Will Always Leak

This is a security-level incident — see §3 User-State Leakage. This section is just a "anti-pattern registration" at the selection layer — pages that contain user-state data (account / orders / admin / messages / settings) all use ssr: false (CSR).

6.4 C4 High Update Frequency + Long maxAge

The anti-pattern flavor of §5.2 B2 Stale Data Drift. Mismatched maxAge means users see content that should already have been updated.

Fix: business explicitly states "how much lag is tolerable" → derive maxAge from there.

6.5 C5 Thousands of Pages + Short revalidate — Origin Gets Hammered

Classic Next.js community pitfall:

  • Site has 10,000 detail pages
  • Each detail page isr: 60 (1-minute expiry)
  • Peak hour: 100 different pages per second → 100 origin fetches per second → API gets hammered

Fix:

  • Long maxAge + on-demand revalidate (business actively triggers, not time-based)
  • Use stale-while-revalidate (cache.swr: true, on by default in Nitro) + set staleMaxAge: -1: serve stale value first and refresh in the background

7. SSR Execution Environment Pitfalls (Class D)

Class D isn't ISR-exclusive — it's a general SSR-path issue — but ISR amplifies these problems: the ISR cache makes wrong SSR output stick around for a long time.

7.1 D1 Top-Level Module Access to window / document in runtime

Top-level access to browser APIs in <script setup> (or in framework runtime modules) → SSR-time ReferenceError → HTTP 500. If unfortunately ISR-cached, the 500 page gets baked in.

// ❌ Top-level access
<script setup lang="ts">
const localValue = localStorage.getItem('foo')  // SSR 500
</script>

// ✅ onMounted guard
<script setup lang="ts">
const localValue = ref<string | null>(null)
onMounted(() => {
  localValue.value = localStorage.getItem('foo')
})
</script>

// ✅ import.meta.client guard (don't put at top level of framework runtime modules)
if (import.meta.client) {
  doSomethingClientOnly()
}

See framework/CLAUDE.md Chapter 8 SSR Compatibility.

7.2 D2 ref Cross-Request Shared State Frozen by ISR

// ❌ Module-top-level ref, shared across requests
const userRef = ref<User | null>(null)

export function useCurrentUser() {
  return userRef
}

Consequences:

  1. During SSR, different requests share the same userRef → data crossover
  2. ISR caching freezes the first user's state into the HTML

Fix: use useState (per-request isolation):

// ✅ useState per-request isolation
export function useCurrentUser() {
  return useState<User | null>('current-user', () => null)
}

7.3 theme Counter-Example

As mentioned in §4.3 A3 callout: swift-web framework's useTheme is a purely client-side toggle — it doesn't bake into SSR output, so ISR is safe.

But it's easy to misjudge as A3 risk. Test: check whether SSR-phase HTML contains theme-related class / data attributes. If not (as in swift-web), ISR is safe; if yes (as in some "FOUC-eliminating" implementations), it falls into A3.


8. Edge Pages (Class E)

Class E is the easily-overlooked "corner" pages.

8.1 E1 error.vue / [...slug].vue Missing Rendering Strategy

app/error.vue is Nuxt's global error boundary. It defaults to SSR, but because no routeRules are explicitly declared, it can mistakenly get cached under some setups.

Hard requirements:

  • error.vue must useSeo({ robots: 'noindex,nofollow', silenceLengthWarnings: true })
  • No need for explicit defineRouteRules (Nuxt won't ISR error pages), but adding defineRouteRules({ ssr: true }) is more explicit

[...slug].vue catch-all pages:

  • If used as a "dynamic content route loader", pick ISR by content type
  • If used as 404 fallback, add noindex

8.2 E2 sitemap.xml / robots.txt and Other Dynamic Files

If you generate them with nuxt-sitemap / a custom server route, they also go through routeRules:

  • sitemap.xml: recommend isr: 3600 (1-hour refresh is enough; search engines don't pull frequently)
  • robots.txt: prerender: true (built once)

8.3 E3 on-demand revalidation Webhook Security

When the backend publishes a new article, it calls /api/_revalidate?path=/articles/123 to invalidate ISR. If this endpoint is public and unprotected:

  • Malicious traffic floods it → backend gets hammered
  • Competitors flood your origin → your servers slow down

Defense (recommend combining):

  1. HMAC + timestamp: signed requests; reject old requests
  2. IP whitelist: accept only internal IPs
  3. Bearer token: short-lived signed tokens
// server/api/_revalidate.post.ts example
export default defineEventHandler(async (event) => {
  const sig = getRequestHeader(event, 'x-signature')
  const ts = getRequestHeader(event, 'x-timestamp')
  if (!sig || !ts || Math.abs(Date.now() - Number(ts)) > 60_000) {
    throw createError({ statusCode: 401 })
  }
  const expected = createHmac('sha256', process.env.WEBHOOK_SECRET!)
    .update(`${ts}|${getQuery(event).path}`)
    .digest('hex')
  if (sig !== expected) {
    throw createError({ statusCode: 401 })
  }
  // ... trigger revalidate
})

9. Nitro Default-Value Pitfalls (Class F)

Nitro's cache defaults are easy to overlook, but each one affects ISR behavior.

9.1 F1 swr: true On by Default

Nitro cachedEventHandler's swr option defaults to true:

  • Cache hit → return stale value immediately
  • Asynchronously trigger regeneration in the background
  • Next request gets the new value

Implication: expired cache doesn't make users wait — every user sees the "fast" experience, and freshness catches up via background refresh.

If your business wants "expired → wait for new value" (e.g. show correct data immediately after error recovery), explicitly set swr: false.

9.2 F2 maxAge: 1 (1 Second) Default

Most business cases must set this explicitly. A 1-second cache window is nearly useless for ISR (ISR's value comes from amortizing over a long maxAge).

9.3 F3 cache key = path + searchParams; cookies/headers Need Explicit varies

See §4.3 A3 and §4.4 A4.

9.4 Custom Cache Configuration

defineRouteRules supports the full cache config:

defineRouteRules({
  cache: {
    maxAge: 3600,
    staleMaxAge: -1,           // always serve stale first (background refresh)
    swr: true,
    varies: ['accept-language', 'cookie'],
    base: 'redis',             // use Redis as storage
    name: 'product-cache',     // cache group name
  },
})

9.5 Payload Extraction Is Disabled by the Framework (fw#209)

Nuxt enables payload extraction for isr / cache routes by default: hydration data gets split out of the HTML and loaded from <pathname>/_payload.json, whose URL drops the query string (upstream nuxt#34496) — so query-driven ISR pages (?page= pagination / ?order= sorting) hydrate with the "no-query view" (wrong-page data). The payload and the HTML are also two independent ISR cache entries with separate SWR revalidation, so version skew can corrupt hydration. The framework therefore sets experimental.payloadExtraction: false across the board: hydration data is always inlined in the same rendered HTML as the DOM. No applier config needed — and do not turn it back on. The framework also switches the nitro route-rule cache to a memory driver in dev, so restarting the dev server clears the cache and stale-response debugging traps are gone.

9.6 F4 Wildcard Rules Leak Rendering Semantics Into the Parent Path

Symptom: when pages/P/index.vue (a list page, isr: 3600) and pages/P/[id].vue (a detail page, ssr: false) are siblings, the list page degrades to an empty SPA shell in production — the HTML is a bare skeleton (~7.8KB), <title> is empty, SEO is gone. Local dev looks completely fine, so this ships to production unnoticed.

Three links in the chain (each verified against upstream source):

  1. Nuxt expands dynamic pages into a /** wildcard — globRouteRulesFromPages() → pathToNitroGlob() turns /P/:id into /P/**;
  2. The matcher considers /P/** to also match the zero-segment parent /P — nitro's routeRules matcher is radix3 (nitropack/dist/runtime/internal/route-rules.mjs). Note that radix3 and rou3 have opposite /* semantics:
    Matcher/x/** matches /x/x/* matches /x
    radix3 (what nitro actually uses)✅❌ (matches exactly one segment)
    rou3✅✅
  3. nitro deep-merges the matched rules with defu — defu({}, ...matchAll(path).reverse()). The list page's isr rule has no ssr field of its own, so ssr: false gets filled in from the wildcard rule.

The key insight: the wildcard doesn't "override" the parent — it fills in fields the parent never declared. That is also why declaring the field explicitly fixes it.

Measured matrix:

ConfigurationResolved for /P/P/1
/P(isr) + /P/**(ssr:false){ ssr:false, cache } 💥{ ssr:false }
Narrow the wildcard to /P/*{ cache } ✅{ ssr:false }
Add ssr: true to /P{ ssr:true, cache } ✅{ ssr:false }

Two fixes:

// ❌ pages/P/index.vue — declares isr but not ssr
defineRouteRules({ isr: 3600 })

// ✅ Fix 1 (recommended): declare ssr explicitly to close the gap; affects no child route
defineRouteRules({ isr: 3600, ssr: true })
# ✅ Fix 2: nest the detail page one level deeper — the wildcard becomes /P/detail/**,
#          which can no longer hit /P
pages/P/index.vue          # list
pages/P/detail/[id].vue    # detail

Fix 2 is exactly the directory shape starter has always used (movie/detail/[id].vue). Most projects in the ecosystem avoided this trap by coincidence — but it was only ever a coincidence, never a written convention, so writing the most common Nuxt layout (posts/index.vue + posts/[id].vue) walks straight into it.

Not limited to ssr: prerender, cache and other rendering-semantics keys behave the same way; nor is it limited to [id].vue — any dynamic child page that expands into /P/** qualifies.

Safety net: the framework ships a build-time hard check (nitro:build:before detects rendering-semantics keys that the wildcard has and the parent path does not, throwing with a per-occurrence fix). False positives are rare — if you intentionally want the parent to be CSR too, declare ssr: false on the parent page and the check passes. The check runs after your own nuxt.config hooks, so rewriting routeRules yourself to work around it is also viable.


10. Public / Private API Separation

This is Swift Web's fundamental architectural answer to the A3 context-sensitive risk. It turns a "dynamic check" (is this call safe?) into a "static contract" (you can tell from the import path).

10.1 The criterion is the backend annotation, not a frontend directory

Back to the core A3 problem: an ISR page called an API carrying user identity, and the whole response got baked into the cache. We need a contract a machine can recognise, rather than a reviewer reading every useCookie / useUserStore call.

Early design (dead): physical app/api/{public,private}/ directories plus a useFooPrivateApi naming prefix, so that "the import path tells you whether it is safe". Once codegen landed, no repository ever adopted it — the shape of app/api/ is decided by the generate-api generator (index.ts / modules/ / types/, never hand-edited), and you consume it through the auto-imported global api object (api.movie.detail()). There is no import path left to scan.

The real source of truth is the backend annotation:

Backend sourceAnnotationFrontend artifact
OpenAPI (self-built server)endpoints carry x-sw-visibility: public | privategenerate-api emits app/api/visibility.json (<module>.<fn> → public | private); generated JSDoc carries @swVisibility
YAPI (legacy company backend)no annotation slotno manifest — static checks go dark; the runtime L5 guard in §10.4 takes over

visibility.json exists only for OpenAPI-sourced projects. The legacy company backend uses YAPI and cannot carry the annotation, so the two static checks in §10.3 silently no-op there. Those projects are not unprotected — the runtime L5 guard in §10.4 needs no backend annotation and is their primary line of defence.

Building your own backend? Serve the spec as OpenAPI and mark endpoints with x-sw-visibility to additionally gain two development-time machine checks — far earlier feedback than L5 alone. See Codegen · generate-api dual sources.

10.2 The semantic red line

The SSR execution path of an ISR / prerender page must not call a private API.

private = the response varies by user (login state, identity, member pricing, favorite status, orders…).

"SSR execution path" means the synchronous top level of <script setup> plus the direct callbacks of SSR wrappers such as useAsyncData / useFetch. Calls inside event handlers, onMounted, or ordinary function bodies are client-triggered and not covered — that is exactly the legitimate shape used by §10.5.

  • swift-web/isr-no-private-api — the direct machine check for §10.2. Data source is app/api/visibility.json (path overridable via manifest). It reports when the same file declares isr / prerender and a private API call appears on the SSR execution path; client-triggered paths stay quiet. ⚠️ Silently no-ops when the manifest is absent (zero impact on YAPI-sourced projects).
  • swift-web/no-isr-on-paid-paths: paid-content route segments (read / play / chapter / video / watch / audio / episode / player / viewer, overridable via segments) forbid isr / swr / prerender.
  • swift-web/no-isr-on-user-state-paths: user-state route segments (mine / orders / settings / chat / live / profile / account / wallet / vip / subscription / dashboard / message, overridable via segments) require ssr: false.

They complement each other: the first judges by the API you call (precise, needs a manifest); the other two judge by route path (coarse fallback, works with any source). Static analysis cannot follow indirect calls buried inside composables — the runtime layer below covers that.

10.4 Runtime guard L5: cacheable pages must not carry user identity on the SSR path

The only layer that works for YAPI-sourced projects, and the last line of defence for every project. It depends on no backend annotation.

The incident it guards against: the first visitor of an ISR page happens to be logged in → their token rides along with the SSR request → the backend returns personalised data → baked into the shared cache and broadcast to everyone. Local dev never reveals it (dev does not cache).

The criterion is not "is this a private endpoint" but "can this render vary by user" — the same /movie/detail may return different content depending on whether a token is attached. "Is identity attached" is a precise, universal, zero-backend-dependency signal, which is why it covers legacy YAPI projects.

request issued during SSR
  ├─ no token?                  → whole block skipped (anonymous visitor, zero overhead)
  ├─ is this page cacheable?    → check routeRules; any of isr / prerender / cache / swr counts
  │                               (route context unavailable, e.g. a nitro task → declines to judge)
  └─ yes ⇒ tiered handling
       ├─ dev + real logged-in user (has user_id) → throw immediately
       ├─ dev + device-anonymous only (no user_id) → console.warn, page keeps working
       └─ both dev and prod: strip the token and re-send
            └─ backend answers errorCode 2002 ("login required") → **throw explicitly**,
               never fall through to the silent re-login retry

Why stripping is safe: measured against the live legacy backend, all 7 public endpoints returned status=y without a token ⇒ it authenticates per endpoint, not at the gateway.

Why 2002 must throw: a private legacy endpoint without a token answers HTTP 200 + errorCode 2002, not a 4xx — and nitro's "status ≥ 400 is never cached" safeguard only looks at the HTTP status. Also, a request whose identity was just stripped must not go through the re-login retry (that would add the identity back), hence the dedicated throwing branch.

Known boundary: L5 only covers framework's request() channel. With a raw useFetch / $fetch, Nuxt forwards cookies by default and L5 cannot see it. Always fetch through request() (or generate-api's api.*) on cacheable pages.

10.5 Forced-Split Mode for Mixed-Data Pages

Many detail pages have a public body (product info) plus a small private add-on (is_favorited, member price, personal recommendations). Force a split instead of "downgrading the whole page to SSR / CSR".

Anti-example: merged

// ❌ body + add-on info merged in the same fetch
defineRouteRules({ isr: 3600 })
const { data } = await useFetch('/api/manga/detail', {
  query: {
    id: route.params.id,
    userId: useUser().id,  // makes the endpoint return is_favorited
  },
})
// is_favorited is cached together with the body → first visitor's favorite state is broadcast

Correct: split

// ✅ ISR caches only the public body
defineRouteRules({ isr: 3600 })
const route = useRoute()
const { data: manga } = await useAsyncData(() => api.manga.detail({ id: route.params.id }))

// Private add-ons hydrated separately on the client
const isFavorited = ref(false)
const memberPrice = ref<number | null>(null)
onMounted(async () => {
  const [favRes, priceRes] = await Promise.all([
    api.manga.hasFavorite({ id: route.params.id }),
    api.manga.memberPrice({ id: route.params.id }),
  ])
  isFavorited.value = favRes
  memberPrice.value = priceRes
})

Wins:

  • Body's ISR hit rate is high (everyone shares the same cache)
  • Private bits fetched on demand, no cache safety risk
  • Architecturally forces separation of concerns; long-term maintainability is great

10.6 generate-api / YAPI Integration

The generate-api workflow currently does not participate in public / private routing — it relies purely on directory convention + mr-review review.


11. defineRouteRules Configuration Cheat Sheet

11.1 Common Combinations

// Detail page (permanent cache + on-demand revalidate)
defineRouteRules({ isr: 3600 })

// List page (hourly refresh)
defineRouteRules({ isr: 3600 })

// List page (fine cache config: 60s fresh, 1h stale, stale-while-revalidate enabled)
defineRouteRules({
  cache: { maxAge: 60, staleMaxAge: 3600, swr: true },
})

// Multi-language list (shard by locale)
defineRouteRules({
  isr: 3600,
  cache: { varies: ['accept-language'] },
})

// Static content
defineRouteRules({ prerender: true })

// User-state page / admin
defineRouteRules({ ssr: false })

// Real-time (order detail)
defineRouteRules({ ssr: true })

// Error page (default SSR + noindex via useSeo)
defineRouteRules({ ssr: true })  // useSeo({ robots: 'noindex,nofollow' })

11.2 Global routeRules vs Page-Level defineRouteRules: Same-Key Conflicts Are a Build Error

Both places can configure rules, but it is not simply "page level overrides global". Since fw#375 framework ships a build-time guard:

CaseResult
Same path, different keys✅ Merged (e.g. global sets headers, page sets isr)
Same path, same key, same value (mirrored declaration)✅ Legal, no error
Same path, same key, different values🔴 Build fails immediately — there is no "who wins"
// nuxt.config.ts
routeRules: { '/article/**': { isr: 3600 } }

// app/pages/article/[id].vue
defineRouteRules({ isr: 600 })   // 🔴 same key, different value ⇒ build fails

Why an error instead of a priority rule: this class of conflict used to be silent — two places declared different values, only one survived into the build output, the other vanished quietly, and lint / typecheck / build were all green. The only way to notice was curl -D- on the response headers. Rather than define a "who wins" rule for people to memorize, the build stops.

When to use which:

  • Global: batch defaults for a class of paths (e.g. /admin/** all CSR)
  • Page-level: fine-grained per-page control
  • Want both: let them own different keys; don't fight over the same one

Escape hatch: if you have confirmed the guard is misfiring and need to unblock temporarily, add 'page-config-route-rule-conflict': 'warn' under swiftConfig.guards in nuxt.config.ts (build continues, warning only). The error message itself also prints this line. Downgrading is a temporary unblock, not a fix — please report the false positive with a reproducing config to framework.

Write dynamic segments in config keys as /*, not /:param (nuxt ≥4.5.2, framework#393): Nuxt now turns page-level defineRouteRules into keys with one * per dynamic segment (/*, /*/*), and nitro matches them with radix3, which stops descending into the :param subtree once a * subtree matches at the same level — a hand-written '/:menucode/lib': { ssr: false } in nuxt.config silently stops applying (yc118's library page got server-rendered and returned 500 on direct hits, while dev / build / lint / typecheck all stayed green). Both spellings mean the same thing to nitro, so always write '/*/lib'. The route-rules-param-keys guard fails the build on any :param key in config once the rule table contains *-segment keys, and prints the rewrite; a false positive can be downgraded temporarily under swiftConfig.guards.

11.3 Compile-Time Macro Limitation

defineRouteRules is a compile-time macro; the argument must be a literal:

// ❌ won't work
const SECONDS = 3600
defineRouteRules({ isr: SECONDS })

// ❌ won't work
defineRouteRules({ isr: process.env.NODE_ENV === 'production' ? 3600 : true })

// ✅ literal
defineRouteRules({ isr: 3600 })

If you need environment-specific rules, use the global routeRules + env vars in nuxt.config.ts.


12. Coordination with SEO / robots / sitemap

12.1 ISR + canonical

When an ISR page has multiple URLs (varying query) mapping to the same content, use canonical to tell search engines "the primary version is here":

useSeo({
  title: ...,
  description: ...,
  canonicalPath: `/products/${category}`,  // without query (path only, no origin)
})

12.2 Private Page noindex + ssr:false

Private pages (account / admin) get double protection:

defineRouteRules({ ssr: false })
useSeo({ robots: 'private', silenceLengthWarnings: true })

robots: 'private' is framework useSeo's convenience alias, equivalent to noindex, nofollow.

12.3 Error Page noindex

Once an error page gets indexed, it pollutes brand-keyword search results. Force noindex:

// app/error.vue
useSeo({
  title: 'Something went wrong',
  description: 'The page failed to load. Please retry later.',
  robots: 'noindex,nofollow',
  silenceLengthWarnings: true,
})

See SEO Best Practices.


Appendix A: MR Review Rendering-Strategy Self-Check List

Before submitting a PR, run through this checklist (strictly aligned with rules 02 rendering strategies):

Must-fix

  • Page has defineRouteRules({ ... }) explicitly declared
  • Private / login-state page uses ssr: false
  • Detail page does not use ssr: true
  • Error page has useSeo({ robots: 'noindex,nofollow' })
  • Pagination / filter uses GET (or RESTful path), not POST
  • High-dimension filter (≥3 dims) page uses CSR
  • runtime has no top-level access to window / document / localStorage
  • The SSR execution path of the ISR page calls no private API
  • When the list page and detail page are siblings (P/index.vue + P/[id].vue), the list page declares ssr explicitly (§9.6)

Important

  • ISR maxAge matches business freshness needs
  • Multi-language / multi-tenant params live in the URL or use cache.varies
  • useFetch errors are explicitly thrown via throw createError(...)
  • ISR pages don't set response-level cookies / personalized headers
  • on-demand revalidation webhook has signature verification
  • Cross-component state uses useState instead of ref

Minor

  • defineRouteRules notes the business reason for maxAge in a comment
  • Public/Private API names follow the physical directory
  • theme toggling doesn't write cookies during SSR
  • <NuxtLink> to a CSR page considers prefetch="false"

Appendix B: Authoritative References

When a build-time guard blocks you (the guards escape hatch)

The framework ships a set of build-time guards that abort nuxt build when they fire. They catch the class of problem that stays silent locally and only bites in production — a wildcard rule leaking render semantics into its parent path, a server route swallowed by an ISR rule so requests cross-contaminate, an fs cache driver combined with parent/child paths that both cache (guaranteed ENOTDIR), and so on.

Assume the error is right. The message normally includes a fix you can paste.

But false positives do happen, and the cost is asymmetric: a missed detection just means a problem went unnoticed, while a false positive stops you from building at all — and the fix ships on the framework's release schedule, not yours. Hence the escape hatch (framework ≥0.119.0):

// nuxt.config.ts — 🔴 NOT swift.config.json
swiftConfig: {
  ...swiftConfig.framework,
  guards: {
    // 'error' (default, blocks the build) / 'warn' (log only, build continues) / 'off' (skip entirely)
    'uncacheable-under-cacheable-wildcard': 'warn',
  },
},

🔴 Put it in the right place: guards belongs to the same family as auth / systemInfo / addToHomescreen — it is not in swift.config.json and the CLI does not manage it (sw config set framework.guards does nothing). Writing it at the top level of swift.config.json has zero consumers: the file changes, the command succeeds, and the guard keeps blocking your build — precisely when you are already stuck.

  • The key is the guard name printed in the build error — no lookup table needed.
  • Everything defaults to error; omit the block and behaviour is exactly as before.
  • Both downgrading and disabling print a line at build time — a gate switched off in silence gets forgotten forever.
  • Prefer warn over off: warn still runs the guard and just doesn't abort, so you can still see what it reports.

Downgrading unblocks you; it does not fix anything. When you add an entry: (1) file an issue against the framework with a minimal reproduction of the routeRules / directory shape, and (2) leave a comment in the config pointing at that issue. Once the framework ships a fix, remove the entry and restore error.