CWA 2 is a prerelease (2.0.0-alpha), heading for a beta. It runs production sites; configuration can still change between releases, always with upgrade notes. View versions and support
Nuxt Module (Front-End)

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=31557600
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.

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.

A page response with any status other than 200 (a redirect or an error) gets no-store. The module recognises an error render by Nuxt's ssrContext.error, so a page that throws while rendering is never cached. So does any render where the page's own route or resource failed to load.

A render that is missing a component is not cached either. That's when a fetch for one of the page's resources still fails with a 5xx or a network error after its retry, or the server's wait for a resource being fetched times out after 10 seconds. The page is sent with the component missing, but marked no-store, so the next visitor gets a fresh render instead of the broken one for the API's full lifetime. A 4xx, such as a 404, doesn't count: the page renders without that resource and is cached as usual, unless the failed resource is the page's own route or resource.

A component that isn't published yet doesn't stop the page that places it being cached. The API leaves the component out of the component position for a visitor, so the module renders nothing there and makes no request. The cached page carries the component's tag and expires when a scheduled component goes live, and publishing the component purges the page.

A page that used no CWA resources, or whose lifetime works out as zero, gets no cache headers from the module.

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. When you know which pages it affects, purge those pages (see Purging One Page). Flushing the whole cache is a last resort, because every page is then slow until it has been rendered again. To flush it:

  • 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. With Cloudflare edge caching configured, a flush also purges everything in the Cloudflare zone (see Purges must reach Cloudflare). 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.

Purging One Page

From the page's settings

An admin can purge the page on screen from its settings. Click the page's name in the admin bar to open its settings, open the Info tab and click Purge this page's cache, then confirm. It drops the page's cached HTML and the cached API data it is built from, and they're rebuilt on the next visit. Other pages that share its layout or components are rebuilt too, because they carry the same tags. With Cloudflare edge caching, the purge reaches the edge as well when purges reach Cloudflare.

The button is shown only when the settings are open for the page on screen, because it uses that page's loaded manifest. It sends one POST /_/http_cache/purge with a body:

  • paths: the route path of each page in the manifest (a nested page has one per depth)
  • tags: every IRI in the manifest, plus manifest:<IRI> for each page and page data

The API purges only those tags and never flushes. See HTTP Caching › Purging One Page or Tag › From the API. The result is shown beside the button:

ResultMessage
PurgedThis page's cache has been purged. It will be rebuilt on its next visit.
401 or 403This page's cache could not be purged: your account does not have permission to do this.
501Nothing was purged. This deployment's cache cannot purge single pages.
422Nothing was purged. The API did not accept this page's cache tags.
Anything elseThis page's cache could not be purged (status). Please try again.

From the command line

To drop one page from the cache, and from Cloudflare when purges reach it (see Cloudflare › Purges must reach Cloudflare), run this in the API container:

php bin/console silverback:api-components:purge-http-cache --path /about-us

--path purges the tag that a change to the route at that path purges, which every page using the route carries. --tag purges any tag, and both can be repeated. See HTTP Caching › Purging One Page or Tag.

Without the command, purge the route's tag through Caddy's admin API, inside the API container. It answers 204:

curl -X PURGE -H 'Surrogate-Key: /_api/_/routes//about-us' http://localhost:2019/souin-api/souin

The tag is the route's IRI, so the path's leading / gives two slashes. -H 'Surrogate-Key: /about-us', the path Souin tags every entry with, and a regex purge (/souin-api/souin/<regex>) clear Souin's store only, never Cloudflare's. Nothing purges Cloudflare by URL; use Cloudflare's own purge_cache API with files for that.

Don't send PURGE to the page's URL. It never purged anything. The site answers PURGE and BAN with 405.

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
            },
        },
    },
})

In the template's Kubernetes deploy, set them as CI variables: every NUXT_* CI variable reaches the Nuxt pods. See CI/CD › Site settings.

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; 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.

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. It caches only paths with no file extension, and .xml for the sitemap, so static files in public/ are never cached. See Docker › What the page cache stores.
  • 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.
  • Normalise Accept-Encoding before a cache that stores compressed pages. The template's Caddyfile has order cache before encode, so a page is compressed once, on the miss, and a hit is served without compressing it again. It first rewrites Accept-Encoding to br, gzip or nothing and puts that value in the cache key, so each page is stored at most three times. With the raw header in the key, the cache would keep one copy for every value browsers send. With the cache inside encode instead, pages are stored uncompressed and every hit is compressed again: Brotli capped a php pod at about 110 page views a second. See Docker › Compressed cache entries for the Caddyfile lines.
  • 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.
  • A CDN in front must not cache pages unless purges reach it. Otherwise an edited page stays stale at the edge for as long as the CDN keeps it. See Cloudflare.
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.

Origin Protection

The template's Caddyfile also stops made-up URLs and floods of requests from getting past the cache: it keeps only the query parameters pages read, strips tracking parameters, and rate-limits cache misses. These are deployment settings, not module options, so they're on Origin Protection.