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

Progressive Web App & Offline

Add safe service-worker caching and offline support to a CWA app — without ever serving a cached draft to the public.

CWA has no built-in PWA. Offline support is opt-in and assembled in your own app with @vite-pwa/nuxt — the module deliberately doesn't ship it as a dependency, so you stay in control of the service worker.

The subtlety, and the reason this needs its own guide, is that CWA API responses are auth-scoped. A signed-in admin and a signed-out visitor request the same URLs — /_/routes/{path}, the resource manifest, a component — and the API returns the draft or the published view based on the auth cookie, not the URL (see the API cache-safety design). A naive service worker that caches "the API" by URL pattern will eventually store an admin's draft and serve it to the public. Everything below exists to make caching safe in the face of that.

Do not cache CWA API responses by URL denylist (e.g. "skip anything under /admin"). Draft and published share a URL, so no pattern can tell them apart. Safety comes from the API marking each response and the service worker honouring that mark — never from the front-end guessing.

The shape

Three tiers, each caching a different kind of thing:

TierCachesWhere
App-shell precacheYour built JS/CSS/fonts/iconsService worker (globPatterns)
CWA API runtime cacheSafe (public) API responses, read offline onlyService worker (runtimeCaching + a safety gate)
Page-side persistence (advanced)Auth-aware data the SW must not holdYour app (IndexedDB)

The first two are the @vite-pwa/nuxt config below. The third is an optional pattern you build yourself — covered at the end.

Install

pnpm add -D @vite-pwa/nuxt
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@vite-pwa/nuxt'],
})

Add it alongside the CWA module — it is app-level, never a CWA dependency.

Tier 1 — App-shell precache

Precaching your own build output is always safe — it has no auth dimension. This is the globPatterns in the workbox block:

pwa: {
  workbox: {
    globPatterns: ['**/*.{js,mjs,ts,json,css,html,png,svg,ico,jpg,jpeg,webp}'],
  },
}

Keeping admin chunks out of the precache

globPatterns matches every built chunk, including the /_cwa admin pages and any editor that only admins open. After a visitor's first page load, the service worker downloads all of them in the background. The module keeps its admin chunks out of the prefetch hints, but it ships no service worker, so the precache is up to you.

Chunk file names are hashes, so globIgnores can't pick them out. The template reads the build manifest instead. A build:manifest hook collects the files that only admin-only sources reach: the /_cwa pages and TipTapHtmlEditor.vue. A file shared with anything a visitor can load stays in. A manifestTransforms entry then drops the collected files from the precache:

pwa: {
  workbox: {
    manifestTransforms: [       async (entries) => ({        manifest: entries.filter(entry => !adminOnlyFiles.has(basename(entry.url))),        warnings: [],      }),    ],  },
}

Copy collectAdminOnlyFiles and the hook from the template's app/nuxt.config.ts (added in components-web-app c0f5658). If you add another admin-only component, add it to isAdminOnlySource.

Tier 2 — CWA API runtime cache

This is the load-bearing part. Cache CWA's content endpoints with NetworkFirst, gated by a cacheWillUpdate plugin that drops any response the API marked non-cacheable:

// nuxt.config.ts
pwa: {
  registerType: 'prompt',
  workbox: {
    // MUST be present — see the callout below
    navigateFallback: null,
    // Lets an applied update finish — see "Applying updates" below
    clientsClaim: true,
    globPatterns: ['**/*.{js,mjs,ts,json,css,html,png,svg,ico,jpg,jpeg,webp}'],
    runtimeCaching: [
      {
        // Anchored to CWA's content paths only — NOT a broad API-origin match,
        // so the Mercure SSE stream is never swallowed (that would break real-time updates).
        urlPattern: ({ url }) =>
          /\/_api\/(?:_\/(?:routes|resource_manifest|pages|layouts|component_groups|component_positions)|page_data|component)\b/.test(url.pathname),
        handler: 'NetworkFirst',
        options: {
          cacheName: 'cwa-api',
          networkTimeoutSeconds: 3,
          cacheableResponse: { statuses: [200], headers: {} },
          plugins: [
            {
              // The safety gate. The API marks an authenticated admin's draft view
              // `Cache-Control: private, no-store`; returning null here means "don't store it",
              // so the cache only ever holds public, published responses.
              cacheWillUpdate: async ({ response }) => {
                const cc = response.headers.get('cache-control') || ''
                if (/no-store|private/.test(cc)) {
                  return null
                }
                return response.status === 200 ? response : null
              },
            },
          ],
          expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 },
        },
      },
    ],
  },
}

