Nuxt SEO 与 Meta 标签实战:从 app.head 到 useSeoMeta 与 Head 组件的完整指南
Nuxt 的 head 标签管理由 Unhead 驱动,它既提供了开箱即用的默认标签,也暴露了 useHead、useSeoMeta 等组合式 API 与 <Head> 系列组件,让你能够以配置、组合式函数或模板组件三种方式管理应用的 SEO 元数据。读完本文,你将掌握:如何在 nuxt.config.ts 中静态配置全局 head、如何通过组合式函数管理响应式 head 标签、titleTemplate 与 templateParams 的动态标题机制,以及 Nuxt 从 schema 解析到 SSR 渲染的完整 head 数据流。
在 nuxt.config 中静态配置 app.head
在 nuxt.config.ts 中提供 app.head 属性,可以为整个应用静态地定制 head。适合放置不会变化的标签:站点默认标题、语言、favicon 等:
export default defineNuxtConfig({
app: {
head: {
title: 'Nuxt', // 默认回退标题
htmlAttrs: {
lang: 'en',
},
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
],
},
},
})
需要特别注意的是:app.head 无法接收响应式数据。需要动态/响应式标签时,应改用 app.vue 中的 useHead()。
默认标签的注入机制
Nuxt 默认会保证站点"开箱即用"地携带两个关键 meta 标签:
viewport:width=device-width, initial-scale=1charset:utf-8
这两个默认值并非简单文档约定,而是在配置解析阶段被强制注入的。从源码看,packages/schema/src/config/app.ts#L89-L95 中的 app.head 解析器会在你未提供对应 meta 时,自动 unshift 到 meta 数组头部:
// provides default charset and viewport if not set
if (!resolved.meta.find(m => m?.charset)?.charset) {
resolved.meta.unshift({ charset: resolved.charset || 'utf-8' })
}
if (!resolved.meta.find(m => m?.name === 'viewport')?.content) {
resolved.meta.unshift({ name: 'viewport', content: resolved.viewport || 'width=device-width, initial-scale=1' })
}
也就是说:如果你自定义了 charset 或 viewport 键(或通过 meta 数组直接提供同名标签),默认值就不会重复注入。大多数站点无需覆盖这些默认值,但你可以用键式快捷方式更新它们:
export default defineNuxtConfig({
app: {
head: {
// 覆盖 Nuxt 默认值
charset: 'utf-16',
viewport: 'width=device-width, initial-scale=1, maximum-scale=1',
},
},
})
此外,解析器还会把 meta、link、style、script、noscript 五个数组字段做 defu 合并与 filter(Boolean) 清洗,确保模块与用户配置的标签能够无冲突合并(见 app.head 解析器)。多模块共享时,模块可以通过 @nuxt/kit 提供的 head 帮助函数把自己的 head 合并进来,实现方式是 defu(head, nuxt.options.app.head)(packages/kit/src/head.ts);最终在 nuxt 包的 核心初始化 中还会对 link 等数组字段做去重。
cdnURL 与静态 head 链接的坑
当你设置了 app.cdnURL 时,public/ 目录下的资源(包括 favicon.ico)会从该 CDN 提供。Nuxt 解析 public 资源时会针对 cdnURL 并回退到 app.baseURL;但 app.head 中的静态链接(如上面 href: '/favicon.ico')是字面路径,不会被解析到 cdnURL。要让 favicon 指向解析后的位置,建议在 app.vue 中用运行时配置构造 href:
<script setup lang="ts">
const { cdnURL, baseURL } = useRuntimeConfig().app
useHead({
link: [
{ rel: 'icon', type: 'image/x-icon', href: `${cdnURL || baseURL}favicon.ico` },
],
})
</script>
useHead:支持响应式输入的组合式 API
useHead 支持响应式输入,让你以编程方式管理 head 标签:
<script setup lang="ts">
useHead({
title: 'My App',
meta: [
{ name: 'description', content: 'My amazing site.' },
],
bodyAttrs: {
class: 'test',
},
script: [{ innerHTML: 'console.log(\'Hello world\')' }],
})
</script>
源码视角:useHead 的注入链路
useHead 的实现在 packages/nuxt/src/head/runtime/composables.ts#L36-L39,它本身只是一层薄封装,真正干活的是 @unhead/vue 的 useHead:
export function useHead (input: UseHeadInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseHeadInput> {
const head = options.head || injectHead(options.nuxt)
return headCore(input, { head, ...options }) as ActiveHeadEntry<UseHeadInput>
}
关键在于 injectHead(同文件 L17-L30)如何拿到 head 客户端实例:SSR 场景下优先取 nuxtApp.ssrContext.head(由服务端渲染器创建),浏览器场景下则通过 Vue 的 inject(headSymbol) 取出客户端插件安装的实例。这条链路由两个内置插件建立:
- 服务端:
plugins/unhead.server.ts以enforce: 'pre'注册nuxt:head插件,执行nuxtApp.vueApp.use(head),让nuxt.config的app.head在渲染器内于服务端生效; - 客户端:
plugins/unhead.client.ts调用createClientHead(unheadOptions)并经installClientHead安装。由于服务端渲染产生的 head 状态需要重新写入客户端,install-client-head.ts中还专门有一步"重新 push 服务端专属的app.head,使titleTemplate在 hydration 后得以保留"的逻辑。
同文件还导出了 useHeadSafe(L41-L44),它是"安全"版本:即使 head 客户端不可用也会安全地创建 entry 并返回,适合在可能缺失上下文的代码路径中使用。文档建议同时了解这两个组合式函数。
useSeoMeta:带完整类型安全的 SEO 元数据
useSeoMeta 让你以对象形式定义站点的 SEO meta 标签,并拥有完整的类型安全:
<script setup lang="ts">
useSeoMeta({
title: 'My Amazing Site',
ogTitle: 'My Amazing Site',
description: 'This is my amazing site, let me tell you all about it.',
ogDescription: 'This is my amazing site, let me tell you all about it.',
ogImage: 'https://example.com/image.png',
twitterCard: 'summary_large_image',
})
</script>
它的价值在于避免拼写错误和常见误用——例如把 property 写成 name(OG 标签要求 property 属性,Twitter Card 标签要求 name 属性,两者不可混用)。useSeoMeta 的输入类型(UseSeoMetaInput)为每个已知标签键提供了精确的类型约束,在 composables.ts#L46-L49 中可以看到它同样走 injectHead 链路,委托给 @unhead/vue 的 seoMeta 实现。
组件方式:在模板中声明 head 标签
虽然官方推荐所有场景优先使用 useHead,但如果你习惯在模板里声明 head 标签,Nuxt 提供了以下组件:<Title>、<Base>、<NoScript>、<Style>、<Meta>、<Link>、<Body>、<Html> 和 <Head>。注意这些组件首字母大写,以确保不产生非法的原生 HTML 标签。
<script setup lang="ts">
const title = ref('Hello World')
</script>
<template>
<div>
<Head>
<Title>{{ title }}</Title>
<Meta
name="description"
:content="title"
/>
<Style>
body { background-color: green; }
</Style>
</Head>
<h1>{{ title }}</h1>
</div>
</template>
<Head> 和 <Body> 可以接受嵌套的 meta 标签(出于美观考虑),但这不影响嵌套标签在最终 HTML 中的渲染位置——渲染位置由标签类型与 tagPosition 决定,而不是组件嵌套层级。建议将标签包裹在 <Head> 或 <Html> 组件内,这样标签的去重(dedupe)行为更直观。
警告:如果需要跨客户端/服务端边界复制标签,请在 <Head> 组件上应用 key 属性。
组件的内部机制
从源码看,这些组件在 packages/nuxt/src/head/runtime/components.ts 中统一实现,有几个值得了解的机制:
- 共享上下文:
<Head>组件本身不渲染任何内容(return () => ctx.slots.default?.()),它只负责调用createHeadComponentCtx()创建并provide一个响应式的 head 上下文。其内部的<Title>、<Meta>等通过inject拿到同一个{ input, entry, update }上下文,把各自的 props 写入input的对应数组(input.meta、input.link……),再调用entry.patch(input)更新。由于input是reactive对象,标签在组件间共享一个 dedupe 基准,因此包裹在<Head>内去重更直观(createHeadComponentCtx)。 - 生命周期自动清理:每个标签组件都在
onUnmounted中把自己占用的input槽位置为null并update()(例如Link组件 L289-L292)。这意味着当包含 head 标签的页面/布局组件卸载时,对应标签会自动从 head 中移除——路由切换后旧页面的<Link>、<Meta>不会残留。 - VNode key 参与去重:
normalizeProps(props, key)会把 Vue VNode 的 key(useVNodeStringKey)作为key写进标签对象,因此给<Head>加key能显式区分跨服务端/客户端边界"重复"的标签组。 <Title>的插槽约束:<Title>只取默认插槽第一个子节点的字符串内容;开发环境下如果插槽里有多于一个子节点、或子节点不是字符串,会触发诊断提示NUXT_E6002(Title 组件 L332-L356)。- 组件注册:这 9 个组件由
nuxt:meta模块在 modules/head/module.ts#L14-L44 中注册,priority: 10标记它们为内置组件、不希望被用户覆盖,kebabName被显式设为与原名相同(因为 kebab-case 形式不是合法的组件名)。 <Meta>对http-equiv的大小写修正:Meta组件会把httpEquivprop 映射为http-equiv并从对象中删除驼峰形式(L387-L392)。
类型定义
以下是 useHead、app.head 和组件共用的非响应式类型(MetaObject):
interface MetaObject {
title?: string
titleTemplate?: string | ((title?: string) => string)
templateParams?: Record<string, string | Record<string, string>>
base?: Base
link?: Link[]
meta?: Meta[]
style?: Style[]
script?: Script[]
noscript?: Noscript[]
htmlAttrs?: HtmlAttributes
bodyAttrs?: BodyAttributes
}
更细粒度的类型(Link、Meta、Base、Style、Script、Noscript 等)由 @unhead/vue 包提供,Nuxt 通过把 @unhead/vue 加入 build.transpile 保证这些类型在工具链中一致可用(见 module.ts#L30)。
功能特性
响应式
所有属性都支持响应式:传入 computed 值、getter 或响应式对象均可。
<script setup lang="ts">
const description = ref('My amazing site.')
useHead({
meta: [
{ name: 'description', content: description },
],
})
</script>
<script setup lang="ts">
const description = ref('My amazing site.')
useSeoMeta({
description,
})
<script setup lang="ts">
const description = ref('My amazing site.')
</script>
<template>
<div>
<Meta
name="description"
:content="description"
/>
</div>
</template>
Title Template
titleTemplate 可为站点标题提供动态模板。例如把站点名追加到每个页面的标题后面。它既可以是字符串(其中 %s 被标题替换),也可以是函数。
如果要使用函数形式(以获得完全控制),不能在 nuxt.config 中设置——建议在 app.vue 中设置,使其应用于站点所有页面:
<script setup lang="ts">
useHead({
titleTemplate: (titleChunk) => {
return titleChunk ? `${titleChunk} - Site Title` : 'Site Title'
},
})
此时,如果你在另一个页面用 useHead 把标题设为 My Page,浏览器标签页就会显示为 My Page - Site Title;也可以传 null 回退为默认的 Site Title。
Template Parameters
templateParams 允许在 titleTemplate 中提供除默认 %s 之外的更多占位符,实现更灵活的标题生成:
<script setup lang="ts">
useHead({
titleTemplate: (titleChunk) => {
return titleChunk ? `${titleChunk} %separator %siteName` : '%siteName'
},
templateParams: {
siteName: 'Site Title',
separator: '-',
},
})
这一功能的背后是 Unhead 的 TemplateParamsPlugin:Nuxt 在生成 unhead-options.mjs 模板时,v5 兼容模式下会显式注册 TemplateParamsPlugin 以保留 %s / %siteName / %separator 这类标题插值能力(module.ts#L121-L139)。
Body 标签位置
可用标签上支持 tagPosition: 'bodyClose' 选项,把标签追加到 <body> 标签末尾:
<script setup lang="ts">
useHead({
script: [
{
src: 'https://third-party-script.com',
// 合法选项:'head' | 'bodyClose' | 'bodyOpen'
tagPosition: 'bodyClose',
},
],
})
组件形式下,<Style>、<Link>、<NoScript> 等都接受 tagPosition prop;旧的布尔 body prop 已被标记为 deprecated(传 body 等价于 tagPosition: 'bodyClose',见 TagPositionProps)。
实战示例
配合 definePageMeta 使用
在 app/pages/ 目录中,你可以用 definePageMeta 配合 useHead 根据当前路由设置元数据。
先设置当前页面标题(注意:title 会在构建时通过宏被静态提取,因此不能动态设置):
<script setup lang="ts">
definePageMeta({
title: 'Some Page',
})
</script>
然后在布局文件中读取路由上设置好的元数据:
<script setup lang="ts">
const route = useRoute()
useHead({
meta: [{ property: 'og:title', content: `App Name - ${route.meta.title}` }],
})
</script>
更多页面元数据用法参见 pages 文档的 page-metadata 部分。
动态标题
titleTemplate 既可以作为带 %s 占位符的字符串,也可以作为函数,为 Nuxt 应用的每个路由动态设置页面标题:
<script setup lang="ts">
useHead({
// 作为字符串,%s 会被标题替换
titleTemplate: '%s - Site Title',
})
<script setup lang="ts">
useHead({
// 或作为函数
titleTemplate: (productCategory) => {
return productCategory
? `${productCategory} - Site Title`
: 'Site Title'
},
})
也可以用 nuxt.config 作为替代方式设置页面标题,但 nuxt.config 不允许标题动态化。因此推荐在 app.vue 中使用 titleTemplate 添加动态标题,使其应用于应用的所有路由。
外部 CSS
下面示例展示如何通过 useHead 的 link 属性或 <Link> 组件启用 Google Fonts:
<script setup lang="ts">
useHead({
link: [
{
rel: 'preconnect',
href: 'https://fonts.googleapis.com',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Roboto&display=swap',
crossorigin: '',
},
],
})
<template>
<div>
<Link
rel="preconnect"
href="https://fonts.googleapis.com"
/>
<Link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
crossorigin=""
/>
</div>
</template>
四种方式如何选择
| 方式 | 适用场景 | 响应式 | 类型安全 |
|---|---|---|---|
app.head(nuxt.config) |
全局不变的静态标签:默认标题、语言、favicon | 否 | MetaObject 类型 |
useHead |
需要响应式或按页面编程式管理任意 head 标签 | 是 | 中等(MetaObject) |
useSeoMeta |
SEO 元数据(OG / Twitter Card 等) | 是 | 强(逐键校验,防 name/property 误用) |
<Head> 系列组件 |
偏好模板声明式写法、标签需随组件卸载自动清理 | 是 | 组件 props |
选型经验:全局常量进 app.head;普通动态标签用 useHead;SEO 相关元数据优先 useSeoMeta 以获得最强的键名约束;模板组件方式适合标签生命周期与页面组件绑定、且希望卸载时自动去重的场景。
head 数据流小结
把源码证据串起来,Nuxt 的 head 数据流是:
- 配置解析:
nuxt.config.ts的app.head经 schema 解析器 补全charset/viewport默认值; - 模块注册:
nuxt:meta模块(configKey 为unhead)注册 9 个 head 组件、#unhead/composables别名,并生成unhead-options.mjs(含 v5 的TemplateParamsPlugin)与unhead.config.mjs(SSR 渲染选项,默认omitLineBreaks: true,见 app.ts#L210-L215); - 运行时安装:服务端/客户端的
nuxt:head插件分别建立 head 客户端实例; - SSR 输出:服务端渲染时把
app.head与运行时useHead/组件写入合并输出到 HTML;SPA/纯客户端入口(如 vite-server 的 html 渲染)同样会渲染app.head配置项,而运行时注册的条目则由客户端插件接管。
掌握这条链路后,你就能解释为什么 app.head 不支持响应式、为什么 titleTemplate 建议放在 app.vue、以及为什么路由组件卸载时 head 标签会自动消失——它们都源于上述各阶段的确定性行为。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00