首页
/ Nuxt SEO 与 Meta 标签实战:从 app.head 到 useSeoMeta 与 Head 组件的完整指南

Nuxt SEO 与 Meta 标签实战:从 app.head 到 useSeoMeta 与 Head 组件的完整指南

2026-09-06 16:19:49作者:何将鹤

Nuxt 的 head 标签管理由 Unhead 驱动,它既提供了开箱即用的默认标签,也暴露了 useHeaduseSeoMeta 等组合式 API 与 <Head> 系列组件,让你能够以配置、组合式函数或模板组件三种方式管理应用的 SEO 元数据。读完本文,你将掌握:如何在 nuxt.config.ts 中静态配置全局 head、如何通过组合式函数管理响应式 head 标签、titleTemplatetemplateParams 的动态标题机制,以及 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=1
  • charset: utf-8

这两个默认值并非简单文档约定,而是在配置解析阶段被强制注入的。从源码看,packages/schema/src/config/app.ts#L89-L95 中的 app.head 解析器会在你未提供对应 meta 时,自动 unshiftmeta 数组头部:

// 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' })
}

也就是说:如果你自定义了 charsetviewport 键(或通过 meta 数组直接提供同名标签),默认值就不会重复注入。大多数站点无需覆盖这些默认值,但你可以用键式快捷方式更新它们:

export default defineNuxtConfig({
  app: {
    head: {
      // 覆盖 Nuxt 默认值
      charset: 'utf-16',
      viewport: 'width=device-width, initial-scale=1, maximum-scale=1',
    },
  },
})

此外,解析器还会把 metalinkstylescriptnoscript 五个数组字段做 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/vueuseHead

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.tsenforce: 'pre' 注册 nuxt:head 插件,执行 nuxtApp.vueApp.use(head),让 nuxt.configapp.head 在渲染器内于服务端生效;
  • 客户端:plugins/unhead.client.ts 调用 createClientHead(unheadOptions) 并经 installClientHead 安装。由于服务端渲染产生的 head 状态需要重新写入客户端,install-client-head.ts 中还专门有一步"重新 push 服务端专属的 app.head,使 titleTemplate 在 hydration 后得以保留"的逻辑。

同文件还导出了 useHeadSafeL41-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/vueseoMeta 实现。

组件方式:在模板中声明 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 中统一实现,有几个值得了解的机制:

  1. 共享上下文<Head> 组件本身不渲染任何内容(return () => ctx.slots.default?.()),它只负责调用 createHeadComponentCtx() 创建并 provide 一个响应式的 head 上下文。其内部的 <Title><Meta> 等通过 inject 拿到同一个 { input, entry, update } 上下文,把各自的 props 写入 input 的对应数组(input.metainput.link……),再调用 entry.patch(input) 更新。由于 inputreactive 对象,标签在组件间共享一个 dedupe 基准,因此包裹在 <Head> 内去重更直观(createHeadComponentCtx)。
  2. 生命周期自动清理:每个标签组件都在 onUnmounted 中把自己占用的 input 槽位置为 nullupdate()(例如 Link 组件 L289-L292)。这意味着当包含 head 标签的页面/布局组件卸载时,对应标签会自动从 head 中移除——路由切换后旧页面的 <Link><Meta> 不会残留。
  3. VNode key 参与去重normalizeProps(props, key) 会把 Vue VNode 的 key(useVNodeStringKey)作为 key 写进标签对象,因此给 <Head>key 能显式区分跨服务端/客户端边界"重复"的标签组。
  4. <Title> 的插槽约束<Title> 只取默认插槽第一个子节点的字符串内容;开发环境下如果插槽里有多于一个子节点、或子节点不是字符串,会触发诊断提示 NUXT_E6002Title 组件 L332-L356)。
  5. 组件注册:这 9 个组件由 nuxt:meta 模块在 modules/head/module.ts#L14-L44 中注册,priority: 10 标记它们为内置组件、不希望被用户覆盖,kebabName 被显式设为与原名相同(因为 kebab-case 形式不是合法的组件名)。
  6. <Meta>http-equiv 的大小写修正Meta 组件会把 httpEquiv prop 映射为 http-equiv 并从对象中删除驼峰形式(L387-L392)。

类型定义

以下是 useHeadapp.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
}

更细粒度的类型(LinkMetaBaseStyleScriptNoscript 等)由 @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

下面示例展示如何通过 useHeadlink 属性或 <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 数据流是:

  1. 配置解析nuxt.config.tsapp.headschema 解析器 补全 charset/viewport 默认值;
  2. 模块注册nuxt:meta 模块(configKey 为 unhead)注册 9 个 head 组件、#unhead/composables 别名,并生成 unhead-options.mjs(含 v5 的 TemplateParamsPlugin)与 unhead.config.mjs(SSR 渲染选项,默认 omitLineBreaks: true,见 app.ts#L210-L215);
  3. 运行时安装:服务端/客户端的 nuxt:head 插件分别建立 head 客户端实例;
  4. SSR 输出:服务端渲染时把 app.head 与运行时 useHead/组件写入合并输出到 HTML;SPA/纯客户端入口(如 vite-server 的 html 渲染)同样会渲染 app.head 配置项,而运行时注册的条目则由客户端插件接管。

掌握这条链路后,你就能解释为什么 app.head 不支持响应式、为什么 titleTemplate 建议放在 app.vue、以及为什么路由组件卸载时 head 标签会自动消失——它们都源于上述各阶段的确定性行为。

登录后查看全文
热门项目推荐
相关项目推荐