CWA 2 is a prerelease (2.0.0-alpha), heading for a beta. It runs production sites; configuration can still change between releases, always with upgrade notes. View versions and support
Configuration

Site Config & SEO

Admin settings panel, siteConfig API, per-page SEO from page data, sitemap, maintenance mode, and robots configuration.

CWA's SEO layer is built on the Nuxt SEO modules. The module registers them for you, so useSeoMeta, useSchemaOrg, robots meta, sitemap generation and OG images work out of the box. It doesn't register the @nuxtjs/seo bundle itself. CWA layers on top of them with API-driven site config and automatic per-page meta from your Page and PageData records.

Site-wide settings are stored in the API and cached in Pinia. Admins change them at /_cwa/settings — no deploy required. Saving a setting also clears every cached page, so a new siteName shows up straight away rather than when the page cache expires (see Purging Every Rendered Page).

Default Settings

These are the site config keys (SiteConfigParams) and the module's defaults. Passing any other key to saveConfig() sends an unknown parameter to the API.

KeyTypeDefaultDescription
siteNamestring'CWA Web App'Used in <title> concatenation
fallbackTitlebooleantrueWhen a page has no title, use the title-cased last URL segment instead
concatTitlebooleantrueAppend | siteName to page titles
indexablebooleantrueWhether search engines should index the site
canonicalUrlstring''Base URL for canonical tags
sitemapEnabledbooleantrueInclude CWA routes in the generated sitemap
sitemapXmlstring''Custom sitemap XML served at /__sitemap__/cwa-custom.xml and added to the sitemap index when non-empty. Validated on save — an invalid document aborts saveConfig() and returns totalConfigsChanged: 0
maintenanceModeEnabledbooleanfalseServe a 503 to all non-admin visitors
robotsTextstring''Custom robots.txt content
robotsAllowNonSeoCrawlersbooleantrueAllow non-SEO bots
robotsAllowAiBotsbooleantrueAllow AI crawlers
robotsRemoveSitemapbooleanfalseOmit the sitemap directive from robots.txt

Accessing Site Config

Use cwa.siteConfig.config. It is declared as a ComputedRef, but site config is a Pinia setup store — Pinia wraps those in reactive(), which unwraps the ref — so read properties directly, without .value:

const cwa = useCwa()

const siteName = computed(() => cwa.siteConfig.config.siteName)
const isMaintenanceMode = computed(() => cwa.siteConfig.config.maintenanceModeEnabled)
const sitemapEnabled = computed(() => cwa.siteConfig.config.sitemapEnabled)

Or use useCwaSiteConfig directly for utility functions:

import { useCwaSiteConfig } from '#imports'
const { defaultSiteConfig } = useCwaSiteConfig()

Saving Config Changes (Admin)

const { totalConfigsChanged } = cwa.siteConfig.saveConfig({
    siteName: 'My Rebranded Site',
    robotsAllowAiBots: false
})

Only changed keys are sent, and saveConfig returns before the requests finish. See cwa.siteConfig › Saving for exactly what is sent and when a save does nothing.

Per-Page SEO

The module's CWA page component sets <title> and <meta name="description"> from your Page and PageData records, so static pages and dynamic pages are both covered:

  • Title: at each page depth, from deepest to shallowest, it takes the page data's title, or the Page's title if the page data has none. The titles it finds are joined with |, deepest first.
  • Description: the first metaDescription found the same way, deepest first. Descriptions are not joined.
  • No title at any depth: when fallbackTitle is on, the title is the route's meta.title, or else the last URL segment in title case. On a 404 or 500 error it is the status and message.
  • Untitled pages: a page or page data with no title doesn't count at that depth. The module treats the title Unnamed Page as no title too, so it's never joined into a nested page's title, and page data with it falls back to its page's title.
  • Site name: when concatTitle is on, cwa-root-layout appends the separator and siteName.

They're set with useHead() at tagPriority: 101, one step below the default priority, so any useHead() or useSeoMeta() call of your own overrides them:

// In a page template component
useHead({
    title: computed(() => `${articleHeadline.value} - In-depth Guide`)
})

Default OG Image

The module ships a default Open Graph image template via nuxt-og-image. Every CWA page gets a 1200 × 630 dark-background social card containing the white CWA logo, the page title, the meta description and the site name.

It needs two packages in your app, the renderers: satori (0.47.1 or later) and @resvg/resvg-js (2.6.2 or later). The template app installs both. Without them the module doesn't register nuxt-og-image, logs open graph image generation is disabled, and defineOgImage() does nothing.

pnpm add satori @resvg/resvg-js

The template is CwaDefault.satori.vue (a Satori/SVG renderer). It receives:

