SEO

The framework treats SEO as a mandatory, non-disableable core capability. The full stack is delivered by @nuxtjs/sitemap + nuxt-schema-org + @nuxtjs/robots + the framework's own useSeo() composable.

Design philosophy: SEO is the very reason to use Nuxt (otherwise a Vue 3 SPA would do). The framework therefore offers no toggle to turn it off. Every swift-web project automatically gets sitemap.xml + robots.txt + JSON-LD out of the box.

useSeo(): one-stop SEO meta output

Every app/pages/**/*.vue MUST call useSeo() at the top level of <script setup> (enforced by the ESLint swift-web/require-useSeo-in-pages rule plus swift-web/seo-tdk-length static length pre-checks on literal title/description — description display-width <50 / >200 and title >70 warn immediately, severity warn; CI can escalate to hard-block via --max-warnings=0). The composable emits the full SEO meta tag set automatically:

TagSource
<title> <meta name="description"> <meta name="keywords">useSeo input
<meta property="og:title"> og:description og:image og:url og:type og:locale og:site_nameuseSeo input + auto fallback
<meta property="og:image:width"> og:image:height1200 / 630 (OG standard)
<meta name="twitter:card"> twitter:title twitter:description twitter:imagesummary_large_image + synced with og:*
<link rel="canonical">input.canonicalPath ?? requestURL.pathname
<script type="application/ld+json">WebSite + WebPage (when schemaOrg !== false)
<meta name="robots">input.robots (optional)

Basic usage

<script setup lang="ts">
useSeo({
  title: 'Page title',
  description: 'Page description (50-160 chars recommended)',
})
</script>

Detail pages: backend-driven SEO node

Backend convention: detail-type APIs return a seo: { title, description, image } node. Use it with ?? fallback chain:

<script setup lang="ts">
const { data } = await useFetch(`/movie/detail/${id}`)

useSeo(() => ({
  title: data.value?.seo?.title ?? data.value?.name,
  description: data.value?.seo?.description ?? data.value?.summary,
  image: data.value?.seo?.image ?? data.value?.cover,
  type: 'article',
  article: {
    author: data.value?.author,
    datePublished: data.value?.created_at,
  },
}))
</script>

<title> template (titleTemplate)

Handed back to Nuxt native — the framework does not inject

