Progressive Web App & Offline
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.
/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:
| Tier | Caches | Where |
|---|---|---|
| App-shell precache | Your built JS/CSS/fonts/icons | Service worker (globPatterns) |
| CWA API runtime cache | Safe (public) API responses, read offline only | Service worker (runtimeCaching + a safety gate) |
| Page-side persistence (advanced) | Auth-aware data the SW must not hold | Your 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, notStaleWhileRevalidate. 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
cacheWillUpdategate. It readsCache-Controland refuses to store anything markedno-storeorprivate. That mark comes from the API (cache-safety headers) — an authenticated admin's draft view isprivate, 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.
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.
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
EventSourceand handles incoming messages, but it has no reconnect, error, or online/offline handling of its own — it relies on the browser's nativeEventSourcereconnection. - 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.