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}'],
  },
}

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,
    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 a long s-maxage (for the shared edge cache, e.g. Souin — purgeable) together with max-age: 0 (for the browser). The CWA template's production API config does exactly this.

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.

Purge on sign-out and on 401

NetworkFirst closes most of the risk, but not one window: a cached public response can outlive a sign-out or a session expiry on a shared device, and be read offline afterwards. Close it by having the page tell the service worker to drop the cwa-api cache on sign-out and on any 401:

// after sign-out, or on a 401 from the API
navigator.serviceWorker?.controller?.postMessage({ type: 'CWA_PURGE_API_CACHE' })
// in your service worker
self.addEventListener('message', (event) => {
  if (event.data?.type === 'CWA_PURGE_API_CACHE') {
    event.waitUntil(caches.delete('cwa-api'))
  }
})

Do this from the page, not the service worker — the page can read $cwa.auth.signedIn, whereas an auth flag held inside the SW fails open when the worker restarts.

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.

Update prompt

Use registerType: 'prompt' (above) so a new deploy doesn't silently swap the app out from under an admin mid-edit. @vite-pwa/nuxt auto-imports a usePWA() composable exposing $pwa.needRefresh and $pwa.updateServiceWorker(); gate the prompt on edit state:

<script setup lang="ts">
const { $pwa } = usePWA()
const cwa = useCwa()

// Only offer to reload when it won't interrupt an edit
const canPrompt = computed(() => $pwa?.needRefresh && !cwa.admin.isEditing.value)
</script>

<template>
  <UButton v-if="canPrompt" @click="$pwa.updateServiceWorker(true)">
    Update available — reload
  </UButton>
</template>
usePWA() is provided by @vite-pwa/nuxt, not CWA — the snippet above is a pattern to adapt, not a component the module ships. $pwa is client-only, so guard for undefined during SSR.

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

Be clear-eyed about the limits when a client goes offline and comes back:

  • CWA's Mercure client opens an EventSource and handles incoming messages, but it has no reconnect, error, or online/offline handling of its own — it relies on the browser's native EventSource reconnection.
  • That native reconnect only backfills missed events if your Mercure hub runs an event store (replaying via Last-Event-ID). Without one, updates that happened while offline are lost, and a cached resource can stay stale until the next full fetch of that route.

So treat SW/offline caching as "the app still opens and shows last-known content offline", not "the app self-heals to live state on reconnect". If you need guaranteed freshness after reconnection, trigger a re-fetch of the current route yourself.

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