Three things make this safe rather than dangerous:

  • NetworkFirst, not StaleWhileRevalidate. The cache is only ever read when the network fails — i.e. offline. An online visitor always gets a fresh response, so no one is served a stale (or wrongly-scoped) page while connected.
  • The cacheWillUpdate gate. It reads Cache-Control and refuses to store anything marked no-store or private. That mark comes from the API (cache-safety headers) — an authenticated admin's draft view is private, no-store, so it never enters the cache. Only public, published responses are stored.
  • The narrow urlPattern. It matches CWA's content resource paths, deliberately not the whole API origin, so the Mercure Server-Sent-Events stream is left alone. A broad pattern would intercept the SSE connection and kill real-time updates.
Always set navigateFallback explicitly — write navigateFallback: null if you don't want one. @vite-pwa/nuxt checks for the key's presence, not its value: omit it entirely and it silently defaults to '/', serving your app shell for every SSR navigation. The presence of the key is what disables the fallback.

Cache layering: the s-maxage / max-age split

For this to work, the API must set an s-maxage for the shared edge cache (for example Souin, which can be purged) and max-age: 0 for the browser. The CWA template sets max_age: 0 in every environment. shared_max_age is one year in production (api/config/packages/prod/api_platform.yaml) and 60 seconds in development (api/config/packages/api_platform.yaml). A long shared lifetime is safe because the API purges the shared cache whenever a resource is saved. What matters is that max-age stays 0.

Page caching is on by default. A cached page's lifetime is the lowest s-maxage of every API response the page used, capped by pageCache.sharedMaxAge if you set it. In the template that gives one year in production and one minute in development. Keep this in mind when you test purging locally: a page that looks invalidated may simply have expired.

Why it matters: if the API instead sent a long max-age, the browser's own HTTP cache would keep an un-purgeable copy, and a Workbox fetch() would be served that stale copy without ever reaching the shared cache — so NetworkFirst would hand back stale content it never had a chance to revalidate. Keep max-age: 0; let the shared cache hold the long-lived, purgeable copy.

Clearing the cache when a session ends

NetworkFirst closes most of the risk, but not one window: a response cached while someone was signed in can outlive their sign-out or session expiry on a shared device, and still be read offline.

The module closes that window for you. When a session ends, it deletes the cwa-api cache. You do not need to write any service-worker code. A session counts as ended in three cases:

  • The user signs out with $cwa.auth.signOut().
  • An API request in the browser returns 401 while the user is signed in. This clears the cache but does not sign the user out.
  • The server finds the session has already expired while rendering the page. This is the most likely case on a shared device: someone opens the site after the last user's session has lapsed. The server cannot reach the browser's caches, so it records that the session ended, and the browser clears the cache once when the app starts.

This is on by default whenever @vite-pwa/nuxt is installed and not disabled (pwa.disable). It clears the caches named in cwa.auth.clearCachesOnSessionEnd, which defaults to ['cwa-api']. If you rename your runtime cache, list the new name:

export default defineNuxtConfig({
  cwa: {
    auth: {
      clearCachesOnSessionEnd: ['my-api-cache'],    },
  },
})

Set it to [] to turn clearing off. Without @vite-pwa/nuxt the option does nothing, even if you set it.

Only a signed-in session ending clears anything. An anonymous visitor's session check also returns 401, but it does not clear the cache, so offline caching keeps working for visitors who never sign in.

Only the caches you name are deleted. The Workbox precache is never touched, so the app shell and offline support survive a sign-out. The module deletes the caches from the page with caches.delete(), so it works whether or not a service worker controls the page at the time. Clearing never delays or blocks sign-out, even if a delete fails.

If the browser was closed and the login had already lapsed before the next visit, the visitor looks anonymous and nothing is cleared. Keep expiration.maxAgeSeconds short: it is the only limit in that case.
Diagram: request flow through the three tiers — browser → SW (cacheWillUpdate gate) → shared edge cache (Souin) → API — showing where a private, no-store response is dropped.

