useSeo()

useSeo() is the entry-point composable of the framework's SEO subsystem. One call per page emits the full og:* / twitter:* / canonical / JSON-LD (WebSite + WebPage, optionally Article) stack, takes over the robots meta, and wires up dev-mode length warnings. SEO is mandatory in Swift Web projects (if you don't need SEO, pick a Vue 3 SPA instead of Nuxt) — every project automatically gets sitemap.xml, robots.txt, and the JSON-LD stack.

The full SEO spec, best practices, and common pitfalls live in /guide/seo. This page only covers the useSeo() composable's API surface.

Usage

// Basic (homepage / static pages)
useSeo({
  title: 'Home',
  description: 'One-line site description (Chinese target 25-80 chars / English 50-160 chars)',
})

// Detail page + backend page-level SEO node
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,
  },
}))

// Tool / private pages (title/description are inherently short — silence length warnings)
useSeo({
  title: 'Login',
  description: 'Sign in to access full features',
  robots: 'private', // equivalent to 'noindex, nofollow'
  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.

// nuxt.config.ts
export default defineNuxtConfig({
  schemaOrg: { reactive: true },
})

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.

import { ref } from 'vue'

const series = ref({ name: 'Example series', path: '/series/300', image: '/images/series.jpg' })
const episode = ref({ number: 3, name: 'Episode 3', description: 'Episode overview', path: '/series/300/episode/81' })
useSeo(() => ({
  title: `${series.value.name} ${episode.value.name}`,
  description: episode.value.description,
  canonicalPath: episode.value.path,
  media: {
    kind: 'series', name: series.value.name, image: series.value.image,
    url: series.value.path, mainEntity: 'episode',
    episode: { episodeNumber: episode.value.number, name: episode.value.name, url: episode.value.path },
  },
}))

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.

SeoInput fields

NameTypeRequiredDefaultDescription
titlestring✓—Page title (recommended 10-60 display width; > 70 triggers keyword-stuffing warning. CJK = 2, ASCII = 1)
descriptionstring✓—Page description (recommended 50-160 display width; > 200 triggers warning; key field for LLM summarization)
imagestring/og-image.pngOG image. Absolute URL or site-relative path; falls back to local /og-image.png (does not read backend header.og_image)
type'website' | 'article' | 'profile''website'OG type. Pass 'article' on detail pages alongside the article field to emit an Article schema
keywordsstring—Comma-separated keywords (≤ 10 items, ≤ 200 chars)
localestring—i18n locale identifier; OG uses underscore form like 'zh_CN' / 'en_US' / 'zh_TW'
siteUrlstringuses useRequestURL().originSite root domain (with protocol). Explicit override — passing this skips framework's CSR consistency check (typical case: multi-domain setups that want canonical to converge on a primary domain)
canonicalPathstringuseRequestURL().pathnameCanonical path (without origin)
schemaOrgbooleantrueWhether to emit JSON-LD. Pass false to disable in rare cases
articleSeoArticleFields—Structured fields when type='article': author / datePublished / dateModified
videoSeoVideoFields—Video pages: emits VideoObject, see "Film & TV structured data" below
mediaSeoMediaFields—Film / series detail pages: emits Movie / TVSeries (+ TVEpisode) + WatchAction
itemListSeoItemListEntry[]—Channel / list pages: emits ItemList
robots'noindex' | 'private' | string—Indexing directive. 'noindex' = 'noindex, follow'; 'private' = 'noindex, nofollow'; raw strings like 'noindex, nofollow, noarchive' are accepted
silenceLengthWarningsbooleanfalseSilence dev-mode length warnings. For tool / private pages (robots: 'noindex' | 'private' makes length warnings meaningless)

Film & TV structured data (video / media / itemList)

JSON-LD for video pages aimed at Google video rich results and AI search (AI Overviews / ChatGPT Search / Perplexity) (framework#381). Each of the three inputs emits its own node(s), joined into the same graph as WebSite / WebPage with @id cross-references; WebPage.mainEntity defaults to the work (or the video when there is no work), and explicitly targets the episode with media.mainEntity: 'episode'. A node with a missing required field is not emitted at all (a partial node gets flagged by Google — better none than broken) and a dev warning names the missing field.

InputEmitsRequired fieldsOptional fields
video: SeoVideoFieldsVideoObjectname / thumbnailUrl / uploadDate (ISO 8601)description (defaults to page description) / duration (seconds, converted to PT1H30M) / embedUrl (the player itself, never the page the video lives on; omitted when not given) / contentUrl / expires / regionsAllowed / views
media: SeoMediaFieldsMovie or TVSeries (by kind) + WatchAction (isAccessibleForFree: true); with kind='series' and episode also a TVEpisodekind / name / imageurl / mainEntity: 'work' | 'episode' / description / datePublished / genre[] / countryOfOrigin / inLanguage (defaults to the current i18n locale) / actor[] / director[] / numberOfEpisodes (series only) / episode: { episodeNumber, name?, url? }
itemList: SeoItemListEntry[]ItemList (ListItem positions start at 1)url per entryname
// Episode page: work + episode + video, cross-referenced
useSeo({
  title: `${series.name} Episode ${ep.no}`,
  description: series.seo.description,
  image: series.seo.img,
  video: { name: `Episode ${ep.no}`, thumbnailUrl: series.seo.img, uploadDate: ep.publishedAt, duration: ep.durationSeconds ?? undefined },
  media: { kind: 'series', name: series.name, image: series.seo.img, genre: series.tags, numberOfEpisodes: series.total, episode: { episodeNumber: ep.no, name: ep.title } },
});

// Channel / list page
useSeo({ title: 'Thrillers', description: '…', itemList: list.map((m) => ({ url: `/movie/${m.id}`, name: m.name })) });

Three hard constraints (all learned the hard way):

  • thumbnailUrl / image must be a plain image Googlebot can fetch and decode — never a .bnc encrypted cover; use the backend seo.img source image (same trap as og:image).
  • If you cannot provide a stable contentUrl (signed m3u8 that expires in hours, provider forbids external caching), give embedUrl instead — but it must be a standalone player URL (an iframe / /embed/:id style page), never the detail page the video lives on. Without either, Google cannot fetch the video and no rich result appears; a VideoObject with only name / thumbnailUrl / uploadDate is of limited value.
  • Omit duration when you do not have it — never invent one; same for views.

Not done on purpose: llms.txt (Google explicitly ignores it and crawlers rarely fetch it); FAQ rich results (do not emit unless the page has real, visible Q&A). Most of GEO is plain SEO fundamentals plus a direct-answer summary and semantic markup that matches the schema — see issue framework#381 §1.4.

origin resolution & image fallback chain

origin resolution: three layers, config first

  1. L1 explicit input.siteUrl — per-page override (e.g. multi-domain setups that want canonical forced to converge on the primary domain). Passing this skips the CSR consistency check below.
  2. L2 NUXT_PUBLIC_SITE_URL (site config) — the main path; used whenever it is set. Also skips the CSR consistency check.
  3. L3 useRequestURL().origin — fallback when no site URL is configured. On SSR, Node parses it from Host / X-Forwarded-Proto; after CSR hydration it equals window.location.origin.

Any site with ISR or prerendered pages must set NUXT_PUBLIC_SITE_URL, otherwise canonical / og:url end up as http://localhost/....

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, so Host is dropped and useRequestURL() degrades to http://localhost.

Why the main path is config rather than request headers: 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. The trade-off is that 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).

systemStore.info.site_url still takes no part in origin resolution (it is a backend field for the "latest address" jump link — different semantics) and remains available for business use.

CSR consistency check

During SSR, useSeo writes useRequestURL().origin into <link rel="canonical"> — this relies entirely on nginx / cloudflare forwarding Host / X-Forwarded-Host / X-Forwarded-Proto correctly. If headers drift, SSR emits an internal IP or the wrong domain. To catch this, framework runs a one-time comparison during CSR hydration:

document.querySelector('link[rel="canonical"]').href.origin   ←  written by SSR
                            vs
window.location.origin                                          ←  what the browser actually sees

On mismatch, the framework writes a diagnostic to the console (dev console.warn / prod console.error) with a fix guide + docs URL. Triggers at most once per session (module-level hasChecked flag prevents spam). Passing input.siteUrl skips the check entirely.

Full ops diagnostic guide: nginx origin header forwarding.

image fallback (no backend read): explicit input.image → /og-image.png (project-local placeholder, enforced via prebuild script in starter). To use a backend ops default image, read it from systemStore in the page and pass it explicitly via input.image.

Single-entry principle

useSeo is the sole output for SEO and no longer implicitly falls back to backend systemStore.header for title / description / keywords / og_image:

  • title / description: must be explicitly passed per page. Missing values only console.warn (non-blocking), no backend fallback.
  • keywords: optional; not output if omitted (validated for count / length only when passed).
  • og:image: see image fallback above — local /og-image.png only, no backend read.
  • <title> site-name suffix: framework no longer injects titleTemplate; it's handed back to Nuxt native — the applier configures app.head.titleTemplate in nuxt.config.ts (e.g. `%s | ${swiftConfig.project.title}`).
  • JSON-LD WebSite.name: reads runtimeConfig.public.siteName (the applier writes it from swift.config.json's project.title, same source as the titleTemplate suffix), no longer reads backend site_title.

Why: the old framework system.client plugin overwrote the page's useSeo title/description with backend header values after hydration, causing <title> to duplicate into SiteName | SiteName and description to be overwritten. This implicit chain was cut so that "every SEO field written to HTML is visible at the page's useSeo call site". To use backend CMS data for SEO, read it from the store in the page and pass it explicitly into useSeo (backend → store → page → useSeo). See framework#89.

displayWidth length threshold

The dev-mode length validator measures display width (visual width) — not JS .length. CJK chars count as 2, ASCII as 1, so the 50-160 threshold is accurate across English, Chinese, and Traditional Chinese:

  • English description: 50-160 chars (unchanged)
  • Chinese / Traditional: 25-80 chars (each counts as 2 → width 50-160)

Warning messages explicitly print (显示宽度 N) so developers see the unit isn't .length. keywords checks count and total string length, which are unaffected by visual width — those still use .length.

displayWidth is exported from useSeo.ts so unit tests can import it directly. Full rationale in /guide/seo.

Recipes

// Recipe 1: detail page — backend seo node first, business fields as fallback (explicit chain)
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: { datePublished: data.value?.created_at }, // US-001
}))
// Recipe 2: list / tag pages — compose a meaningful description even without one (US-002)
useSeo({
  title: `Content tagged ${tag}`,
  description: `${count} curated items under ${tag}, including ${top3.join(', ')}`,
})
// Recipe 3: error / private pages — noindex + silence length warnings
useSeo({ title: 'Page not found', description: 'The content you visited does not exist', robots: 'noindex,nofollow', silenceLengthWarnings: true })

Caveats

  • Required-field validation: title / description must be explicitly passed per page; missing values trigger console.warn (no backend fallback) — but rendering is not blocked (avoids crashing dev when useFetch fails)
  • silenceLengthWarnings does not affect hard warnings: the missing-field warning is never silenced (that's a real risk)
  • OG image dimensions: defaults to og:image:width=1200 / og:image:height=630. Operations should upload OG images at 1200×630 PNG/JPG
  • Article schema only emits when type='article' AND the article field is provided — non-detail pages don't need to pass article