SEO
Swift Web 框架将 SEO 作为强制启用、不可关闭的核心能力,整体由
@nuxtjs/sitemap + nuxt-schema-org + @nuxtjs/robots + 框架自带 useSeo() composable 协作完成。
设计哲学:SEO 是 Nuxt 项目存在的核心理由(不需要 SEO 用 Vue3 SPA 即可), 框架本不该给"关闭 SEO"的开关。所有 swift-web 项目自动获得 sitemap.xml + robots.txt + JSON-LD 全套能力。
useSeo():一站式 SEO 元信息输出
每个 app/pages/**/*.vue 必须在 <script setup> 顶层调用 useSeo()(ESLint
swift-web/require-useSeo-in-pages 规则强制,severity warn;CI 可 --max-warnings=0 升级硬阻断;配套 swift-web/seo-tdk-length 对字面量 title/description 做长度静态预检——description 显示宽度 <50 / >200、title >70 当场 warn),框架自动输出全套 SEO 元标签:
| 标签 | 来源 |
|---|---|
<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_name | useSeo input + 自动兜底 |
<meta property="og:image:width"> og:image:height | 1200 / 630(OG 标准) |
<meta name="twitter:card"> twitter:title twitter:description twitter:image | summary_large_image + 与 og 同步 |
<link rel="canonical"> | input.canonicalPath ?? requestURL.pathname |
<script type="application/ld+json"> | WebSite + WebPage(schemaOrg !== false 时) |
<meta name="robots"> | input.robots(可选) |
基础用法
<script setup lang="ts">
useSeo({
title: '页面标题',
description: '页面描述(建议 50-160 字符)',
})
</script>
详情页对接后端 SEO 节点
后端约定详情类 API 返回 seo 节点({ title, description, image }),业务侧用
?? data 兜底链:
<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> 模板(titleTemplate)
交还 Nuxt 原生,framework 不注入
framework 不接管 titleTemplate(framework#89)。<title> 的站点名后缀由 applier 在 nuxt.config.ts 的 app.head.titleTemplate 用 Nuxt 原生机制配置,站点名来自 swift.config.json 的 project.title(单一事实源):
// nuxt.config.ts
import swiftConfig from './swift.config.json'
export default defineNuxtConfig({
app: {
head: {
titleTemplate: `%s | ${swiftConfig.project.title}`,
},
},
})
// 页面
useSeo({ title: '影城会员管理', description: '...' })
// → <title>影城会员管理 | 影城管理系统</title>
自定义分隔符 / 品牌前缀 —— 直接调整 titleTemplate 字符串顺序:
titleTemplate: `${swiftConfig.project.title} - %s` // 品牌前缀
titleTemplate: '%s · 影城管理系统' // 写死品牌
titleTemplate: '%s' // 无品牌后缀
多语言站点名 —— Nuxt 原生 titleTemplate 是构建期静态字符串,不支持 t()。需要按语种翻译站点名时把 titleTemplate 设为 '%s',在页面用 useSeo({ title: \${t('页面标题')} | ${t('站点名')}` })` 传拼好的完整 title。
兜底行为
- 页面没传
title→console.warn(<title>仅剩 titleTemplate 的站点名后缀)。所有页面都应显式传 title。 - useSeo 不再从后端
site_title兜底 title,也不再注入 titleTemplate / 替换%site_title。 - JSON-LD
WebSite.name取runtimeConfig.public.siteName(applier 从swift.config.json的project.title写入),不再读后端site_title。
私有页 noindex(robots prop)
登录页、个人中心、后台等不应被搜索引擎索引的页面,加 robots prop:
<script setup lang="ts">
// 完全私有页(不索引、不追链接)
useSeo({
title: '登录',
description: '登录账号以使用完整功能',
robots: 'private', // → <meta name="robots" content="noindex, nofollow">
})
// 中转 / 搜索结果页(不索引但允许追链接)
useSeo({
title: '页面即将跳转',
description: '安全外链跳转中转页',
robots: 'noindex', // → <meta name="robots" content="noindex, follow">
})
</script>
⚠️ 不要同时在
_robots.txt加Disallow: /login/—— 那会让爬虫访问不到页面,反而看不到 noindex meta,可能"无 snippet 索引"反向损害 SEO。延伸阅读 SEO 最佳实践 · sitemap.xml / robots.txt。
origin / canonical 单一事实源 + CSR 诊断
useSeo 的 origin 解析是 3 层、配置优先:
L1 显式 input.siteUrl ← 单页覆盖(跳过 CSR 诊断)
L2 NUXT_PUBLIC_SITE_URL 配了它即**收敛**到该域名(opt-in,跳过 CSR 诊断)
L3 useRequestURL().origin **默认**:跟随用户实际访问的域名
有 ISR 页或预渲染页的站点,都必须配 NUXT_PUBLIC_SITE_URL。
原因是被缓存的页面拿不到请求头:nitro 的 cachedEventHandler 进 handler 前会把请求头只保留 cache.varies 白名单,Host 被丢掉 ⇒ useRequestURL() 退化成 http://localhost,canonical / og:url 全错。预渲染期同理(那时根本没有真实请求)。
实测对照(同一进程、同样请求头,差异只由「是否被缓存」决定):
未被缓存的页面 → canonical = https://真域名/... ✅
被缓存的页面 → canonical = http://localhost/... ❌
自查:curl -sI https://<域名>/<某 ISR 页> 看 canonical,不得出现 localhost。
🔴 L2 是 opt-in 的收敛开关,不是默认行为。 默认走 L3 跟随访问域名 —— 这是刻意的默认值,理由来自真实约束:①开发常不知道正式生产域名 ②生产域名会换 ③一个产品往往有多个域名。
配了 NUXT_PUBLIC_SITE_URL 才收敛 —— 那是运营决策。⚠️ 收敛对多域名产品有害:其余域名的页面全部 canonical 到这一个,等于自己声明「我是副本」、可能被搜索引擎剔除。这类产品应留空该变量,或用 L1 逐页覆盖。
⚠️ 有 prerender: true 页的项目必须配该变量(构建期没有真实请求)⇒ 无法靠留空退出收敛,多域名场景只能用 L1 逐页覆盖。
⚠️ L3 能正常工作,靠的是 framework 在 isr → cache 翻译时固定注入 varies: ['host','x-forwarded-proto'] —— 不带它,被缓存的页面拿不到 Host。
原先的一层理由(保留供参考):被缓存的 HTML 是全体访客共用的,把「本次请求的 Host」烤进去在原理上就不对;canonical 的语义本就是「首选域名」,取站点配置反而更准。代价:从副域名访问也输出主域名 canonical(这正是 canonical 该做的),og:url 同样跟随 —— 确需随访问域名变化的页面用 L1 覆盖。未配置该变量的项目行为不变(走 L3)。
预渲染(prerender / SSG)origin(framework#117):prerender 期
useRequestURL().origin是构建机的http://localhost,会把og:image/canonical烤成 localhost、分享时社交爬虫抓不到。故useSeo在预渲染期改取构建期环境变量NUXT_PUBLIC_SITE_URL,缺该变量直接构建报错(暴露误配而非静默劣化)。有预渲染页的项目,构建环境必须设NUXT_PUBLIC_SITE_URL=https://你的域名;ISR / SSR / dev 不受影响。
useRequestURL().origin 完全依赖 nginx / cloudflare 透传 Host / X-Forwarded-Host / X-Forwarded-Proto。透传漂移会让 SSR 拿到内网地址污染 SEO —— framework 在 CSR hydration 阶段比对 <link rel="canonical"> 与 window.location.origin,不一致即 dev console.warn / prod console.error 一次性诊断 + 修复指引,运维必须修。
完整诊断指引:nginx origin 透传配置。
为什么不拿
systemStore.info.site_url兜底:它的语义跟「用户实际访问域名」脱节(framework#87),origin 链里没有它这一层——SystemInfo.site_url字段仍保留供业务读取(如「最新地址」跳转),与 SEO 解耦。⚠️
NUXT_PUBLIC_SITE_URL(即runtimeConfig.public.siteUrl)不在此列:它自 framework#278 起就是上面优先级链的 L2,配了即收敛、优先于请求头,不是「仅 build-time 用」。
image 兜底链:显式 input.image → 本地 /og-image.png(#89 起不再读后端 header.og_image / header.img)。
og-image 占位图(1200×630 PNG,prebuild 强制)
每个项目 public/og-image.png 必须存在(1200×630 PNG,≤8MB),由 scripts/check-og-image.ts
在 prebuild 钩子检查,缺失则 build 失败。
业务详情页推荐用真实封面图覆盖,而非通用占位:
<SwImage
:src="data.cover"
alt="..."
ratio="16/9"
loading="eager"
fetchpriority="high"
preload ← LCP 优化:自动注入 <link rel="preload">
/>
dev 环境关键词堆砌警告
业界 2026 SEO 标准下,搜索引擎对过长的 title / description 会反向降权(判定为 keyword stuffing)。useSeo 在 dev 环境 console.warn 提示:
| 字段 | 建议 | 触发警告 |
|---|---|---|
| title | 10–60 字符 | > 70 |
| description | 50–160 字符 | > 200 |
| keywords | ≤10 个、≤200 字符 | 数量 > 10 或字符 > 200 |
生产构建 dead code elimination 后零开销,仅 dev 环境提示运营 / 开发人员。
警告输出带上页面 URL 方便直接跳转排查,同时附一行链接到 SEO 知识库 wiki。
静音单页(私有 / 工具型)
私有页(如 /admin、/internal-tools)的 title / description 通常更短,频繁告警噪音大。
传 silenceLengthWarnings: true 关闭长度告警:
useSeo({
title: '内部工具',
description: '仅内部使用',
robots: 'private',
silenceLengthWarnings: true,
})
响应式输入与单集主实体
useSeo(source: MaybeRefOrGetter<SeoInput>): void 接受同步 getter、ref 或 computed,并随数据更新 title、description、canonical、OG 与 schema。普通 SeoInput 对象仍是调用时的快照;不要把 data.value 复制进普通对象后期待切集更新,也不要传异步 getter。
JSON-LD 默认在 SSR 输出;dev 允许客户端更新,生产客户端默认不更新。需要在同一页面实例内切集同步 JSON-LD 时,在 nuxt.config.ts 显式设置 schemaOrg: { reactive: true }。head 响应式输入与生产 schema 更新是两个开关;dev 正常不能替代生产导航验收。
media.url?: string 是稳定的作品地址,默认当前 canonical;单集页必须显式传整剧地址。media.mainEntity?: 'work' | 'episode' 默认作品;仅 kind: 'series' 且有 episode 时可选单集,否则回退作品。episode.url 默认当前 canonical;单集 @id 使用该集 URL + #episode,partOfSeries 指回稳定作品 @id。 完整字段和示例见 useSeo API。
本节 API 为本地新增,待 framework#430 发布。消费方须按实际安装版本进行完整 XML 失败 / 恢复验收;本地通过不等于生产部署或收录。
JSON-LD 结构化数据
useSeo 默认输出 WebSite + WebPage schema(schemaOrg !== false 时):
{
"@context": "https://schema.org",
"@graph": [
{ "@type": "WebSite", "name": "...", "url": "...", "description": "..." },
{ "@type": "WebPage", "name": "...", "description": "...", "url": "..." }
]
}
详情页传 type: 'article' + article 字段时额外输出 Article schema。
业务可在 page 内额外调用 useSchemaOrg() 输出自定义 schema(如 Movie / Recipe / Product)。
导入路径:必须从
@unhead/schema-org/vue导入(不是nuxt-schema-org/schema,两者签名不同):import { useSchemaOrg, defineMovie } from '@unhead/schema-org/vue' useSchemaOrg([defineMovie({ name, datePublished, /* ... */ })])
sitemap.xml 与 robots.txt(动态生成)
/sitemap.xml由@nuxtjs/sitemap模块自动生成,含静态路由 + 显式注册的动态路由- 静态路由自动收录零配置;动态路由(
[id]等)走sitemap.sources端点接入(defineSitemapEventHandler拉后端 ID,见下方 FAQ) - sitemap 默认不带 XSL 展示页(
xsl: false,framework#445):Chrome 正在移除 XSLT,带xml-stylesheet的 sitemap 在浏览器里会弹弃用提示;爬虫读原始 XML,不受影响。需要时可在顶级sitemap.xsl重新开启,此时展示页站点名默认取app.head.title回填site.name(#237),显式配site.name可覆盖
- 静态路由自动收录零配置;动态路由(
/robots.txt由@nuxtjs/robots模块自动生成,框架默认配置:
User-agent: *
Allow: /
Disallow: /api/
Sitemap: <siteUrl>/sitemap.xml
必需源配置、错误状态和验收见 Sitemap 指南。
siteUrl 由 NUXT_PUBLIC_SITE_URL 锁定(framework#85)
sitemap.xml 必须输出稳定的主域名给搜索引擎,由 process.env.NUXT_PUBLIC_SITE_URL 锁定,跟 useSeo runtime origin(useRequestURL().origin)解耦 —— 跟用户访问域名变化无关。
framework 在 module setup 阶段对此变量做 host 格式校验(不是合法 URL / 协议非 http(s) / 带 path 或 query 都会 warn),见 nginx origin 透传配置 → NUXT_PUBLIC_SITE_URL 配置。
默认 exclude 私有路径(framework#85)
framework 默认 sitemap exclude 通用私有路径,避免 sitemap.xml 跟 useSeo({ robots: 'private' | 'noindex' }) 自相矛盾:
/login, /register, /forgot,
/mine/**, /profile/**, /account/**,
/admin/**, /dashboard/**,
/search, /oauth/**
业务自定义私有路径(如中转页 /link)通过 swift.config.json 追加:
{
"framework": {
"seo": {
"sitemap": {
"exclude": ["/link", "/promo-draft"]
}
}
}
}
详见 ModuleOptions → seo.sitemap。
i18n strategy 与 hreflang(framework#85 / #88)
启用 @nuxtjs/i18n 时建议使用 strategy: 'prefix' 或 'prefix_except_default',让三语 URL 有独立路径(如 /, /zh-TW/, /en/),sitemap.xml 才能输出 <xhtml:link rel="alternate" hreflang="..."> 互链让搜索引擎正确识别多语言版本。
多语言站的 hreflang 互链已内置、零配置:i18n 启用且 locales ≥ 2 且 strategy 非 no_prefix 时,sitemap 自动按 locale 拆分(/sitemap.xml → sitemapindex + 每语言一份),每条 <url> 输出全语言 <xhtml:link rel="alternate" hreflang> 互链(含 x-default)。单语言站保持单份 sitemap 不变;确需覆盖走 nuxt.config.ts 顶级 sitemap.autoI18n。hreflang 是绝对 URL,多语言站需配置 NUXT_PUBLIC_SITE_URL。
strategy: 'no_prefix'(三语共用同一 URL)会被 framework dev console.warn 提示,且 sitemap 的 hreflang 集成自动跳过 —— 无独立 URL 就没有可互链的对象。
AI bot 抓取策略
swift.config.json 唯一暴露的 SEO 选项:
{
"framework": {
"seo": {
"aiBotPolicy": "allow"
}
}
}
'allow'(默认):robots.txt 不对任何 AI bot 单独设限,最大化 AI Overviews / 答案引用曝光'disallow':robots.txt 对下列 UA 整站Disallow: /(适合订阅 / 付费内容站):GPTBot/CCBot/ClaudeBot/anthropic-ai/PerplexityBot/Bytespider/Google-Extended
disallow 拦的不只是训练爬虫,也不是全部 AI 爬虫(fw#381):清单里 PerplexityBot 是 Perplexity 的搜索检索爬虫、Google-Extended 管 Gemini 的 grounding——开 disallow 会把这两条 AI 搜索流量一起挡掉;而 ChatGPT 搜索用的 OAI-SearchBot / ChatGPT-User 与 Claude-SearchBot 不在清单里,仍按 * 放行。所以它的实际语义是「拦训练 + 拦 Perplexity / Gemini 检索,放过 ChatGPT / Claude 检索」,选它之前要接受这一点。「只拦训练、放过所有检索」的 search-only 档只预留了名字,未实现——真有站需要时再按当时各家 UA 清单落地,别照现在的清单猜。
Google 官方口径:AI Overviews / AI Mode 由
Googlebot决定,不受Google-Extended影响,也不需要任何额外文件或 schema;本开关不会影响 Google 自家的 AI 搜索曝光。
SystemHeader 字段约定
后端 /system/info 返回值的 header 字段:
| 字段 | 用途 |
|---|---|
site_title | 站点标题。不参与 SEO(不再注入 titleTemplate、不再当 title 兜底)。站点名改由 applier 在 nuxt.config.ts 的 app.head.titleTemplate + runtimeConfig.public.siteName 提供,源自 swift.config.json 的 project.title。此字段仍供业务读取 |
description | 站点描述(useSeo 描述兜底) |
keywords | 站点关键词(useSeo keywords 兜底) |
og_image | OG 默认分享图 URL(运营 CMS 上传 1200×630 PNG/JPG) |
img | 旧后端兼容字段(新代码一律用 og_image);与 og_image 长期共存兼容,待后端全升级后再清除,不预设具体版本 |
site_url | 站点根域名(含 protocol,多副域名场景的 canonical 主源) |
常见问题
为啥不引入 nuxt-og-image?
满文字 satori 动态生图运行时压力大、出图不美观。框架推荐运营在 CMS 上传真实封面图,
业务详情页用 data.cover 等真实封面,远比 satori 文字图好看。
动态路由怎么进 sitemap?
静态路由自动收录;动态详情页(/movie/[id] 等)framework 自动发现不了,走标准
sitemap.sources 端点(defineSitemapEventHandler + 后端 ID 数据源),见
Sitemap 站点地图。
私有页加了 robots: 'private',sitemap 还会包含吗?
不会。framework 默认已 exclude 私有路径(framework#85,见上文「默认 exclude 私有路径」);
业务自定义私有路径走 swift.config.json 的 framework.seo.sitemap.exclude 追加。