Applying updates

Use registerType: 'prompt' (above). With it, a new service worker installs after a deploy and then waits; nothing replaces the running app until something applies the waiting worker. That matters because CWA admins edit inline, and an update that reloads the page mid-edit throws the edit away.

A waiting worker never activates on its own. If nothing applies it, visitors keep the old assets indefinitely.

The template applies it silently, on the visitor's next page navigation, with a small client plugin, app/plugins/pwa-update.client.ts. There is no notice for the visitor to act on:

export default defineNuxtPlugin((nuxtApp) => {
  useRouter().afterEach((to, from, failure) => {
    if (failure || to.path === from.path) {
      return
    }
    const $pwa = nuxtApp.$pwa as ReturnType<typeof usePWA> | undefined
    const $cwa = nuxtApp.$cwa as ReturnType<typeof useCwa>
    // Held while an admin is editing, so unsaved inline edits are never lost
    if (!$pwa?.needRefresh || $cwa.admin.isEditing) {
      return
    }
    void $pwa.updateServiceWorker()
  })
})

updateServiceWorker() activates the waiting worker. @vite-pwa/nuxt then reloads the page itself once the new worker takes control, so the visitor lands on the page they were going to, running the new build.

Four details are easy to get wrong:

  • Use afterEach, not beforeEach. The reload is the plugin's own window.location.reload(), and it reloads whatever URL is current. By afterEach that is the destination. From beforeEach, the reload can cancel the navigation in progress and leave the visitor on the page they were leaving.
  • Keep clientsClaim: true in the workbox block. The reload only happens once the new worker takes control of the page. A page that no worker controlled yet (the load that first registered the worker, or a Shift-reload) is never claimed without it, so the update never finishes. It is safe with 'prompt': the worker still only activates when the plugin asks.
  • needRefresh is a plain boolean, never a ref. $pwa is a reactive() object, so $pwa.needRefresh.value is undefined and the update never applies. $cwa.admin.isEditing is a plain getter as well.
  • Type the injections through the composables. Inside defineNuxtPlugin, nuxtApp.$pwa and nuxtApp.$cwa are typed unknown, which fails type checking. $pwa is only provided by @vite-pwa/nuxt's own client plugin, and is undefined whenever that plugin is not registered, so optional-chain it.

Only a change of path triggers the update, so following an in-page anchor or changing a query string never reloads the page. An update held back while an admin is editing applies on their first navigation after they leave edit mode.

Tier 3 — Page-side persistence (advanced)

The service-worker cache holds only public responses. For auth-aware data that must survive offline but must never sit in a shared SW cache — a signed-in user's own view — persist it from the page instead, where you can read $cwa.auth.signedIn and decide whether it's safe to store.

The module's in-memory route cache (route-keyed, bounded, non-reactive) is the natural thing to persist to IndexedDB for this. This is a pattern you build yourself — the module keeps the cache in memory only and ships no persistence layer. Treat it as complementary to the SW cache, not a replacement.

Offline and real-time updates

The Mercure client checks for missed changes after it loses its connection:

  • When the connection drops, the module marks Mercure as disconnected and logs a warning.
  • When the connection comes back, or the browser fires its online event while Mercure is disconnected, the module fetches every resource on the current page again. It waits for any requests that are already running before it does this.

Because it fetches the resources again, your Mercure hub does not need an event store to find updates missed while offline.

The module does not change the page with this new data. If a resource has not changed, the new copy is discarded. If it has changed, the new copy is held back, and the page keeps showing the old content:

  • Admins see "The content on this page is outdated" in the admin header, with a button to load the update.
  • Other visitors see the same notice and Update button at the top of the page, rendered by cwa-root-layout. A page that uses a different Nuxt layout shows no notice, and keeps the old content until the visitor goes to another page or reloads.
Only resources on the current page are fetched again. Resources for other pages that are still in the module's route cache are not refreshed. They update the next time that page is fetched.

In short, offline caching means the app still opens and shows the last content it had. After reconnecting, the module finds what changed on the current page and offers it through the outdated-content notice.

Screenshot: the app loaded offline (DevTools → Network → Offline) showing last-known content and an offline indicator.