PropSource
titleCurrent page or pageData title
descriptionCurrent page or pageData meta description
siteNameThe site config's siteName, shown at the bottom. Falls back to cwa.rocks

Previewing locally — open the OG Image tab in Nuxt DevTools, or open the URL in a page's og:image meta tag.

The OG image secret

nuxt-og-image signs its image URLs with Nuxt's appSecret. In production, set NUXT_APP_SECRET to at least 32 characters, for example the output of openssl rand -base64 32.

Without NUXT_APP_SECRET, every /_og/ image returns a 500. Pages still render, so the only signs are broken social cards and [nuxt-og-image] URL signing has no valid secret in the server log. Every SSR pod must have the same value: one pod renders the signed URL into the page, and another may serve the image.

In development Nuxt generates its own secret and warns NUXT_B5028; a build never generates one. The template's deploy keeps one for you: it takes NUXT_APP_SECRET from a CI variable, else from the live release, else generates it (see Variables › Secrets). The template's compose.prod.yaml refuses to start without it.

Overriding the OG Image

Per-page override — call defineOgImage() in your page template component. Your call replaces the module default for that route:

// In your page template <script setup>
defineOgImage('MyCustomTemplate', {
    title: pageData.value?.data?.headline,
    imageUrl: pageData.value?.data?.heroImage?.contentUrl
})

Project-wide override — create app/app/components/og-image/CwaDefault.satori.vue in your application. Nuxt resolves component names from the app layer first, so this shadows the module's default template for all pages that haven't overridden per-page.

Open Graph and Twitter Cards

Social card meta tags (og:title, og:description, og:image, twitter:card) are too content-specific for the module to set automatically — add them in your page templates alongside your own content fields:

useSeoMeta({
    ogTitle: computed(() => pageData.value?.data?.headline),
    ogDescription: computed(() => pageData.value?.data?.summary),
    ogImage: computed(() => pageData.value?.data?.heroImage?.contentUrl),
    twitterCard: 'summary_large_image'
})

Maintenance Mode

When maintenanceModeEnabled: true, the module's server middleware throws a 503 Service Unavailable (statusMessage: 'Website under maintenance', message "We will be back up and running as soon as possible"), which Nuxt renders through your app/app/error.vue. There is no redirect and no dedicated maintenance page — customise the screen by handling error.statusCode === 503 in your error page.

Exempt from the check: /login, /__nuxt_error, anything under /_cwa, plus /sitemap.xml, /sitemap_index.xml, /robots.txt and /__sitemap__/*. Signed-in users whose api_component JWT carries ROLE_ADMIN or ROLE_SUPER_ADMIN (and hasn't expired) bypass it entirely, so admins can still reach the site to turn maintenance mode off.

Sitemap

The sitemap is generated by @nuxtjs/sitemap. The module registers a CWA source endpoint (/__sitemap__/cwa-urls) that lists your API's routes as loc entries. It leaves out routes a visitor can't land on as a page:

  • Redirects — a route that redirects to another route
  • Routes with no page — neither a page nor a pageData attached
  • Routes with no path

The list is fetched anonymously, so a route that isn't live yet (scheduled or draft) is left out as well, because the API doesn't return it to an anonymous request.

Your own sitemap sources

There is no per-route priority or changefreq. Set defaults through @nuxtjs/sitemap's own sitemap config in nuxt.config, or add a second source, if you need more than URLs.

A source endpoint of your own imports defineSitemapEventHandler from #sitemap/server. Sitemap hooks and handlers get Nuxt's RequestEvent, which has req, url, res and context but no event.node:

// app/server/api/__sitemap__/products.ts
import { defineSitemapEventHandler } from '#sitemap/server'

export default defineSitemapEventHandler(async (event) => {
    const products = await $fetch<{ slug: string }[]>('https://shop.example.com/products')
    return products.map(product => ({ loc: `/products/${product.slug}` }))
})

Add it to sitemap.sources in nuxt.config (sources: ['/api/__sitemap__/products']).

To publish a hand-written sitemap instead, put the XML in sitemapXml (admin → Settings) — it's served at /__sitemap__/cwa-custom.xml and added to the sitemap index.

Stop CWA contributing routes at all if you manage the sitemap externally — the source then returns an empty list:

// Admin settings panel → Sitemap → Disabled
// Or programmatically:
cwa.siteConfig.saveConfig({ sitemapEnabled: false })

Nuxt Config Overrides (Static Defaults)

You can set site config defaults in nuxt.config — these are merged with the API's stored values, with the API winning for any key it defines:

// nuxt.config.ts
cwa: {
    siteConfig: {
        siteName: 'My App',
        canonicalUrl: 'https://www.example.com'
    }
}

This lets you ship a sensible default without requiring a database record on first boot.