Module Setup
@cwa/nuxt is the front-end half of a CWA application. It connects your Nuxt application to a Symfony API powered by API Platform, providing automatic route resolution, a reactive resource store, real-time updates via Mercure, authentication pages, a full admin CMS panel, and composables for every content pattern.
You own your Vue templates — CWA drives what data they receive and in what order they render.
app/ and its source directory is also app/, so CWA's Vue files are in app/app/cwa/ and your own pages in app/app/pages/. In a Nuxt project of your own, drop the first app/. See Where things live.The section overview, Nuxt Module, lists what each part of this section covers.
Install
Tagged releases are published to npm as @cwa/nuxt. Until a stable version exists, each pre-release is published as latest. This is what the template app depends on:
pnpm add @cwa/nuxt
CWA ships as a Nuxt layer, not just a module. Extend the layer in nuxt.config.ts. It registers the module for you, along with the /login and password-reset pages, the /_cwa/* admin panel, and the cwa-root-layout:
// app/nuxt.config.ts
export default defineNuxtConfig({
extends: ['@cwa/nuxt/layer'], modules: [
'@nuxt/ui', // optional — for your own components; CWA doesn't require it
'@nuxt/image' // optional — only if you use <NuxtImg>
]
})
Extend it by the package name, @cwa/nuxt/layer. A path such as ./node_modules/@cwa/nuxt/dist/layer works too, but the package name is the recommended form.
@cwa/nuxt to modules. extends is what wires it up — registering the module directly skips the layer, and you lose the auth pages, the admin panel and the root layout.The module declares @pinia/nuxt, @nuxtjs/robots, @nuxtjs/sitemap, nuxt-og-image, nuxt-schema-org, nuxt-seo-utils, nuxt-site-config and nuxt-link-checker as module dependencies, so Nuxt resolves and registers them for you. You don't need to list them yourself. If you do, use the module's major (see Nuxt SEO v6).
nuxt-og-image is registered only when the app installs its renderers, satori and @resvg/resvg-js. See Default OG Image.
Requirements
| Package | Version | Notes |
|---|---|---|
| Nuxt | >=4.6.0 | The module is developed and tested against Nuxt 4.6.2, Vue 3.5.44 and Vite 8, and accepts 4.6.0 or later. Nuxt warns if your app is on an older version |
pinia | ^4.0.3 | A dependency of the module, so you don't need to install it |
@pinia/nuxt | 1.0.2 exactly | A dependency of the module. If your app installs it too, Nuxt checks your copy against ^1.0.2 |
| Nuxt SEO v6 modules | @nuxtjs/sitemap 9, @nuxtjs/robots 7, nuxt-site-config 5, nuxt-seo-utils 9, nuxt-schema-org 7, nuxt-link-checker 6, nuxt-og-image 7 | Dependencies of the module. If your app lists any of them, or @nuxtjs/seo, it must use the same major. See Nuxt SEO v6 |
satori, @resvg/resvg-js | >=0.47.1, ^2.6.2 | Optional. Install both for OG images |
| api-components-bundle | 2.0.0-alpha.25 or later; 2.0.0-alpha.32 for module 2.0.0-alpha.24; 2.0.0-alpha.35 for module 2.0.0-alpha.26; 2.0.0-alpha.36 for module 2.0.0-alpha.29 | See Bundle versions below. Update the module and the bundle together |
The template app doesn't install Pinia itself, so it gets the right versions from the module. If your app installs @pinia/nuxt, install 1.0.2, the same version as the module's copy, so only one copy is installed.
pinia or @pinia/nuxt itself, Nuxt checks that copy against the module's requirement and refuses to boot when it doesn't match:Module `@pinia/nuxt` version (`0.11.3`) does not satisfy `^1.0.2`
pnpm add pinia@^4 @pinia/nuxt@1.0.2, or, if your own code doesn't import Pinia, remove them from your package.json and let the module supply them.Bundle versions
The bundle release each module release needs, or needs for all of its behaviour. A later module keeps the earlier rows' requirements, so module 2.0.0-alpha.29 needs bundle 2.0.0-alpha.25 at least, and 2.0.0-alpha.36 for everything:
| @cwa/nuxt | api-components-bundle | Why |
|---|---|---|
| 2.0.0-alpha.30 | No new requirement | Fixes to co-editing in the rich-text editor |
| 2.0.0-alpha.29 | 2.0.0-alpha.36 for checked saves; 2.0.0-alpha.37 for the Yjs editor | Admin saves and deletes send If-Match with _metadata.version, which bundle alpha.36 adds, and a 412 shows Use theirs / Keep mine. With an older bundle, saves aren't checked and crossing saves overwrite each other. Breaking: an API on another origin must allow If-Match in CORS. The Yjs rich-text editor stores its state in /_/collaboration_states, from bundle alpha.37. See Real-Time Updates › When You're Both Changing the Same Field and Rich-Text Editor › Typing at the Same Time |
| 2.0.0-alpha.28 | No new requirement | Field presence and useCwaRealtime events use the admin real-time channel of bundle alpha.35 |
| 2.0.0-alpha.27 | 2.0.0-alpha.35 for who else is here | Admins announce themselves with POST /_/realtime. With the endpoint missing or turned off, the module logs one warning and shows no one. See Real-Time Updates › Who Else Is Here |
| 2.0.0-alpha.26 | 2.0.0-alpha.35 for co-editing | The module tells its own saves from another admin's, and puts updates in order, by the _metadata.lastWrite that bundle alpha.35 adds. With an older bundle, a run of an admin's own quick saves can come back as someone else's change. Breaking: admin writes send X-CWA-Client-Id, so an API on another origin must allow the header in CORS whatever the bundle version. See Real-Time Updates › Co-Editing in the Admin |
| 2.0.0-alpha.25 | No new requirement | The placement menu for Clone and Move uses the same requests as alpha.24 |
| 2.0.0-alpha.24 | 2.0.0-alpha.32 for Move and the list of places behind the Shared badge | Move sends one POST {component}/move, which older bundles don't have, so Move doesn't work with an older bundle. The places come from GET {component}/usage, which lists them from bundle alpha.32; with an older bundle the Shared · N places badge is only a count. See The Admin Panel › Moving a Component and The Admin Panel › Shared Components |
| 2.0.0-alpha.23 | No new requirement | Move relocates a position with a PATCH of componentGroup and after/before, which bundle alpha.24 already accepts |
| 2.0.0-alpha.22 | 2.0.0-alpha.30 for the full clone behaviour, see The Admin Panel › Cloning a Component | The API refuses to place a component inside its own subtree from bundle alpha.30. With an older bundle the module still greys those targets out, but the API accepts them |
| 2.0.0-alpha.21 | 2.0.0-alpha.29 recommended | Anonymous Mercure updates and the cookie changes: bundle alpha.27 leaves an unpublished component out of a visitor's component position, and expires the Mercure cookie when a session ends. The module works with older bundles; see Real-Time Updates. Skip bundle alpha.27 and alpha.28: with a debug kernel (dev and test), publishing a component can return a 500 (Event "api_platform.processor.write" is not started), which alpha.29 fixes. Production without debug isn't affected |
| 2.0.0-alpha.20 | 2.0.0-alpha.25 | Required. The page settings' Purge this page's cache sends a body to POST /_/http_cache/purge. An older bundle ignores the body and flushes the whole cache. See Purging One Page |
| 2.0.0-alpha.19 | 2.0.0-alpha.24 | Required, both ways. The API orders component positions: the module sends after/before and reorders through the group's /order endpoint, which older bundles don't have. Bundle alpha.24 in turn needs module alpha.19, because it ignores the sortValue older modules write |
| 2.0.0-alpha.10 to alpha.18 | 2.0.0-alpha.13 | Publishing sends "publishedAt": "now" |
| 2.0.0-alpha.9 | 2.0.0-alpha.11 | For the Invalid files report; everything else works with 2.0.0-alpha.9 |
| 2.0.0-alpha.8 | 2.0.0-alpha.9 | For the Public without a route toggle |
| 2.0.0-alpha.4 to alpha.7 | 2.0.0-alpha.8 | Live updates use the Mercure 1.0 protocol, which a 0.x hub rejects |
Nuxt SEO v6
The module uses the Nuxt SEO v6 modules. If your app lists any of them in its package.json, use the same major, or two majors are installed side by side. The template app lists @nuxtjs/sitemap, on 9:
pnpm add @nuxtjs/sitemap@^9
What an app needs for them:
- OG images need a secret.
nuxt-og-image7 signs its URLs, so production needsNUXT_APP_SECRET. See The OG image secret. - The renderers' minimums are
satori0.47.1 and@resvg/resvg-js2.6.2. - Your own sitemap sources import
defineSitemapEventHandlerfrom#sitemap/server, and sitemap hooks get Nuxt'sRequestEvent, which has noevent.node. See Your own sitemap sources.
@nuxtjs/seo 6 yourself also turns on nuxt-skew-protection and nuxt-ai-ready. Skew protection polls for new builds, and AI Ready serves llms.txt and similar files. Together they add about 5.5 KB gzip to every visitor's entry. The module doesn't register @nuxtjs/seo, so this only applies if your app does. To turn them off:// app/nuxt.config.ts
export default defineNuxtConfig({
skewProtection: { enabled: false },
aiReady: { enabled: false },
})
Each release's bundle requirement is in Releases › The Module's Minimum Bundle.
Set the API URL
Set your API URL via runtimeConfig. The browser URL is used client-side; the server URL is used during SSR (can be an internal Docker network address):
export default defineNuxtConfig({
runtimeConfig: {
cwa: {
apiUrl: 'http://api-internal' // server-side (SSR), private
},
public: {
cwa: {
apiUrlBrowser: 'https://api.example.com' // client-side
}
}
}
})
In a Docker Compose setup these are typically different — the internal hostname resolves only within the Docker network. Nuxt Config › Runtime Config has the environment variables and URL rules.
The other options live under the cwa: key. The key ones at a glance:
export default defineNuxtConfig({
cwa: {
resources: { /* your CMS component types */ },
layouts: { /* your layout component types */ },
pages: { /* your page template component types */ },
pageData: { /* your PageData resource classes */ },
pagesDepth: 2, // nested page depth (default: 4)
siteConfig: { siteName: 'My App' }
}
})
See Nuxt Config for the complete reference.
What You Get
You do not create these — the module ships them.
Pages (override by creating the same path in app/app/pages/):
- The auth pages:
/login, password reset and email verification. See Auth Pages /_cwa/*— the full admin panel
Middleware:
- Route resolution on every navigation — fetches the manifest and resolves the layout, page, and page data IRIs from the current URL
- Auth state initialisation on the client — the
cwa_authcookie flags whether a session exists, and the signed-in user is loaded from the API's/meendpoint cwa-authandcwa-admin— named route middleware you opt into per page to require a signed-in user or an admin. See Protecting Pages
useCwa(), or $cwa in templates, is your entry point to everything the module manages:
const cwa = useCwa()
cwa.resources.page.value // current page resource (undefined until resolved)
cwa.auth.signedIn.value // boolean
cwa.admin.isEditing // admin edit mode active (plain boolean — no .value)
The useCwa() API lists every service, and which values are refs. Which of them are available during server rendering is covered in Reading Auth State.
Behind these services the module keeps its state in its own Pinia stores: resources, fetcher, mercure, apiDocumentation, auth, admin, error and siteConfig. Reach them through useCwa() rather than directly. cwa.forms is a service, not a store.
Mixing Your Own Pages with CWA
CWA's catch-all route covers / and all paths. But you can still create regular Nuxt pages alongside it — your app/app/pages/ files take precedence over CWA's dynamic routing.
For pages you write yourself, use definePageMeta to control how much of CWA runs on that page. definePageMeta CWA Options lists every key.
Disable CWA Route Fetching
Every page of your own that isn't CWA content must set cwa: { disabled: true }. The CWA route middleware runs on every navigation, and without the flag it asks the API for a Route at the page's path. There isn't one, so the visitor gets the error page:
<!-- app/app/pages/status.vue -->
<script setup lang="ts">
definePageMeta({
cwa: { disabled: true }
})
</script>
With the flag, CWA doesn't fetch a route manifest or resolve any resources. Auth state and useCwa() are still available. The one exception is a page that loads a CWA page by IRI, which must leave the flag off.
Use a Static Layout Component
If you want a fixed layout component instead of the one resolved from the API's Layout resource, name it with staticLayout. The value is a registered component name, such as one of your app/app/cwa/layouts/ components (primary.vue registers as CwaLayoutPrimary). It is rendered inside cwa-root-layout, so it is not a Nuxt layout name. To change the Nuxt layout, set layout in definePageMeta as usual.
<script setup lang="ts">
definePageMeta({
cwa: {
disabled: true,
staticLayout: 'CwaLayoutPrimary',
}
})
</script>
Load a CWA Page by IRI
A page of your own can render a CWA page instead of the one its path would route to. Use it when Nuxt owns a dynamic path, such as /resources/share/[slug], and a CWA page serves as its layout and template:
<!-- app/app/pages/resources/share/[slug].vue -->
<script setup lang="ts">
definePageMeta({
layout: 'cwa-root-layout',
cwa: {
fetch: {
iri: '/_/pages/<page id>',
manifestPath: '/_/resource_manifest/<page id>', // optional
},
},
})
</script>
The module fetches that resource as the primary page instead of a Route for the path. manifestPath lets it fetch the page's resources in one batch; without it, they're fetched as they're found.
routable_security, as the template does. Visitors get a 401, and server-side rendering passes that on, while signed-in admins see the page working. Turn on Public without a route in the page's settings (isReachableWithoutRoute).Use the CWA Root Layout on Your Own Page
Your own pages already use cwa-root-layout, because the module applies it (or your layoutName) to every page that doesn't set a layout of its own. Setting it explicitly makes that visible and gives you the admin header and edit-mode overlay on a hand-coded page:
<script setup lang="ts">
definePageMeta({
layout: 'cwa-root-layout',
cwa: { disabled: true } // disable CWA content fetching but keep the layout
})
</script>
This is useful for pages like account settings or checkout flows that are not CMS-managed but should still appear inside the site shell and support admin edit mode for other parts of the page.
TypeScript Support
The module exports types for all component patterns:
import type { IriProp } from '#cwa/composables/cwa-resource'
// Every CWA component receives :iri as a prop
defineProps<IriProp>()
#cwa is an alias the module registers for its runtime directory — that's the import path to use, not a @cwa/nuxt/* subpath (the package only exports its root entry).
API resource data is dynamically shaped (resource.data is typed as Record<string, any> or similar). Under strict mode TypeScript may flag index access on these types as potentially undefined, even in contexts where you've checked for it. If you encounter these conflicts and don't want to add ? or type assertions throughout your component code, set typescript: { strict: false } in nuxt.config.ts — Nuxt generates the real tsconfig.json, so configure it there. This is what the template app does, alongside typeCheck: true. Alternatively, keep strict mode on and use optional chaining (resource.data?.title) consistently.
Pre-Release Builds and Changelogs
Every commit to the module's dev branch is also published as @cwa/nuxt-edge. To use a change that isn't in a tagged release yet, alias the edge package to @cwa/nuxt, so your imports and config stay the same:
pnpm add @cwa/nuxt@npm:@cwa/nuxt-edge
Tagged releases are listed in the module's CHANGELOG.md and on its GitHub releases, which use the same notes. Changes on dev that aren't in a release yet are under Unreleased in the dev branch's copy. The Releases page shows these notes with the other packages' releases, and which bundle release each module release needs. For the API bundle's changes, see Bundle Setup.