A Nuxt 4 blog on a self-hosted Node server uses routeRules isr: 3600, yet every request re-renders. Why, and would swr or prerender fit better?
answer
- which rule feeds Nitro's cache
- isr is read by hosting presets
- swr serves stale, renders behind
- cached renders see no cookies
- prerender means rebuild to publish
basics
~20 sOn a Node server, Nitro caches rendered pages only for swr or cache rules; isr is left to hosting presets with a platform cache, so alone it caches nothing. Self-hosted, use swr: 3600; prerender suits a blog rebuilt on each publish.
solid answer
~50 sNitro turns an `swr` route rule into a `cache` rule and wraps the page renderer in its cached event handler; `isr` is not converted. It is read by hosting presets that translate it into their platform's cache configuration, so on the Node server preset it only switches on `_payload.json` extraction and every HTML request still renders. For a self-hosted blog I'd use `swr: 3600`: the first request renders and stores the page, later requests are served from the cache, and after an hour the stale copy is still served while a fresh render runs in the background. It needs a number of seconds, and the page must be the same for everyone, because the cached render sees no cookies. If posts change only when we publish and we can rebuild then, `prerender` is simpler still: static files, but new posts must be listed for the build.
code
ts · 8 lines// nuxt.config.ts: self-hosted Node server
export default defineNuxtConfig({
routeRules: {
// Nitro caches for 1 h, then serves stale while re-rendering
'/blog/**': { swr: 3600 },
// isr: 3600 here would only matter on a preset that maps it
},
})go deeper
Recall the three options: prerender builds files, swr caches renders on the server, isr relies on the hosting platform.
Explain stale-while-revalidate: what the visitor gets after expiry, what runs in the background, and why a number of seconds matters.
Diagnose the isr-on-Node symptom from source and headers, and keep personalised output away from cached renders and per-instance caches.
Decide freshness per content type against build cadence, hosting and team workflow, and make that decision visible in the route rules.
## Three rules, three places the HTML comes from | Rule on `'/blog/**'` | When the HTML is produced | Where it is kept | How it gets fresh | |---|---|---|---| | `prerender: true` | during `nuxt build` | static files in `.output/public/` | the next build | | `swr: 3600` | on the first request, then in the background | Nitro's cache on the server, plus `s-maxage=3600, stale-while-revalidate` for proxies | background re-render after 3600 s | | `isr: 3600` | depends on the hosting preset | the platform's cache, where a preset implements it | as the platform defines | ## Why isr re-renders on a Node server The Nuxt docs describe `isr` as behaving like `swr` with an added platform cache. The Nitro 2.13 source is narrower: 1. When Nitro normalises route rules, **`swr` becomes a `cache` rule** carrying `swr: true` and, for a number, `maxAge`. 2. At build time Nitro wraps the page renderer in its **cached event handler** only for paths whose rule has `cache`. 3. **`isr` is never converted into `cache`.** It is consumed by specific hosting presets, which write it into their platform's configuration. 4. On the Node server preset nothing consumes it. Nuxt still generates `_payload.json` for the route, but each HTML request runs a full server render. That is the symptom in the question. When the docs and the source disagree, the source wins: for a self-hosted server, use `swr`. ## How swr serves the blog 1. The first request for `/blog/lemon-drizzle` renders the page and stores the response, keyed by path and query string. 2. Requests within `maxAge` are answered from the cache without rendering. 3. After `maxAge`, the **stale** entry is still sent at once, and a background render replaces it. 4. Concurrent requests for the same expired key share one pending render inside that server process. 5. Responses with a status of 400 or above are not stored, so an error page is not cached. ## The traps - **`swr: true` without a number.** The rule then carries no `maxAge`, the header says only `stale-while-revalidate`, and Nitro's cache falls back to its one-second default age, so nearly every request triggers a background re-render. Give it seconds. - **No personalisation.** The cached render receives only the request headers listed in the cache's `varies` option; cookies are not among them by default. Every visitor gets the same HTML, so a greeting, a paywall check or a basket count must not be rendered there. - **Where the cache lives.** Unless a `cache` storage mount is configured, Nitro keeps entries in the server process's memory: they vanish on restart and are not shared between instances. Configuring storage belongs to the server side of Nuxt. - **Query strings split the cache.** Tracking parameters create separate entries. ## Choosing for the blog | Situation | Fit | |---|---| | posts change only on publish, and publishing can trigger a build | `prerender`, with slugs listed or crawling on | | frequent edits or many posts, self-hosted | `swr: 3600` | | a hosting preset that implements `isr` | `isr: 3600` | | per-visitor content anywhere on the page | none of them; plain SSR plus client-side personalisation | Prerendering gives the cheapest serving and no cold renders, but publishing waits for a build, and a wildcard `prerender` rule in a `nuxt build` does not discover dynamic slugs by itself. `swr` publishes within the cache age and keeps working without builds, at the cost of a server and a warm-up render per path. ## Client-side navigation and payloads Cached routes behave well inside the app too. For routes with `swr` or `isr`, Nuxt generates a `_payload.json` next to the HTML when the route is first rendered. When a reader clicks from the blog index to a post, the browser loads that payload instead of running the post's data fetching again, and the payload follows the same route rules as the page. A prerendered blog gets the same benefit from payload files written at build time, with the same caveat as its HTML: the data is as old as the last build.
- The blog runs on three Node instances behind a load balancer with swr: 3600. What will editors notice?Each instance keeps its own in-memory cache, so the three copies expire at different times, and a reader can see the old and new versions of a post on consecutive requests. A restart empties that instance's cache. Mounting shared `cache` storage in Nitro, or putting a proxy that honours `s-maxage` in front, gives one consistent cache.
- How do you verify which of these rules is actually active in production?Request a blog URL twice and read the response headers: an `swr` route carries a `cache-control` with `s-maxage` and `stale-while-revalidate`, and repeated requests return quickly with the same `etag`. A prerendered route is a file under `.output/public/`. An `isr` route on the Node preset shows neither, which is the tell.
saying these in an interview costs you the question
- isr and swr behave identically on any Node server
- swr: true caches the page until the next deploy
- After the cache age expires, the visitor waits for a fresh render
- An swr-cached page can safely greet the signed-in user by name
- The swr cache is shared across instances and survives restarts
- A prerender wildcard picks up new blog slugs on its own