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.inlineRouteRulesinnuxt.config.ts
defineRouteRulesdepends on Nuxt'sexperimental.inlineRouteRules. Appliers must enable it explicitly innuxt.config.ts; otherwise everydefineRouteRulescall silently no-ops and every page falls back to full SSR:export default defineNuxtConfig({ experimental: { inlineRouteRules: true }, })Projects scaffolded by
sw createalready 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
swrfield: This guide usesisrconsistently as the rendering strategy. The lowercaseswrfield that appears indefineRouteRulesandcacheconfigs (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
| Mode | Trigger | SSR phase | Client | Cache location | SEO | Best for |
|---|---|---|---|---|---|---|
| prerender | prerender: true | Once at build | hydrate | Static files (CDN) | ✅ Best | Rarely changing content (about / terms) |
| isr | isr: <seconds> (integer, 60–3600) | First visit + periodic invalidation | hydrate | Nitro cache (in-process memory by default, bounded LRU) | ✅ Excellent | List / detail / content home pages |
| ssr | ssr: true (default) | Every request | hydrate | Not cached | ✅ Excellent | Real-time (orders / live data) |
| csr | ssr: false | Empty shell | All on client | Not cached | ❌ Loses SEO | User-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.
-1means 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
maxAgeto 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 characteristic | Recommended strategy | Performance reason | SEO reason |
|---|---|---|---|
| 0–2 stable params (category / pagination / tag) | RESTful URL paths + ISR (e.g. /products/electronics/page/2) | Normalized cache key, high hit rate | Semantic URL, single canonical, efficient crawl budget |
| 3+ filter dimensions (search / advanced filter) | CSR (ssr: false) | Cache explosion, hit rate collapse | Duplicate 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 rate | Core landing pages — SEO is mandatory |
| Content home / list home | ISR (short maxAge) | Balances freshness and hit rate | Entry pages — SEO is mandatory |
| User-state pages (account / orders / admin) | CSR (ssr: false) | — | Should not be indexed (noindex) |
| Error pages / 404 | SSR + useSeo({ robots: 'noindex,nofollow' }) | Avoid being baked into cache | Prevent error pages from being indexed |
| Rarely changing (about / terms / EULA) | isr: 3600 | Header / 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:
- User A visits
/products/listfor the first time (with login cookie) - During the SSR phase, the renderer may:
- Have
useFetchautomatically 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)
- Have
- The rendered output is baked into the ISR cache
- Users B, C, D... visit
/products/listand 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":
| Category | Example | Consequence |
|---|---|---|
| Identity / session | Login state, user id, username, avatar | Identity impersonation |
| Permissions / subscription | Membership tier, paid plan, enterprise account | Privilege escalation |
| Personalized data | Favorites, reading history, orders, message count | Privacy leak |
| Personalized pricing | Member price, new-user price, coupon discount | Pricing chaos |
| Progress state | Reading progress, video progress, game saves | State 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
})
Pattern C: useRequestHeaders reading cookie / authorization
// ❌ 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/i18nprefix strategy) - Multi-tenant → subdomain
tenant1.app.comor 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/$fetchcalls return the same result for every user? - Does the SSR execution path call a private API (marked
x-sw-visibility: private)? Calls inonMounted/ event handlers are fine. - Are you reading
cookie/authorizationheaders 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/cachemust not readroute.queryon the SSR execution path. Enforced byswift-web/no-query-in-cacheable-ssr(error, in the@swift-web/framework/eslintrecommended 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=abcand?evil=random123all 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) | |
|---|---|---|
| Requests | Served straight from cache, zero extra | Cached HTML + one more client request |
| What the user sees | Correct immediately | Default view → skeleton → target view (one visible content swap) |
| Payload | Only the target data | SSR 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 source | Applies to | Cost |
|---|---|---|
| Front-end enumeration (value set known client-side) | sort / navigation / category | Purely static, no origin hit |
Response body (last_page / total) | page numbers and other back-end-driven dimensions | One origin hit; protects the cache, not the backend |
Hard ceiling (MAX_PAGE = 500) | fallback when neither of the above exists | Purely static, defense in depth |
| ❌ No boundary at all | search keywords, external URLs | Must 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:
- 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.
- If the page really can be cached, delete the
falsedeclaration 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":
| Param | Source | Risk |
|---|---|---|
| i18n language | cookie / Accept-Language header | First visitor's language is cached |
| Device type | User-Agent | Mobile sees desktop template (or vice versa) |
| Multi-tenant | host / x-forwarded-host | Tenants leak into each other |
| Geolocation | cf-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.vuevs/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
varieslist 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-Languageis gone → falls back to the default locale
Fix:
- If your SSR path needs a header, list it explicitly under
cache.variesin 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().querywill unexpectedly contain aurlfield - 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:
- Real content change frequency: business tells you "the detail page typically updates every few hours"
- Maximum tolerable lag: would users complain about seeing N-hour-old data?
Recommended combinations:
| Content type | maxAge | Pair with |
|---|---|---|
| Long-tail content (published articles) | isr: 3600 (upper bound) | Background revalidation once an hour; isr: true was removed (fw#414) |
| List pages | isr: 1800 to isr: 3600 | swr default on |
| Short-lived (rankings / hot search) | isr: 60 to isr: 300 | accept 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.
:::
5.3 B3 A Set-Cookie Written During SSR on a Cacheable Page Gets Cached and Broadcast to Every Visitor
⚠️ 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:
- Cross-visitor leakage: cookies / personalized headers written during SSR land in the shared copy, so every cache hit receives the same one.
- The whole page stops being cached: an nginx / CDN without
proxy_ignore_headers Set-Cookierefuses to cache a response carryingSet-Cookie— a silent no-op at the reverse proxy whose symptom is a perfectly fine upstreams-maxageyet a permanentMISS.
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 — thedefaulttriggers a write- the i18n
detectBrowserLanguagemarker 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:
- Cache explosion: dimension combinations = m × n × p × ..., easily thousands or tens of thousands
- Hit rate collapse: every pagination / filter change is a cache miss
- 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) + setstaleMaxAge: -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:
- During SSR, different requests share the same
userRef→ data crossover - 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.vuemustuseSeo({ robots: 'noindex,nofollow', silenceLengthWarnings: true })- No need for explicit
defineRouteRules(Nuxt won't ISR error pages), but addingdefineRouteRules({ 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: recommendisr: 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):
- HMAC + timestamp: signed requests; reject old requests
- IP whitelist: accept only internal IPs
- 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
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):
- Nuxt expands dynamic pages into a
/**wildcard —globRouteRulesFromPages()→pathToNitroGlob()turns/P/:idinto/P/**; - 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/xradix3 (what nitro actually uses) ✅ ❌ (matches exactly one segment) rou3 ✅ ✅ - nitro deep-merges the matched rules with
defu—defu({}, ...matchAll(path).reverse()). The list page's isr rule has nossrfield of its own, sossr: falsegets 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:
| Configuration | Resolved 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 source | Annotation | Frontend artifact |
|---|---|---|
| OpenAPI (self-built server) | endpoints carry x-sw-visibility: public | private | generate-api emits app/api/visibility.json (<module>.<fn> → public | private); generated JSDoc carries @swVisibility |
| YAPI (legacy company backend) | no annotation slot | no 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-visibilityto 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.
10.3 ESLint enforcement (three complementary rules, all in @swift-web/framework/eslint's recommended, default error)
swift-web/isr-no-private-api— the direct machine check for §10.2. Data source isapp/api/visibility.json(path overridable viamanifest). It reports when the same file declaresisr/prerenderand 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 viasegments) forbidisr/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 viasegments) requiressr: 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:
| Case | Result |
|---|---|
| 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 declaresssrexplicitly (§9.6)
Important
- ISR maxAge matches business freshness needs
- Multi-language / multi-tenant params live in the URL or use
cache.varies -
useFetcherrors are explicitly thrown viathrow createError(...) - ISR pages don't set response-level cookies / personalized headers
- on-demand revalidation webhook has signature verification
- Cross-component state uses
useStateinstead ofref
Minor
-
defineRouteRulesnotes 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 considersprefetch="false"
Appendix B: Authoritative References
- Nuxt routeRules docs
- Nitro Cache docs — full
varies/swr/maxAge/staleMaxAgefields - Vercel ISR docs
- Next.js ISR docs
- Google duplicate content & canonical docs
- Internal:
- framework/CLAUDE.md Chapter 8 SSR Compatibility
useTheme.tsimplementation — A3 counter-example source- SEO Best Practices — sibling chapter to rendering strategies
rules/rendering-strategies.md— companion hard rules to this guide
- Community issues:
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
warnoveroff:warnstill 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.