The framework does not own titleTemplate (framework#89). The <title> site-name suffix is configured by the applier in nuxt.config.ts's app.head.titleTemplate using the Nuxt native mechanism, with the site name coming from swift.config.json's project.title (single source of truth):

// nuxt.config.ts
import swiftConfig from './swift.config.json'

export default defineNuxtConfig({
  app: {
    head: {
      titleTemplate: `%s | ${swiftConfig.project.title}`,
    },
  },
})
// page
useSeo({ title: 'Member admin', description: '...' })
// → <title>Member admin | Cinema CMS</title>

Custom separator / brand prefix — just reorder the titleTemplate string:

titleTemplate: `${swiftConfig.project.title} - %s`   // brand prefix
titleTemplate: '%s · Cinema CMS'                     // hardcoded brand
titleTemplate: '%s'                                  // no brand suffix

Multilingual site name — Nuxt native titleTemplate is a build-time static string and doesn't support t(). To translate the site name per locale, set titleTemplate to '%s' and pass a fully composed title in the page: useSeo({ title: \${t('Page Title')} | ${t('Site Name')}` })`.

Fallback behavior

  • No title passed → console.warn (<title> is left with only the titleTemplate site-name suffix). All pages should pass title explicitly.
  • useSeo no longer falls back to backend site_title for title, and no longer injects titleTemplate / replaces %site_title.
  • JSON-LD WebSite.name reads runtimeConfig.public.siteName (the applier writes it from swift.config.json's project.title), no longer backend site_title.

Private pages: noindex (robots prop)

For login, profile, admin, and similar pages that should NOT appear in search results, pass the robots prop:

<script setup lang="ts">
// Fully private (don't index, don't follow links)
useSeo({
  title: 'Sign in',
  description: 'Sign in to access full features',
  robots: 'private', // → <meta name="robots" content="noindex, nofollow">
})

// Redirect / search result pages (don't index but allow link-following)
useSeo({
  title: 'Redirecting',
  description: 'External link redirect transit page',
  robots: 'noindex', // → <meta name="robots" content="noindex, follow">
})
</script>

⚠️ Do NOT also add Disallow: /login/ to _robots.txt. That blocks the crawler from seeing the page, hiding the noindex meta tag — Google may then "index without snippet" the URL anyway, hurting your brand.

origin / canonical: single source of truth + CSR check

framework#87 reduced useSeo's origin resolution from a 4-tier fallback to one source of truth + one explicit override:

L1  Explicit input.siteUrl          ← per-page override (skips the CSR diagnostic)
L2  NUXT_PUBLIC_SITE_URL            set it to **converge** on that domain (opt-in; skips the CSR diagnostic)
L3  useRequestURL().origin          **default**: follows the domain the visitor actually used

Any site with ISR or prerendered pages must set NUXT_PUBLIC_SITE_URL.

The reason is that cached pages never see the request headers: before invoking the handler, nitro's cachedEventHandler keeps only the headers listed in cache.varies. Host is dropped, so useRequestURL() degrades to http://localhost and every canonical / og:url is wrong. The same applies during prerender (there is no real request at all).

Measured side by side (same process, same request headers — the only difference is whether the page is cached):

page not cached → canonical = https://your-domain/...  ✅
page cached     → canonical = http://localhost/...     ❌

Self-check: curl -sI https://<domain>/<some ISR page> and read the canonical — it must not contain localhost.

🔴 L2 is an opt-in convergence switch, not the default. By default (L3) the origin follows the domain the visitor actually used — a deliberate default, driven by real constraints: (1) developers often don't know the production domain, (2) production domains change, (3) one product frequently has several domains.

Setting NUXT_PUBLIC_SITE_URL is what turns on convergence — an operational decision. ⚠️ Convergence is harmful for multi-domain products: every other domain's pages canonicalise to this one, effectively declaring themselves duplicates and risking de-indexing. Such products should leave it empty, or override per page via L1.

⚠️ Projects with any prerender: true page must set it (there is no real request at build time) ⇒ they cannot opt out by leaving it empty; use L1 per-page overrides for multi-domain cases.

⚠️ L3 only works because framework injects varies: ['host','x-forwarded-proto'] when translating isr → cache — without it, cached pages never see Host.

The original rationale (kept for reference): cached HTML is shared by every visitor, so baking "the Host of this particular request" into it is wrong in principle; canonical is meant to name the preferred domain, which the site config expresses more accurately. Trade-off: visiting via a secondary domain still emits the primary domain's canonical (exactly what canonical is for), and og:url follows suit — use L1 to override on pages that genuinely must vary by visited domain. Projects that don't set this variable behave exactly as before (L3).

Prerender (prerender / SSG) origin (framework#117): during prerender useRequestURL().origin is the build machine's http://localhost, which would bake og:image / canonical as localhost so social crawlers can't fetch them on share. So useSeo reads the build-time env var NUXT_PUBLIC_SITE_URL during prerender, and throws (fails the build) if it's missing — surfacing the misconfig instead of silently degrading. Projects with any prerendered page must set NUXT_PUBLIC_SITE_URL=https://your-domain in the build environment; ISR / SSR / dev are unaffected.

useRequestURL().origin relies entirely on nginx / cloudflare forwarding Host / X-Forwarded-Host / X-Forwarded-Proto. Drift would cause SSR to emit an internal IP and silently pollute SEO — to catch this, framework compares <link rel="canonical"> against window.location.origin during CSR hydration. On mismatch, dev console.warn / prod console.error outputs a one-shot diagnostic + fix guide that ops must address.

Full diagnostic guide: nginx origin header forwarding.

Why systemStore.info.site_url is not a fallback: it diverges semantically from "the domain the user actually visited" (framework#87), so it has no layer in the origin chain — SystemInfo.site_url is preserved as a backend field for business use (e.g. "Latest Address" jump link), decoupled from SEO.

⚠️ NUXT_PUBLIC_SITE_URL (i.e. runtimeConfig.public.siteUrl) is not in that category: since framework#278 it is L2 of the priority chain above — when set it wins over the request headers and pins the origin. It is not "build-time only".

image fallback chain: explicit input.image → local /og-image.png (since #89, no longer reads backend header.og_image / header.img).

og-image placeholder (1200×630 PNG, prebuild enforced)

Every project must have public/og-image.png (1200×630 PNG, ≤8MB). The scripts/check-og-image.ts script runs in prebuild, failing the build if missing.

Detail pages should use real cover images instead of the generic placeholder:

<SwImage
  :src="data.cover"
  alt="..."
  ratio="16/9"
  loading="eager"
  fetchpriority="high"
  preload                     ← LCP optimization: auto-injects <link rel="preload">
/>

Dev-mode keyword stuffing warnings

Per 2026 industry SEO standards, search engines down-rank pages with overly long title / description (treating them as keyword stuffing). useSeo warns in dev:

FieldRecommendedTriggers warning
title10–60 chars> 70
description50–160 chars> 200
keywords≤10 items, ≤200 charscount > 10 or chars > 200

Production builds have zero overhead via dead code elimination. Dev-only feedback aimed at operators and developers.

Each warning includes the page URL for quick navigation, plus a link to the SEO knowledge-base wiki.

Silence per page (private / internal-tool)

Private pages (e.g. /admin, /internal-tools) often run with shorter title / description on purpose. Pass silenceLengthWarnings: true to opt out of length warnings:

useSeo({
  title: 'Internal tool',
  description: 'Internal use only',
  robots: 'private',
  silenceLengthWarnings: true,
})

Reactive input and episode main entity

useSeo(source: MaybeRefOrGetter<SeoInput>): void accepts a synchronous getter, ref, or computed value and updates title, description, canonical, OG, and schema as data changes. A plain SeoInput object remains a snapshot taken at invocation. Copying data.value into a plain object does not make episode navigation reactive; asynchronous getters are unsupported.

JSON-LD is emitted during SSR by default. Dev permits client updates; production client updates are disabled by default. Set schemaOrg: { reactive: true } explicitly in nuxt.config.ts when episode navigation reuses a page instance. Reactive head input and production schema updates are separate switches; a working dev page does not verify production navigation.

media.url?: string is the stable work URL and defaults to the current canonical; an episode page must explicitly pass the series URL. media.mainEntity?: 'work' | 'episode' defaults to the work; episode selection requires kind: 'series' and an episode, otherwise it falls back to the work. episode.url defaults to the current canonical. Its @id uses the episode URL + #episode, while partOfSeries references the stable work @id. See useSeo API for fields and examples.

These APIs are local additions awaiting release under framework#430. Consumers must verify complete XML failure and recovery against their installed version; passing locally does not establish production deployment or indexing.

JSON-LD structured data

useSeo outputs WebSite + WebPage schema by default. With type: 'article' and an article field, it additionally emits an Article schema.

For custom schema (Movie / Recipe / Product), call useSchemaOrg() directly inside the page.

Import path: import from @unhead/schema-org/vue (not nuxt-schema-org/schema — different signatures):

import { useSchemaOrg, defineMovie } from '@unhead/schema-org/vue'
useSchemaOrg([defineMovie({ name, datePublished, /* ... */ })])

sitemap.xml and robots.txt (dynamically generated)

  • /sitemap.xml is auto-generated by @nuxtjs/sitemap, including static routes plus explicitly registered dynamic routes
    • the sitemap ships without the XSL view by default (xsl: false, framework#445): Chrome is removing XSLT, so a sitemap carrying xml-stylesheet shows a deprecation notice in the browser; crawlers read the raw XML and are unaffected. Re-enable it with a top-level sitemap.xsl if needed; the view's site name then falls back to app.head.title when site.name is unset (#237), and an explicit site.name still wins
  • /robots.txt is auto-generated by @nuxtjs/robots with the framework's default configuration:
User-agent: *
Allow: /
Disallow: /api/

Sitemap: <siteUrl>/sitemap.xml

See the Sitemap guide for mandatory source configuration, errors, and verification.

siteUrl is locked to NUXT_PUBLIC_SITE_URL (framework#85)

sitemap.xml must output a stable primary domain for search engines, locked to process.env.NUXT_PUBLIC_SITE_URL. This is decoupled from useSeo's runtime origin (useRequestURL().origin) — it doesn't follow the visitor's domain.

framework runs a host-format check on this variable at module setup (not a valid URL / non-http(s) protocol / contains path or query → all warn); see nginx origin header forwarding → NUXT_PUBLIC_SITE_URL config.

Default exclude for private paths (framework#85)

framework's sitemap default exclude covers common private paths so sitemap.xml doesn't contradict useSeo({ robots: 'private' | 'noindex' }):

/login, /register, /forgot,
/mine/**, /profile/**, /account/**,
/admin/**, /dashboard/**,
/search, /oauth/**

Append business-specific private paths (e.g. transit pages like /link) via swift.config.json:

{
  "framework": {
    "seo": {
      "sitemap": {
        "exclude": ["/link", "/promo-draft"]
      }
    }
  }
}

See ModuleOptions → seo.sitemap.

i18n strategy & hreflang (framework#85 / #88)

When @nuxtjs/i18n is enabled, use strategy: 'prefix' or 'prefix_except_default' so each language gets a unique URL (e.g. /, /zh-TW/, /en/) — sitemap.xml can then emit <xhtml:link rel="alternate" hreflang="..."> cross-links so search engines correctly identify language variants.

hreflang cross-links are built in with zero configuration for multilingual sites: when i18n is enabled with locales ≥ 2 and a non-no_prefix strategy, the sitemap automatically splits per locale (/sitemap.xml → sitemapindex + one sitemap per language), and every <url> emits <xhtml:link rel="alternate" hreflang> cross-links for all languages (including x-default). Single-locale sites keep a single sitemap unchanged; to override, set top-level sitemap.autoI18n in nuxt.config.ts. hreflang uses absolute URLs, so multilingual sites must configure NUXT_PUBLIC_SITE_URL.

strategy: 'no_prefix' (all languages sharing the same URL) triggers a framework dev console.warn, and the sitemap hreflang integration is skipped automatically — without distinct URLs there is nothing to cross-link.

AI bot policy

The single SEO option exposed in swift.config.json:

{
  "framework": {
    "seo": {
      "aiBotPolicy": "allow"
    }
  }
}
  • 'allow' (default): robots.txt puts no AI-bot-specific rule, maximizing AI Overviews and answer-engine citation visibility
  • 'disallow': robots.txt emits Disallow: / for these user agents (for paid / subscription content sites): GPTBot / CCBot / ClaudeBot / anthropic-ai / PerplexityBot / Bytespider / Google-Extended

disallow blocks more than training crawlers, yet not all AI crawlers (fw#381): PerplexityBot in that list is Perplexity's search retrieval crawler and Google-Extended governs Gemini grounding, so disallow also cuts off those two AI-search traffic sources — while ChatGPT search's OAI-SearchBot / ChatGPT-User and Claude-SearchBot are not in the list and stay allowed under *. Its real semantics are "block training + block Perplexity / Gemini retrieval, keep ChatGPT / Claude retrieval"; accept that before choosing it. A search-only tier ("block training only, keep every retrieval bot") is reserved by name only and not implemented — when a site actually needs it, implement it against the UA lists of that day rather than guessing from the current list.

Google's own position: AI Overviews / AI Mode are governed by Googlebot, unaffected by Google-Extended, and need no extra file or schema; this switch does not change your visibility in Google's own AI search.

SystemHeader field convention

The header field returned by the backend /system/info:

FieldPurpose
site_titleSite title. Does not participate in SEO (no titleTemplate injection, no title fallback). The site name now comes from the applier's app.head.titleTemplate + runtimeConfig.public.siteName in nuxt.config.ts, sourced from swift.config.json's project.title. This field remains available for business use
descriptionSite description
keywordsSite keywords
og_imageDefault OG share image URL (1200×630 PNG/JPG, uploaded via CMS)
imgLegacy backend-compat field (new code always uses og_image); coexists with og_image long-term, removed only after all backends upgrade — no specific version planned
site_urlSite canonical domain (primary source for canonical URL in multi-domain setups)

FAQ

Why not use nuxt-og-image?

Satori-based dynamic text-image generation is heavy at runtime and the output looks poor. The framework recommends operations upload a real cover image via the CMS, and detail pages use real covers like data.cover — far better looking than satori text images.

How do dynamic routes get into the sitemap?

Static routes are collected automatically; dynamic detail pages (/movie/[id] etc.) cannot be auto-discovered by the framework — use the standard sitemap.sources endpoint (defineSitemapEventHandler + a backend ID data source), see Sitemap.

A private page has robots: 'private' — will the sitemap still include it?

No. The framework excludes private paths by default (framework#85, see "default exclude of private paths" above); custom private paths are appended via framework.seo.sitemap.exclude in swift.config.json.