metadata 流水线

静态 metadata / generateMetadata / 约定文件的合并顺序;本站 title.template 与 getTopicMetadata 的真实例子

三条通道:静态导出 → 动态函数 → 约定文件

按「构建期确定 / 请求期计算 / 文件即配置」分工;最终合并成一棵 metadata 树

通道什么时候算出来能不能取数/读 params本站例子
export const metadata构建期(静态页)或渲染期求值一次不能(就是个对象)根 layout、各专题薄壳页
generateMetadata()请求期 async 计算能(params / searchParams / fetch)/router/dynamic-routes/[id]
约定文件构建期收集(icon 可为动态 route)动态 icon/og 文件可运行时生成app/favicon.ico

合并顺序:子覆盖父,title.template 沿层级拼接

metadata 不是「谁赢谁」,而是按路由层级逐段合并

  • 标量字段子覆盖父:子段的 description 覆盖父段的; 子段不写就继承父段,一路兜底到根 layout
  • title.template 是例外:子段给出标题主体(如 「约定文件对照」),离它最近的父级 template 负责拼接,最终产出约定文件对照 | Next 权衡录—— 子段自己也能再定义 template,形成多级模板链
  • openGraph 整体浅合并:子段写了 openGraph.title 就只覆盖 title,其余 og 字段继续继承父段

本站真实例子 ①:根 layout 的静态 metadata

app/layout.tsx —— 全站的根:default 兜底 + template 后缀 + og/twitter 基线

// app/layout.tsx —— 根布局,全站 metadata 的根
// 品牌常量全部来自 config/site.ts(品牌单一数据源)
export const metadata: Metadata = {
    metadataBase: new URL(SITE_URL),   // 部署后自动取 VERCEL_URL
    title: {
        default: HOME_TITLE,           // "Next 权衡录 · 把选型放到同一把尺子上"
        template: `%s | ${SITE_NAME}`, // 子页标题自动套后缀
    },
    description: SITE_DESCRIPTION,
    openGraph: { type: "website", locale: "zh_CN", /* … */ },
    icons: { icon: "/favicon.ico" },
};

本站真实例子 ②:薄壳页读注册表

app/*/page.tsx + lib/topic-meta.ts —— 文案三处同源,杜绝卡片与 <meta> 漂移

// app/metadata/guide/page.tsx —— 本站所有专题页的薄壳范式
import { getTopicMetadata } from "@/lib/topic-meta";
import MetadataGuideTopic from "@/topics/metadata/guide";

// 静态导出:构建期就确定,标题/描述与首页卡片同源(都读注册表)
export const metadata = getTopicMetadata("/metadata/guide");

export default function Page() {
    return <MetadataGuideTopic />;
}

// lib/topic-meta.ts 里 getTopicMetadata 做的事:
//   getTopicByPath(path) → { title, description, keywords, openGraph }
// 于是「首页卡片 / 专题页头 / <meta> 标签」三处文案永远一致

本站真实例子 ③:动态段的 generateMetadata

app/router/dynamic-routes/[id]/page.tsx —— 每个用户详情页一个专属标题

// app/router/dynamic-routes/[id]/page.tsx —— 本站的活例子
export async function generateMetadata({
    params,
}: {
    params: Promise<{ id: string }>;
}): Promise<Metadata> {
    const { id } = await params;
    const user = getUserById(Number(id));
    if (!user) return { title: "用户未找到" };
    return {
        title: `${user.name} - ${user.role}`,
        openGraph: { title: `${user.name} - ${user.role} | ${SITE_NAME}` },
    };
}

// page 保持 Server Component,标题就和页面写在一起。
// 只有 page 不得不 "use client" 时,才把 generateMetadata 挪到同级 layout。

运行效果见动态路由专题:点进任一用户详情,浏览器标签标题随 id 变化。

约定文件:icon / opengraph-image

文件即配置的第三条通道,适合「图」类资产

// 文件即配置:放进 app/ 对应段,Next 自动生成 <link>/<meta>
app/
├── favicon.ico            ← <link rel="icon">(本站正在用)
├── icon.png               ← 同职责,支持多尺寸与动态生成
├── opengraph-image.png    ← og:image 默认图,可按段覆盖
└── metadata/
    └── guide/
        └── opengraph-image.tsx  ← 段级覆盖:本段的分享卡片用这张

// 与 metadata 对象的关系:约定文件与 metadata 字段合并,
// 谁离页面近谁生效(段级 opengraph-image 覆盖根级 openGraph.images)

什么时候别用 generateMetadata

决策要点:动态函数是请求期成本,静态内容别为它买单

  • 静态内容别动态生成:标题构建期就确定的页面用 export const metadata;generateMetadata 意味着请求期多一次函数 执行,静态页用它还会拖住预渲染
  • 慢查询必须进 cache():generateMetadata 与页面组件都要 取同一份数据时,用 React cache() 记忆化(本站 data/blog.ts 的 getPosts 即此模式),否则同一请求会把慢查询跑两遍 —— 一遍为<title>,一遍为页面;详见四层缓存对照台的 Request Memoization 一节
  • 别在里面做副作用:generateMetadata 可能因预渲染、 爬虫、重试被多次调用,埋点/写库这类事放进这里迟早出事
  • sitemap、robots 和 JSON-LD 不走这个函数,见爬虫文件专题