Site Config & SEO
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.
| Key | Type | Default | Description |
|---|---|---|---|
siteName | string | 'CWA Web App' | Used in <title> concatenation |
fallbackTitle | boolean | true | When a page has no title, use the title-cased last URL segment instead |
concatTitle | boolean | true | Append | siteName to page titles |
indexable | boolean | true | Whether search engines should index the site |
canonicalUrl | string | '' | Base URL for canonical tags |
sitemapEnabled | boolean | true | Include CWA routes in the generated sitemap |
sitemapXml | string | '' | 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 |
maintenanceModeEnabled | boolean | false | Serve a 503 to all non-admin visitors |
robotsText | string | '' | Custom robots.txt content |
robotsAllowNonSeoCrawlers | boolean | true | Allow non-SEO bots |
robotsAllowAiBots | boolean | true | Allow AI crawlers |
robotsRemoveSitemap | boolean | false | Omit 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'stitleif the page data has none. The titles it finds are joined with|, deepest first. - Description: the first
metaDescriptionfound the same way, deepest first. Descriptions are not joined. - No title at any depth: when
fallbackTitleis on, the title is the route'smeta.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 Pageas 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
concatTitleis on,cwa-root-layoutappends the separator andsiteName.
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:
| Prop | Source |
|---|---|
title | Current page or pageData title |
description | Current page or pageData meta description |
siteName | The 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.
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
pagenor apageDataattached - 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.