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}'],
},
}
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, 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 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
401while 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.
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.
expiration.maxAgeSeconds short: it is the only limit in that case.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.
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, notbeforeEach. The reload is the plugin's ownwindow.location.reload(), and it reloads whatever URL is current. ByafterEachthat is the destination. FrombeforeEach, the reload can cancel the navigation in progress and leave the visitor on the page they were leaving. - Keep
clientsClaim: truein theworkboxblock. 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. needRefreshis a plain boolean, never a ref.$pwais areactive()object, so$pwa.needRefresh.valueisundefinedand the update never applies.$cwa.admin.isEditingis a plain getter as well.- Type the injections through the composables. Inside
defineNuxtPlugin,nuxtApp.$pwaandnuxtApp.$cwaare typedunknown, which fails type checking.$pwais only provided by@vite-pwa/nuxt's own client plugin, and isundefinedwhenever 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
onlineevent 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.
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.