Page Caching
The module lets a shared cache (Souin, in the template) store the HTML that Nuxt renders. It sets the headers; the shared cache stores the page and drops it when the API purges. Page caching is on by default.
Configuration
// nuxt.config.ts
export default defineNuxtConfig({
cwa: {
pageCache: {
enabled: true, // default
sharedMaxAge: undefined, // optional cap, in seconds. Default: unset, so the API decides
staleWhileRevalidate: 0, // default: off
},
},
})
Set enabled: false to turn it off.
What a cached page carries
When a page can be cached, the module sets two headers:
Cache-Control: public, max-age=0, s-maxage=31536000
Surrogate-Key: cwa-html, /_/pages/8f3c…, /_/component_groups/4d5e…, /component/titles/1a2b…, …
Surrogate-Keylists the IRI of every CWA resource used to render the page, plus the constant keycwa-html. When the API saves a resource, it purges that resource's IRI, and every page that used it is dropped. Saving site config purgescwa-html, which drops every page. See Purging Every Rendered Page.max-age=0is fixed. A browser's cache can't be purged, and a stale page that points at an old build's/_nuxtfiles loads blank.stale-while-revalidateis added only when you setstaleWhileRevalidate.
cwa-html is a contract shared with the API bundle. Both sides must use the same name, or the purge matches nothing and pages stay stale until they expire.Cache-Control and Surrogate-Key headers.How long a page is kept
A page's s-maxage is the lowest of:
pageCache.sharedMaxAge, if you set it- the
s-maxageof every API response fetched during the render (or itsmax-age, if it has nos-maxage) - the time left until any API response's
Expires, measured against that response's ownDateheader
The API shortens its responses ahead of anything scheduled: a component's future publishedAt arrives as Expires, and an upcoming route go-live caps s-maxage on routes, pages and manifests (Scheduled Publication and Cache Lifetime). So a page is never kept past a scheduled change, even though no save happens at that moment.
If none of these supplies a value, the page is kept for one hour.
By default sharedMaxAge is unset, so a page lasts as long as the API responses it used: one year in the template's production API, and 60 seconds in development. A long lifetime is safe because the API purges a page when any resource on it is saved, site config purges every page, scheduled changes shorten the lifetime in advance, and each deploy purges and warms the cache.
sharedMaxAge if your pages render anything the API doesn't provide. Only CWA resources are tagged for purging. A date rendered on the server, a third-party fetch, or a useAsyncData call outside CWA is never purged, so with the default lifetime it stays in the cached page for up to a year. sharedMaxAge caps every page, for example sharedMaxAge: 3600 refreshes them at least hourly. It can only shorten a page's lifetime, never extend it past the API's.636e9247 (cwa-nuxt-module#325) defaulted sharedMaxAge to 3600, so pages were capped at an hour. They also added stale-while-revalidate only when that cap set the page's lifetime.Go-live capping uses the soonest scheduled go-live across all routes. While any route is waiting to go live, every page's lifetime is shortened to that moment.
When a page is not cached
The module marks a page private, no-store when:
- the visitor is signed in, or
- any API response used in the render was
privateorno-store. The API sends this for an admin's draft view.
A 4xx API response doesn't count towards the page's headers. A 5xx still does, because nothing purges the page once the API recovers. An anonymous visitor's request for an unpublished component returns a private 404, and the page that places it is still cached; the component appears when it is published, because publishing purges it. Module builds before 26f06f8d (cwa-nuxt-module#324) counted that 404, so a page that used a draft component was never cached.
A page response with any status other than 200 (a redirect or an error) gets no-store. So does any render where the page's own route or resource failed to load.
7a219b76 stored error pages (cwa-nuxt-module#340). Nuxt renders an error page through a separate internal request that returns 200, and the module copied that request's cacheable headers onto the real 404 or 500. A missing page stayed a cached 404 for the full page lifetime. A route scheduled to go live stayed a 404 after its time passed, because nothing purges at that moment. If you see this, update the module and purge the page cache.A page that used no CWA resources, or whose lifetime works out as zero, gets no cache headers from the module.
Deployment requirements
- The shared cache must purge by
Surrogate-Key. The template's Caddyfile caches page HTML on the same Souin store that the API purges. See the@use_cachenotes in Docker. - The page's cache key must not include
Accept. Each browser sends a differentAcceptheader for the same HTML, so a key that includes it stores one copy per browser, and a page cached for one browser is a miss for the next. The template keepsAcceptin the key for/_api*only. API responses do depend on it, and API Platform's Swagger UI response sends noVary: Accept, so without it a cache hit could serve HTML to a JSON-LD client. - Compression must happen after the cache. If the cache stores responses that are already compressed, it keeps one copy per
Accept-Encodingvalue. In Caddy, useorder cache after encode, so the page is stored once, uncompressed, and compressed for each client on the way out. - The shared cache must skip requests that carry the auth cookie. The module sends no
Vary: Cookie, because it would ruin the hit rate. A cached page is always an anonymous one, so this is not a leak, but a signed-in admin served a cached page sees no admin controls or draft content.
useFetch, a geo banner, an A/B test), turn page caching off or exclude those paths from the shared cache.isr, swr and prerender route rules don't mix with this. Nitro's own cache isn't purged by the API, so edits don't appear until the route rule expires. The module warns at build time when both are on./_nuxt file names without changing any resource, so nothing purges cached pages. The template's Souin store is in memory in the php container, so it is emptied whenever that container restarts. If you deploy the Nuxt app on its own, or put a persistent CDN in front, cached pages can point at files that no longer exist until they expire.Purge the pages after every front-end deploy, once the new Nuxt pods have fully replaced the old ones. Run php bin/console silverback:api-components:purge-rendered-html in the API container, or send POST /_/rendered_html/purge as an admin (it returns 204). Both purge only cwa-html, so cached API responses are kept. If the cache refuses the purge or can't be reached, the command prints the reason and exits 1, and the endpoint returns 502, so a deploy script can rely on either. The template's pipeline already does this: see CI/CD.Tracking parameters
The query string is part of the cache key. Without help, every ?utm_source=… or gclid value would render and store its own copy of a page, so ad clicks and shared links would always miss the cache. The API would be split the same way, because the module passes the page query on to Collection fetches.
The template's Caddyfile drops these parameters before the cache sees the request. A @tracking_query matcher decides when to run a uri query { … } block:
@tracking_query {
not path /_nuxt/*
expression `{http.request.uri.query}.matches("(^|&)(utm_source|utm_medium|…|mc_eid)(=|&|$)")`
}
uri @tracking_query query {
-utm_source
-utm_medium
-utm_campaign
# … the rest of the list
}
The full list is utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id, utm_source_platform, utm_creative_format, utm_marketing_tactic, gclid, gclsrc, gbraid, wbraid, dclid, srsltid, _gl, _ga, fbclid, msclkid, twclid, ttclid, li_fat_id, igshid, yclid, mc_cid and mc_eid.
- It only runs when the query contains a listed parameter. Caddy's
uri queryrewrites the whole query whenever it runs, even with nothing to remove, and turns a key with no value (?k) intok=. Other requests keep their query exactly as sent. A query with a tracking parameter and a valueless key still getsk=, which is harmless for pages. - It never runs for
/_nuxt/*. Innuxt dev, Vite asks for style blocks with URLs such as?vue&type=style&index=0&lang.css. Rewritten to?vue=&…, Vite served them as raw CSS and the app never mounted. - Every other path is covered,
/_apiincluded. A URL with these parameters gets the same cache entry as the URL without them. Any other query parameter still gets its own entry. - Only the server loses them. Analytics scripts (Google Analytics, Meta Pixel and so on) read them from the browser's address bar, so tracking still works.
- Nuxt and php never see them. If your project needs one on the server, remove it from the block and from the matcher's expression. To strip another parameter, add a
-nameline and add the name to the expression. The two lists must match. Wildcards are not supported. The expression is CEL, so keep it on one line and put no#comments inside it. - The access log still shows the original URL. A
utm_parameter in Caddy's log does not mean the strip failed.
@tracking_query matcher and the uri @tracking_query query { … } block from the template's api/frankenphp/Caddyfile into your own, inside the site block. If you already copied a plain uri query { … } block, add the matcher: without it, nuxt dev serves style blocks as raw CSS and the app never mounts.Purging all cached data
A purge by tag only reaches what the API knows has changed. If data changed some other way, such as a direct database edit, a data fix or an import, the cached API responses and pages still hold the old data. For that, flush the whole cache:
- in site settings, click Purge all cached data and confirm, or
- call
$cwa.siteConfig.purgeHttpCache(), which sendsPOST /_/http_cache/purge, or - run
php bin/console silverback:api-components:purge-http-cachein the API container.
All three drop every cached response, API and page HTML together. There is no way to flush only the API responses. See Flushing the Whole HTTP Cache for how the API does it.
The site settings action is shown even when pageCache.enabled is false, because the API cache exists either way. The section is then titled Cached data and the page-cache buttons are hidden. With page caching on, a successful purge offers Warm page cache now, because every page is a cache miss until it is rendered again.
501 and site settings reports Nothing was purged.Warming the cache from site settings
After a purge, every page is a cache miss until someone visits it. An admin can refill the cache from site settings with Warm page cache, which shows live progress. It calls $cwa.siteConfig.warmPageCache(), which posts to the module's server route POST /_cwa/page-cache/warm.
The warm runs on the Nuxt server, not in the admin's browser. The browser's requests carry the auth cookie, and the shared cache deliberately skips those, so nothing would be stored. Instead, the server reads the same page list as the sitemap and requests each page anonymously through the in-cluster origin, with the Host header set to the site's public host. Each response is stored under the same cache key visitors use.
- Only admins can start it. The route checks the role through the API, and returns
403otherwise. - One warm runs at a time per Nuxt server process. A second request to the same server gets
409while one is running, but separate replicas can warm at the same time. - Only a
200counts as warmed. The result lists every other page with its cause:/about (308 → https://www.example.com/about)for a redirect,/about (no response: DEPTH_ZERO_SELF_SIGNED_CERT)when the request got no response, or/about (timed out). The page list already leaves out redirect routes, so a3xxhere means the request never reached the renderer.
Settings live in the server-only runtime config, so they can be set per environment with NUXT_CWA_PAGE_CACHE_WARM_* variables:
export default defineNuxtConfig({
runtimeConfig: {
cwa: {
pageCacheWarm: {
concurrency: 3, // pages at once, capped at 10
timeout: 30000, // per page, in milliseconds
origin: '', // defaults to the origin of apiUrl
},
},
},
})
Choosing the warm origin
origin (NUXT_CWA_PAGE_CACHE_WARM_ORIGIN) is where the warm connects. It defaults to the origin of the server's apiUrl, and the public host travels in the Host header, so the page is stored under the key visitors hit and the request never leaves your network.
On Kubernetes, leave it unset. The chart runs Caddy with SERVER_NAME=:80: plain HTTP, no redirect, so the derived origin (http://<fullname>) already works.
On a single server where Caddy runs its own automatic HTTPS, you must set it, and to a different URL from the one the server renders with:
| Example | Why | |
|---|---|---|
Rendering (apiUrl) | http://php.local/_api | Over HTTPS the TLS name would be the internal host, which only an internally issued certificate covers. |
Warming (NUXT_CWA_PAGE_CACHE_WARM_ORIGIN) | https://php.local | The warm sends the public Host, so the TLS name is your real domain and its ACME certificate validates. |
The template's compose.prod.yaml sets both, from its 2.0.0-alpha.2 release on; see Production Docker Compose.
Over plain HTTP on that setup, Caddy answers every page with a 308 to HTTPS, so every page is reported failed and nothing is warmed.
reverse_proxy sets X-Forwarded-Proto from the scheme it received, so the pages would be rendered and cached with http:// canonical and og:url values, for the API's full cache lifetime.When Caddy issues the certificate itself (SERVER_NAME=localhost, a .local host or tls internal), the warm fails with DEPTH_ZERO_SELF_SIGNED_CERT, because Node doesn't trust Caddy's local root. Give Node the root with NODE_EXTRA_CA_CERTS, or install it in the system trust store and start Node with --use-system-ca. In the template, the root is at /data/caddy/pki/authorities/local/root.crt in the php container's caddy_data volume. The Nuxt container doesn't mount that volume, so copy the file out or mount it.
The template's local development stack already sets NODE_TLS_REJECT_UNAUTHORIZED=0 on the Nuxt container, so the default origin works there.
The deploy pipeline has its own warm step, which does the same job after each deploy: see CI/CD.
Fixed: 404 status on the wrong page
72df02b3 could apply one request's 404 to a different page rendered at the same moment (cwa-nuxt-module#313). That page kept its content but got a 404 status, and a shared cache could store it for the full page lifetime. If you're on an older build, update the module. Until you do, watch for real pages reported as 404 in the post-deploy cache warm, and purge the page cache if one is stored.