The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
DraftNuxt Module

Page Caching

How the module lets a shared cache store rendered page HTML, tags it for purging, and works out how long each page can be kept.

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-Key lists the IRI of every CWA resource used to render the page, plus the constant key cwa-html. When the API saves a resource, it purges that resource's IRI, and every page that used it is dropped. Saving site config purges cwa-html, which drops every page. See Purging Every Rendered Page.
  • max-age=0 is fixed. A browser's cache can't be purged, and a stale page that points at an old build's /_nuxt files loads blank.
  • stale-while-revalidate is added only when you set staleWhileRevalidate.
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.
Screenshot: DevTools → Network showing a page response's 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-maxage of every API response fetched during the render (or its max-age, if it has no s-maxage)
  • the time left until any API response's Expires, measured against that response's own Date header

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.

Set 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.
Module builds before 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 private or no-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.

Module builds before 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_cache notes in Docker.
  • The page's cache key must not include Accept. Each browser sends a different Accept header 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 keeps Accept in the key for /_api* only. API responses do depend on it, and API Platform's Swagger UI response sends no Vary: 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-Encoding value. In Caddy, use order 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.
Your own personalised content is not detected. The module only understands CWA data. If your pages render per-visitor content of their own (a cookie-based useFetch, a geo banner, an A/B test), turn page caching off or exclude those paths from the shared cache.
Nuxt 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.
A deploy changes the /_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 query rewrites the whole query whenever it runs, even with nothing to remove, and turns a key with no value (?k) into k=. Other requests keep their query exactly as sent. A query with a tracking parameter and a valueless key still gets k=, which is harmless for pages.
  • It never runs for /_nuxt/*. In nuxt 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, /_api included. 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 -name line 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.
Projects generated before this change don't have the block. Copy the @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 sends POST /_/http_cache/purge, or
  • run php bin/console silverback:api-components:purge-http-cache in 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.

Ordinary edits in the admin are already purged by tag, so you don't need this after saving content. If the cache can't be flushed (the purger isn't Souin), the endpoint returns 501 and site settings reports Nothing was purged.
Screenshot: site settings with the Purge all cached data button and its confirmation dialog.

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 403 otherwise.
  • One warm runs at a time per Nuxt server process. A second request to the same server gets 409 while one is running, but separate replicas can warm at the same time.
  • Only a 200 counts 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 a 3xx here 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:

ExampleWhy
Rendering (apiUrl)http://php.local/_apiOver 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.localThe 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.

Don't keep the warm on HTTP to avoid the certificate. Caddy's 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 module has no option to turn certificate verification off, and won't add one. With verification off, a warm pointed at the wrong host would report every page warmed while storing nothing.

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

Module builds before 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.