Next.js SEO 完整实践:metadata、Open Graph 与结构化数据
在服务端导出 generateMetadata 返回 title、description 与 Open Graph,Next.js 会自动写进 head。本文讲清常用字段全表、父子段合并规则、JSON-LD 结构化数据、sitemap 与 robots 的生成方式,以及如何验证标签真的输出到了 HTML。
- Next.js
- 操作
- 进阶
- 更新于 2026-10-31
页面能正常渲染,只说明它「能用」。要让搜索引擎正确理解它、把链接分享出去时带着标题和封面、让搜索结果里出现富摘要,你还得把三样东西放进 HTML 的 <head> 里:页面摘要(title / description)、分享卡片(Open Graph)、结构化数据(JSON-LD)。
在 App Router 里,这三样都由 Next.js 的 Metadata API 负责生成。你不需要手写 <head>,只需要在服务端段导出 metadata 或 generateMetadata,Next.js 会把结果渲染进 HTML。这篇把 Metadata API 的字段、继承规则、动态用法、sitemap 与 robots 全部讲透,并给出可复现的验证步骤——标签写了不等于生效,必须在源代码里看到才算数。
一、两种写法:静态 metadata 与 generateMetadata
Metadata API 提供两种出口,用错地方会直接构建报错。
静态 metadata 对象:内容不依赖请求数据时用,编译期就能确定。
// app/layout.tsx —— 全站默认值
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'), // 相对路径拼绝对 URL 时的基准
title: {
default: '示例博客',
template: '%s · 示例博客', // 给子路由段的 title 加后缀
},
description: '示例博客:Koa、Next.js 与 SQLite 的实战记录',
keywords: ['Koa', 'Next.js', 'SQLite', '全栈'],
applicationName: '示例博客',
authors: [{ name: '示例博客编辑部' }],
creator: '示例博客编辑部',
publisher: '示例博客',
alternates: {
canonical: '/',
},
openGraph: {
title: '示例博客',
description: '示例博客:Koa、Next.js 与 SQLite 的实战记录',
type: 'website',
locale: 'zh_CN',
siteName: '示例博客',
},
twitter: {
card: 'summary_large_image',
title: '示例博客',
description: '示例博客:Koa、Next.js 与 SQLite 的实战记录',
},
robots: {
index: true,
follow: true,
},
};
generateMetadata 函数:内容依赖路由参数、数据库或接口时用。
// app/articles/[id]/page.tsx —— 每篇文章各自的标签
import type { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params; // Next.js 15 起 params 是 Promise
const article = await getArticle(id);
if (!article) {
return { title: '文章不存在' }; // 找不到时也要给一个明确的 title
}
return {
title: article.title, // 会被根布局的 template 拼成「标题 · 示例博客」
description: article.excerpt ?? '',
alternates: { canonical: `/articles/${id}` },
openGraph: {
title: article.title,
description: article.excerpt ?? '',
type: 'article',
publishedTime: article.createdAt,
authors: [article.author],
},
twitter: {
card: 'summary_large_image',
title: article.title,
description: article.excerpt ?? '',
},
};
}
三条硬规则:
- 只能在服务端组件里导出。写在带
'use client'的组件中无效——元数据必须在页面渲染前于服务端解析完。 - 同一个路由段不能同时导出
metadata和generateMetadata,会直接报错。 - Next.js 15 起
params是 Promise,必须await。这一条最常被漏掉,类型系统会报错,生产构建也会失败。
什么时候选哪一个,可以压成一张表:
| 场景 | 用哪个 | 原因 |
|---|---|---|
| 全站默认值、栏目页 | 静态 metadata |
内容编译期确定,不需要等数据 |
| 文章详情、商品详情 | generateMetadata |
标签随路由参数变化 |
| 页面标题要跟着查询参数变 | generateMetadata 放在 page.tsx |
searchParams 只有 page 段能拿到 |
| 想在父级基础上补字段 | generateMetadata 的 parent 参数 |
可以读父级已解析的结果再扩展 |
一个容易被忽略的差异是渲染时机:metadata 是编译期常量,不产生任何额外开销;generateMetadata 属于页面渲染的一部分,如果它引入了动态行为(读请求期数据、取未缓存的数据),标签可能延后到响应流里再补上——这正是第七节要讲的流式 metadata。
二、字段全表:哪些能写,写了生成什么
| 字段 | 作用 | 生成的标签 |
|---|---|---|
title |
页面标题,支持字符串或 { default, template, absolute } |
<title> |
description |
页面摘要 | <meta name="description"> |
keywords |
关键词列表 | <meta name="keywords"> |
applicationName / creator / publisher / authors |
站点与作者信息 | 对应的 <meta> |
referrer |
Referrer 策略 | <meta name="referrer"> |
robots |
索引与跟随策略 | <meta name="robots">、googlebot |
icons |
favicon / apple-touch-icon | <link rel="icon"> |
manifest |
PWA manifest 地址 | <link rel="manifest"> |
alternates |
canonical 与多语言版本 | <link rel="canonical">、hreflang |
openGraph |
社交分享卡片 | og:* 系列 |
twitter |
Twitter / X 卡片 | twitter:* 系列 |
verification |
搜索引擎站点验证 | google-site-verification 等 |
metadataBase |
相对 URL 的基准站点 | 不直接产出标签 |
other |
任意自定义 meta | 自定义 <meta> |
几个容易踩的点:
metadataBase必须配:像openGraph.images: '/og.png'这类需要绝对地址的字段,没有基准地址会在构建时直接报错。配了之后相对路径会被自动拼成https://example.com/og.png。title.template只对子路由段生效:写在layout.tsx里会给app/articles/[id]/page.tsx的 title 加后缀;写在page.tsx里没有任何效果,因为页面段没有子段。用了template就必须同时给default。title.absolute忽略父级 template:想让某个页面完全自定义标题时用它。viewport、themeColor、colorScheme属于 viewport export,不要再放进 metadata(Next.js 14 起这几个字段在 metadata 里已废弃,应改用generateViewport)。robots可以精确控制收录:比如后台页面写robots: { index: false, follow: false },比在 robots.txt 里写 Disallow 更可靠(robots.txt 是约定,meta 是指令)。
2.1 openGraph.images:绝对地址还是相对路径
两种都能用,区别只在于「谁来拼域名」。
// 相对路径:依赖 metadataBase,最终产出绝对地址
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
openGraph: {
images: [{ url: '/og.png', width: 1200, height: 630, alt: '站点封面' }],
},
};
// 绝对地址:直接使用,此时 metadataBase 被忽略
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
openGraph: {
images: [{ url: 'https://cdn.example.com/og.png', width: 1200, height: 630 }],
},
};
实践上的取舍很清晰:站内资源写相对路径,跟着域名、协议、部署环境一起变;CDN 上的图写绝对地址,因为它本来就不在本站域名下。twitter.images 的规则完全相同。
图片字段用对象形式比字符串形式多两个好处:能带上 width / height / alt。og:image:width 与 og:image:height 能让分享平台提前确定版式,og:image:alt 是图片读不出来时的兜底文案。
2.2 metadataBase 的拼接规则与 URL 归一化
metadataBase 不是简单的字符串前缀,它有一套明确的拼接规则。官方给的对照表如下(基准为 https://acme.com):
| 字段里写的值 | 解析后的地址 |
|---|---|
/ |
https://acme.com |
./ |
https://acme.com |
payments |
https://acme.com/payments |
/payments |
https://acme.com/payments |
./payments |
https://acme.com/payments |
../payments |
https://acme.com/payments |
https://beta.acme.com/payments |
https://beta.acme.com/payments |
从这张表能读出五条规则:
- 字段里写了绝对地址,
metadataBase直接被忽略,多域名场景下这正是你要的行为。 - 字段里用相对路径却没配
metadataBase,构建直接报错,不会给你一个半成品站点。 - 重复斜杠会被归一:
metadataBase写成https://acme.com/、字段写成/path,结果是https://acme.com/path,不会拼出//。 - 字段里的「绝对路径」按相对路径处理,从
metadataBase的路径末尾开始接,所以../payments落在/payments而不是往上跳一级。 metadataBase可以带子域名或基路径,比如https://acme.com/blog;此时字段里的/articles/1会拼成https://acme.com/blog/articles/1。这解决了「站点挂在子路径下」的需求——但记得 canonical 也要跟着这个基路径走,否则会指向一个不存在的地址。
一句话概括:只要字段值是相对的,就交给 metadataBase 拼;需要跨域名时,字段值写绝对地址。
2.3 icons 与文件式元数据 API 的优先级
图标有两种来源,选错会互相覆盖。
// 方式一:metadata 字段
export const metadata: Metadata = {
icons: {
icon: '/icon.png',
shortcut: '/shortcut-icon.png',
apple: '/apple-icon.png',
other: {
rel: 'apple-touch-icon-precomposed',
url: '/apple-touch-icon-precomposed.png',
},
},
};
// 方式二:文件式元数据 API,用代码生成图标
import { ImageResponse } from 'next/og';
export const size = { width: 32, height: 32 };
export const contentType = 'image/png';
// 样式单独抽出来,避免在 JSX 里堆一大段对象
const styles = {
root: {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
background: '#0f172a',
color: 'white',
},
};
export default function Icon() {
return new ImageResponse(<div style={styles.root}>示</div>, { ...size });
}
// 方式二(续):生成分享图
import { ImageResponse } from 'next/og';
export const alt = '示例博客封面';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
const styles = {
root: {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 72,
background: '#0f172a',
color: 'white',
},
};
export default function Image() {
return new ImageResponse(<div style={styles.root}>示例博客</div>, { ...size });
}
优先级规则很明确:文件式元数据(app/icon.tsx、app/favicon.ico、app/apple-icon.tsx、app/opengraph-image.tsx、app/twitter-image.tsx)的优先级高于 metadata 对象与 generateMetadata,同一路由段里两者同时存在,文件式覆盖对象式。
位置规则也要记住:favicon.ico 只能放在 app 根目录,icon 与 apple-icon 可以放在任意路由段;用代码生成的图标默认在构建期静态优化并缓存,除非它读了请求期数据或未缓存的数据。
还有一个常见误解:opengraph-image.tsx 只产出 og:image 系列标签,不会顺带产出 twitter:image。Twitter 卡片要么单独放 app/twitter-image.tsx,要么在 metadata.twitter.images 里写。
2.4 robots:精确控制收录
robots 字段写进 <meta> 标签,是给爬虫看的页面级指令。
// 后台页面:明确拒绝收录与索引
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
},
};
<!-- 预期输出 -->
<meta name="robots" content="noindex, nofollow" />
需要更细的粒度时,用 googleBot 单独给 Google 配一套:
export const metadata: Metadata = {
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
noimageindex: false,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
<!-- 预期输出 -->
<meta name="robots" content="index, follow" />
<meta
name="googlebot"
content="index, follow, max-video-preview:-1, max-image-preview:large, max-snippet:-1"
/>
robots.txt 与 meta robots 作用不同,别混用:robots.txt 决定「能不能来抓这个 URL」,meta robots 决定「抓到之后要不要放进索引」。一个后台页面只写 robots.txt 的 Disallow,URL 仍可能以「无摘要」的形式出现在搜索结果里;两个都写才稳妥。另外,返回 404 的页面 Next.js 会自动注入 noindex,所以自定义 404 页面不需要自己补这条。
三、父子段的合并规则:谁覆盖谁
这是最容易搞混的部分。Next.js 从根段往下逐段求值,浅合并:同名 key 由更靠近页面的段覆盖。
// app/layout.tsx
export const metadata: Metadata = {
title: 'Acme',
openGraph: { title: 'Acme', description: 'Acme is...' },
};
// app/about/page.tsx
export const metadata: Metadata = {
title: 'About',
openGraph: { title: 'About' },
};
结果是:
title→About(字符串被整体替换,不拼接;拼接只发生在 template 机制里);openGraph.description→ 消失。因为页面段定义了openGraph,父级整个openGraph对象被替换掉,父级的description不会自动继承下来。
把规则按字段类型摊开看:
| 字段类型 | 父段定义 | 子段定义 | 最终结果 |
|---|---|---|---|
顶层标量(description) |
有 | 无 | 继承父段的值 |
顶层标量(title) |
有 | 有 | 子段覆盖父段 |
嵌套对象(openGraph) |
有 | 有 | 整体替换,父级同名字段消失 |
嵌套对象(openGraph) |
有 | 无 | 整个对象继承下来 |
嵌套对象(robots) |
有 | 有 | 整体替换,父级 follow 之类也会丢 |
最后一行是实战里最容易踩的:父布局写了 robots: { index: true, follow: true, nocache: true },子页面只想改一项,继承来的却是整个对象被换掉,follow 变成了默认值。
想让多个段共享一部分嵌套字段,就把公共部分抽成变量再展开:
// app/shared-metadata.ts
export const openGraphImage = { images: ['https://example.com/og.png'] };
// app/about/page.tsx
import { openGraphImage } from '../shared-metadata';
export const metadata: Metadata = {
title: 'About',
openGraph: { ...openGraphImage, title: 'About' },
};
顺带一提,generateMetadata 还有一个常被忽略的参数:parent 是父级已解析元数据的 Promise,可以在它的基础上扩展而不是替换:
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> },
parent: ResolvingMetadata,
): Promise<Metadata> {
const previousImages = (await parent).openGraph?.images ?? [];
const { id } = await params;
const article = await getArticle(id);
return {
title: article.title,
openGraph: { title: article.title, images: [...previousImages, '/article.png'] },
};
}
用 parent 扩展比手动复制父级字段更稳:父级改了,你这边自动跟着变。
四、动态路由上的三个版本坑
坑一:忘记 await params。 Next.js 15 起 params 与 searchParams 都是 Promise:
// 错误:params.id 是 undefined,且 TS 会直接报错
const article = await getArticle(params.id);
// 正确
const { id } = await params;
用 TypeScript 5.9 实测,错误写法的报错原文是:
error TS2339: Property 'id' does not exist on type 'Promise<{ id: string; }>'.
同一次迁移还会牵出 cookies()、headers()、draftMode() 这几个 API,它们也要改成异步。官方为此提供了 codemod:
npx @next/codemod@latest next-async-request-api . # 把同步读取请求期 API 的写法批量改成 await
坑二:searchParams 只有 page 段有。 layout 段的 generateMetadata 拿不到 searchParams,需要它就把逻辑放进 page.tsx。
// app/articles/layout.tsx —— layout 段只有 params
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
return { title: `文章 ${id}` };
}
// app/search/page.tsx —— 需要 searchParams 就放到 page 段
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}): Promise<Metadata> {
const { q } = await searchParams;
return { title: q ? `搜索:${q}` : '搜索' };
}
两条连带影响要一起记住:searchParams 是请求期数据,用了它这条路由就会在请求时渲染,不是静态产物;而 layout 段拿不到 searchParams,想按查询参数变化标题,只能把这段逻辑下沉到 page 段,或者改用客户端组件。Next.js 还提供 PageProps<'/articles/[id]'> 与 LayoutProps<'/route'> 两个辅助类型来给参数补全类型,省得手写 Promise<...>。
坑三:generateMetadata 里的 fetch 会被自动 memoize。 同一份数据在 generateMetadata、generateStaticParams、Layout、Page、Server Component 之间共享,不会重复请求;没有 fetch 时可以用 React 的 cache() 达到同样效果。
// lib/data.ts —— 没有 fetch 时,用 React cache() 做同一轮渲染内的去重
import { cache } from 'react';
import { getArticle } from './articles';
// 同一轮渲染里调多次只查一次
export const getArticleOnce = cache(async (id: string) => getArticle(id));
要注意 cache() 和「跨请求缓存」是两件事:cache() 的生命周期是一次渲染轮次,它不会让路由变成 SSR,也不会跨请求复用数据。要跨请求缓存数据,走 fetch 的缓存选项或 unstable_cache——那部分属于渲染策略,详见同批的《Next.js App Router 渲染全解》。
五、结构化数据:让搜索引擎读懂这是什么页面
元标签告诉搜索引擎「这页叫什么」,结构化数据告诉它「这页是什么」。对文章详情页,BlogPosting 能带来作者、发布时间等富摘要信息。
// app/articles/[id]/page.tsx
export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const article = await getArticle(id);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: article.title,
description: article.excerpt ?? undefined,
datePublished: article.createdAt,
dateModified: article.updatedAt ?? article.createdAt,
author: { '@type': 'Person', name: article.author },
publisher: { '@type': 'Organization', name: '示例博客' },
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://example.com/articles/${id}`,
},
};
return (
<article>
{/* 结构化数据用 JSON-LD 塞进 script 标签 */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<h1>{article.title}</h1>
{/* 正文 HTML 来自数据库,必须先消毒再用 dangerouslySetInnerHTML */}
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(article.html) }} />
</article>
);
}
注意事项:
dangerouslySetInnerHTML里的内容必须来自可信来源或先经过消毒,否则等于开了一个 XSS 入口;JSON.stringify的结果里如果含</script>也要转义。- 面包屑导航用
BreadcrumbList,站点主页用WebSite+Organization,结构化数据之间可以互相引用。 - 结构化数据必须与页面上肉眼可见的内容一致,否则会被判为「标记与页面内容不符」。
「与可见内容一致」这一条最容易被忽略,也最容易踩:JSON-LD 里写了 datePublished,页面上就得能看到发布时间;写了 author,页面上最好有署名。搜索引擎会把两者比对,在 JSON-LD 里多写一个页面看不见的字段,风险大于收益。
页面里可以同时挂多段 JSON-LD,但它们应当描述同一件事的不同侧面(比如文章本身用 BlogPosting,位置信息用 BreadcrumbList),而不是互相矛盾。写完之后用 Google 的「富媒体搜索结果测试」验证,比自己对着 schema.org 文档逐字段核对更快。
六、sitemap 与 robots:让爬虫有路可走
Next.js 提供文件约定来生成这两份文件,不需要自己写生成脚本。
// app/sitemap.ts —— 生成 /sitemap.xml
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await listPublishedArticles();
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
...posts.map((post) => ({
url: `https://example.com/articles/${post.id}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
})),
];
}
// app/robots.ts —— 生成 /robots.txt
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: '/admin/',
},
sitemap: 'https://example.com/sitemap.xml',
};
}
要点:
sitemap.ts/robots.ts是特殊的 Route Handler,默认被缓存,除非它用了请求时 API;- 单个 sitemap 上限 5 万条 URL,站点更大时用
generateSitemaps拆成多个,并在app/products/sitemap.ts这类嵌套段里分别声明; robots.ts支持按 userAgent 分别配置,也能通过other透传非标准指令;- robots.txt 里不要屏蔽 CSS 和 JS:渲染引擎需要它们来还原页面,挡掉会导致渲染结果与用户看到的不一致。
站点大到需要拆分时,generateSitemaps 的写法是:
// app/products/sitemap.ts —— 拆成多个 sitemap
import type { MetadataRoute } from 'next';
export async function generateSitemaps() {
// 返回几份,就生成几份
return [{ id: 0 }, { id: 1 }];
}
export default async function sitemap(props: {
id: Promise<string>;
}): Promise<MetadataRoute.Sitemap> {
const id = await props.id;
// 单份 sitemap 的上限是 5 万条
const start = Number(id) * 50000;
const end = start + 50000;
const products = await listProducts(start, end);
return products.map((product) => ({
url: `https://example.com/products/${product.id}`,
lastModified: product.updatedAt,
}));
}
一个版本相关的提醒:Next.js 16 把 generateSitemaps 返回的 id 改成了 Promise(id: Promise<string>),代码里必须 await props.id;Next.js 15 上 id 是普通字符串。升级时这一处会直接报类型错误。
按 userAgent 分组配置也很实用:
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: '/admin/',
},
{
userAgent: ['Applebot', 'Bingbot'],
disallow: '/',
},
],
sitemap: 'https://example.com/sitemap.xml',
};
}
七、验证:写了不等于生效
Metadata 是最容易「以为配了其实没生效」的部分,因为开发工具里的元素面板永远显示正常。必须按下面四步验:
第一步:看源代码,不是元素面板。
# 抓服务端返回的原始 HTML,确认 title 与 description 在里面
Invoke-WebRequest -Uri 'https://example.com/articles/42' -UseBasicParsing |
Select-String -Pattern '<title>|meta name="description"|og:title'
第二步:在浏览器里右键「查看网页源代码」,用 Ctrl+F 搜 <title>、og:title、application/ld+json。注意:元素面板显示的是 JS 执行后的 DOM,不能作为证据。
第三步:看分享卡片。 把 URL 粘进 LinkedIn、Facebook、Slack 的卡片调试器,或用微信开发者工具的「分享调试」。卡片标题和图正常,说明 OG 标签被正确读取了——这一步专门抓「meta 在客户端注入」的问题。
第四步:用 Google Search Console 的 URL 检查工具,查看「已呈现的 HTML」,确认 Google 拿到的版本里有完整标签。
7.1 可复制的命令
上面第一步值得展开,因为它是最容易被写错的一段。三个要点:
- Windows PowerShell 5.1 下必须带
-UseBasicParsing,否则会依赖 IE 解析引擎,在非交互环境里直接报错; Select-String是按行匹配的,HTML 常常是一整行,匹配到的是整行而不是标签本身,要看清具体值得用正则提取;- Windows PowerShell 里的
curl是Invoke-WebRequest的别名,想用真正的 curl 要写curl.exe。
# 1) 取原始 HTML
$html = (Invoke-WebRequest -Uri 'http://localhost:3000/articles/metadata-seo' -UseBasicParsing).Content
# 2) 逐条打印关键标签,方便肉眼核对
[regex]::Matches(
$html,
'<title>[^<]*</title>|<meta name="description"[^>]*>|<meta property="og:[^>]*>|<link rel="canonical"[^>]*>'
) | ForEach-Object { $_.Value }
# 3) 只要「有 / 没有」的结论
$html -match '<meta property="og:image"' # True 表示 og:image 在服务端 HTML 里
# Linux / macOS / Git Bash:curl + grep 直接筛标签
curl -s http://localhost:3000/articles/metadata-seo \
| grep -oE '<title>[^<]*</title>|<meta name="description"[^>]*>|<meta property="og:[^>]*>'
# Windows PowerShell:curl 是别名,要用 curl.exe
curl.exe -s http://localhost:3000/articles/metadata-seo | Select-String -Pattern 'og:image'
# sitemap 与 robots 也要确认真的生成了
curl.exe -s http://localhost:3000/sitemap.xml
curl.exe -s http://localhost:3000/robots.txt
7.2 浏览器里三种「看起来像」的东西
验证时最容易自我欺骗的地方,是把下面三样东西混为一谈:
| 你看到的 | 它到底是什么 | 能当证据吗 |
|---|---|---|
| 元素面板 / Inspect | JS 执行后的 DOM,客户端注入的标签也在里面 | 不能 |
| 右键「查看网页源代码」 | 服务端返回的原始 HTML | 能——爬虫拿到的就是它 |
| 分享卡片调试器 | 分享平台爬虫抓取并解析后的结果 | 能——这是平台真正看到的样子 |
三者不一致时,问题就定位到了:源代码里有、元素面板里有、但分享卡片没有,说明平台爬虫拿到的是另一份 HTML(多半是它不执行 JS,所以拿到的是服务端那份),需要回到服务端标签本身找原因;源代码里没有、元素面板里有,说明标签是客户端注入的,等于没配。
7.3 关于流式 metadata
Next.js 15.2 起支持流式 metadata:如果 generateMetadata 需要等待慢查询,Next.js 可以先把页面渲染出来,标签稍后追加到 <body>。这对会执行 JavaScript 的爬虫(例如 Googlebot)是可接受的;对不执行 JavaScript 的爬虫,Next.js 会识别出这类「HTML 受限爬虫」,让 metadata 阻塞渲染、留在 <head> 里。
这条机制解释了一个常见困惑:为什么有些页面在浏览器源码里找不到 <head> 里的标签,却在最终 DOM 里有。如果你需要绝对的兼容性(比如大量使用不执行 JS 的爬虫分享场景),可以在 next.config 里把 htmlLimitedBots 设为 /.*/,强制所有请求都走阻塞式渲染。
// next.config.ts —— 强制阻塞式渲染,标签留在 <head>
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// 默认名单已包含 Google 系列爬虫、Bingbot、Twitterbot、Slackbot
// 设成 /.*/ 表示所有请求都不走流式
htmlLimitedBots: /.*/,
};
export default nextConfig;
代价是响应变慢——流式 metadata 本来就是为了降低首字节时间。默认名单通常够用,先别急着改成 /.*/。
八、排错清单
| 现象 | 原因 | 修法 |
|---|---|---|
构建报 metadataBase 相关错误 |
用了相对路径的 URL 字段却没配基准地址 | 在根布局配 metadataBase: new URL('https://example.com') |
| 页面 title 没有后缀 | title.template 写在了 page 段 |
template 放到 layout 段;页面段要用 title.absolute |
| 父级的 og:description 消失 | 嵌套对象整体替换,不做深合并 | 抽公共字段变量再展开,或用 parent 参数扩展 |
| 报「metadata 与 generateMetadata 不能同时导出」 | 同一段两个都写了 | 二选一 |
| 生产构建报 params 相关类型错误 | Next.js 15+ params 是 Promise | const { id } = await params |
| 分享出去没有卡片 | OG 标签在客户端组件里注入 | 挪到服务端段的 metadata / generateMetadata |
| 收录了但没有富摘要 | 结构化数据缺失或与页面内容不符 | 补 JSON-LD,并确保与可见内容一致 |
| 爬虫看不到正文 | 页面其实是客户端渲染 | 见「服务端渲染与 SEO」那篇 |
| 站点地图不更新 | sitemap.ts 被缓存或没重新构建 | 确认没有用请求时 API;必要时用 generateSitemaps 拆分 |
| 首页标题没套上模板 | 根布局与首页属于同一个路由段,template 只对子路由段生效 | 首页不写 title(用 title.default),或写 title.absolute |
| 全站 canonical 都指向首页 | canonical 写在了根布局里,子段没覆盖就继承了 | canonical 写在各页面,不要写在根布局 |
| og:image 少了域名 | 字段里写相对路径却没配 metadataBase | 配 metadataBase;跨域图片写绝对地址 |
| 地址里出现双斜杠 | 手工拼 URL 时结尾斜杠与 / 撞车 |
统一交给 metadataBase 拼,别手工拼地址 |
图标出现两个 <link rel="icon"> |
同时写了 metadata.icons 和 app/icon.tsx |
二选一,文件式优先级更高 |
| 404 页面被收录 | 只在 robots.txt 里放行,没管 meta 层 | 让框架自动注入的 noindex 生效,必要时再加 Disallow |
| 升级 16 后 sitemap 拆分报类型错 | generateSitemaps 的 id 变成 Promise |
await props.id |
| metadata 写在客户端组件里没反应 | 只能在服务端组件导出 | 把导出移到 page.tsx / layout.tsx |
九、动手:搭一个完整可跑的小工程
前面八节是把规则拆开讲。这一节把它们合回一个能直接跑的工程:全站默认 metadata、动态路由的 generateMetadata、JSON-LD、sitemap、robots、404 页面,全部就位。
工程以 Next.js 15 的 API 为口径(params 为 Promise、支持流式 metadata);16 的 metadata API 同源,主要差异在 sitemap 拆分的 id 变成 Promise,以及 Cache Components 模式下的元数据解析策略。
9.1 目录树
metadata-demo/
├── app/
│ ├── layout.tsx # 全站 metadata + title template + openGraph
│ ├── page.tsx # 首页
│ ├── articles/
│ │ └── [id]/
│ │ ├── page.tsx # 详情页:generateMetadata + JSON-LD
│ │ └── not-found.tsx # 404 页面
│ ├── robots.ts # 生成 /robots.txt
│ └── sitemap.ts # 生成 /sitemap.xml
├── lib/
│ ├── articles.ts # 内存数据源
│ └── html.ts # 最小转义函数
├── public/
│ ├── og.png # 分享封面(静态文件)
│ └── articles/
│ └── [id]/cover.png # 每篇文章的分享图(按 id 存放)
├── next.config.ts
├── package.json
└── tsconfig.json
{
"name": "metadata-demo",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.6.0"
}
}
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
9.2 数据层:一个够跑起来的假数据源
不接数据库,用内存数组把工程跑通;换成真实数据源时,页面代码一行都不用改。
// lib/articles.ts —— 内存里的假数据源
export interface Article {
id: string;
title: string;
excerpt: string;
author: string;
createdAt: string;
updatedAt: string;
html: string;
}
const articles: Article[] = [
{
id: 'metadata-seo',
title: 'Next.js SEO 完整实践',
excerpt: 'metadata、Open Graph 与结构化数据的落地方式。',
author: '示例博客编辑部',
createdAt: '2026-10-20T08:00:00.000Z',
updatedAt: '2026-10-31T08:00:00.000Z',
html: '<p>正文内容。</p>',
},
{
id: 'ssr-ssg-isr',
title: 'Next.js App Router 渲染全解',
excerpt: 'SSG、ISR、SSR 怎么选、怎么配。',
author: '示例博客编辑部',
createdAt: '2026-10-25T08:00:00.000Z',
updatedAt: '2026-10-30T08:00:00.000Z',
html: '<p>正文内容。</p>',
},
];
export async function listArticles(): Promise<Article[]> {
return articles;
}
export async function getArticle(id: string): Promise<Article | null> {
return articles.find((item) => item.id === id) ?? null;
}
// lib/html.ts —— 示例用的最小转义函数;生产环境请用成熟的消毒库
export function escapeHtml(input: string): string {
return input
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
9.3 app/layout.tsx:全站默认值
// app/layout.tsx —— 全站 metadata、title template 与 openGraph
import type { Metadata, Viewport } from 'next';
import Link from 'next/link';
// 全站默认值都写在这里,子路由段只写自己不同的部分
export const metadata: Metadata = {
// 相对路径拼绝对地址的基准;没配它,含相对 URL 的字段会在构建时报错
metadataBase: new URL('https://example.com'),
title: {
// 没有单独定义 title 的路由段,用这个兜底
default: '示例博客',
// 只对子路由段生效,把文章标题拼成「文章标题 · 示例博客」
template: '%s · 示例博客',
},
description: '示例博客:Koa、Next.js 与 SQLite 的实战记录',
applicationName: '示例博客',
authors: [{ name: '示例博客编辑部' }],
openGraph: {
type: 'website',
locale: 'zh_CN',
siteName: '示例博客',
title: '示例博客',
description: '示例博客:Koa、Next.js 与 SQLite 的实战记录',
// 相对路径,由 metadataBase 拼成 https://example.com/og.png
images: [{ url: '/og.png', width: 1200, height: 630, alt: '站点封面' }],
},
twitter: {
card: 'summary_large_image',
title: '示例博客',
description: '示例博客:Koa、Next.js 与 SQLite 的实战记录',
},
robots: {
index: true,
follow: true,
},
};
// viewport 是独立导出,不要放回 metadata
export const viewport: Viewport = {
themeColor: '#0f172a',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<nav>
<Link href="/">首页</Link>
<Link href="/articles/metadata-seo">示例文章</Link>
</nav>
<main>{children}</main>
</body>
</html>
);
}
注意根布局里没有写 alternates.canonical:canonical 必须写在各页面自己身上,写在根布局会被所有子段继承,变成全站都指向首页。
9.4 app/page.tsx:首页
// app/page.tsx —— 首页
import type { Metadata } from 'next';
import Link from 'next/link';
import { listArticles } from '@/lib/articles';
// 首页不定义 title:会落到根布局的 title.default,即「示例博客」
// 根布局的 template 不作用于同一路由段的 page
export const metadata: Metadata = {
description: 'Koa、Next.js 与 SQLite 的实战记录',
alternates: {
canonical: '/',
},
};
export default async function HomePage() {
const articles = await listArticles();
return (
<section>
<h1>示例博客</h1>
<ul>
{articles.map((article) => (
<li key={article.id}>
<Link href={`/articles/${article.id}`}>{article.title}</Link>
<p>{article.excerpt}</p>
</li>
))}
</ul>
</section>
);
}
9.5 app/articles/[id]/page.tsx:详情页
// app/articles/[id]/page.tsx —— 详情页:generateMetadata + JSON-LD
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getArticle } from '@/lib/articles';
import { escapeHtml } from '@/lib/html';
type Props = {
params: Promise<{ id: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
// Next.js 15 起 params 是 Promise,必须 await
const { id } = await params;
const article = await getArticle(id);
// generateMetadata 里也可以直接 notFound(),页面会返回 404
if (!article) notFound();
return {
// 子路由段的 title 会套上父级的 template:文章标题 · 示例博客
title: article.title,
description: article.excerpt,
alternates: {
canonical: `/articles/${id}`,
},
openGraph: {
type: 'article',
title: article.title,
description: article.excerpt,
url: `/articles/${id}`,
publishedTime: article.createdAt,
authors: [article.author],
// 相对路径由 metadataBase 拼成绝对地址
images: [{ url: `/articles/${id}/cover.png`, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: article.title,
description: article.excerpt,
},
};
}
export default async function ArticlePage({ params }: Props) {
const { id } = await params;
const article = await getArticle(id);
if (!article) notFound();
// 结构化数据:内容必须与页面上看得见的文字一致
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: article.title,
description: article.excerpt,
datePublished: article.createdAt,
dateModified: article.updatedAt,
author: { '@type': 'Person', name: article.author },
publisher: { '@type': 'Organization', name: '示例博客' },
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://example.com/articles/${id}`,
},
};
return (
<article>
{/* JSON-LD 用 script 标签塞进页面 */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<h1>{article.title}</h1>
<p>{article.excerpt}</p>
{/* 正文来自数据源,渲染前先转义 */}
<div dangerouslySetInnerHTML={{ __html: escapeHtml(article.html) }} />
<footer>
<time dateTime={article.createdAt}>{article.createdAt.slice(0, 10)}</time>
</footer>
</article>
);
}
9.6 app/sitemap.ts 与 app/robots.ts
// app/sitemap.ts —— 生成 /sitemap.xml
import type { MetadataRoute } from 'next';
import { listArticles } from '@/lib/articles';
// 这是特殊的 Route Handler,默认被缓存
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await listArticles();
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
...articles.map((article) => ({
url: `https://example.com/articles/${article.id}`,
lastModified: new Date(article.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.8,
})),
];
}
// app/robots.ts —— 生成 /robots.txt
import type { MetadataRoute } from 'next';
// 同样默认被缓存
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
// 后台路径不开放抓取
disallow: '/admin/',
},
sitemap: 'https://example.com/sitemap.xml',
};
}
9.7 app/articles/[id]/not-found.tsx
// app/articles/[id]/not-found.tsx —— 该路由段内 notFound() 的落地页
import Link from 'next/link';
export default function ArticleNotFound() {
return (
<section>
<h1>文章不存在</h1>
<p>这个地址没有对应的文章,可能已经被删除或改过标题。</p>
<Link href="/">回到首页</Link>
</section>
);
}
返回 404 的页面,Next.js 会自动注入 noindex,所以这个页面不需要自己补 robots 字段。
9.8 跑起来并验证
npm install
npm run dev # 开发模式:改代码即生效
npm run build # 生产构建
npm run start # 以生产模式启动,验证流式 metadata 时用这个
# 验证详情页的标签真的在服务端 HTML 里
$html = (Invoke-WebRequest -Uri 'http://localhost:3000/articles/metadata-seo' -UseBasicParsing).Content
[regex]::Matches($html, '<title>[^<]*</title>|<meta name="description"[^>]*>|<meta property="og:[^>]*>|<link rel="canonical"[^>]*>') |
ForEach-Object { $_.Value }
<!-- 预期输出(<title> 的后缀来自根布局的 template)-->
<title>Next.js SEO 完整实践 · 示例博客</title>
<meta name="description" content="metadata、Open Graph 与结构化数据的落地方式。"/>
<meta property="og:title" content="Next.js SEO 完整实践"/>
<meta property="og:url" content="https://example.com/articles/metadata-seo"/>
<meta property="og:image" content="https://example.com/articles/metadata-seo/cover.png"/>
<link rel="canonical" href="https://example.com/articles/metadata-seo"/>
上面是按官方文档规则推导出的预期输出,不是某次运行的抓取结果;真实值以你在自己环境里抓到为准。抓取命令见 7.1。
十、页面类型与字段对照:上线前照着这张表核对
不同页面职责不同,该配的字段也不一样。按页面类型对着填,比按字段列表逐个想更省事:
| 页面类型 | title 怎么写 | description | openGraph | 结构化数据 | robots |
|---|---|---|---|---|---|
| 首页 | 继承 title.default,或用 title.absolute 写全 |
一句话说明站点是做什么的 | type: 'website' + siteName + 分享图 |
WebSite + Organization |
index, follow |
| 列表 / 栏目页 | 继承 template | 栏目里有什么内容 | type: 'website' |
一般不需要 | index, follow |
| 文章详情页 | generateMetadata 返回文章标题(自动套 template) |
文章摘要 | type: 'article' + publishedTime + authors + 封面 |
BlogPosting(可加 BreadcrumbList) |
index, follow |
| 落地页 / 营销页 | title.absolute 写全 |
说明清楚页面提供什么 | type: 'website' + 大图 |
无 | index, follow |
| 后台 / 搜索结果页 | title.absolute |
无意义 | 不写 | 无 | index: false, follow: false |
| 404 页 | 不用管 | 不用管 | 不用管 | 无 | 由框架自动加 noindex |
上线前再逐条过一遍这六项,比反复看文档快:
- 每个页面的原始 HTML(不是元素面板)里能搜到
<title>与description; canonical指向自己,不是首页,也不是别的页面;og:title/og:description/og:image齐全,且og:image是可公开访问的绝对地址;- JSON-LD 与页面上看得见的内容一致,用富媒体搜索结果测试验证过;
sitemap.xml收录了全部已发布页面,robots.txt里的 sitemap 地址写对;- 后台类页面的 meta robots 是
noindex, nofollow,robots.txt里也加了 Disallow。
最后提醒一句边界:这些配置解决的是「让爬虫正确理解页面」,不解决收录,也不承诺排名。收录取决于内容质量与站点权重,排名取决于更广泛的竞争环境。把标签配对、源码里验到、该有的页面都有,剩下的交给内容本身。
参考资料
- Next.js generateMetadata 官方文档 —— 字段全表、继承与合并规则、流式 metadata
- Next.js Metadata 文件约定 —— 文件式元数据(icon、opengraph-image 等)及其优先级
- Next.js favicon / icon / apple-icon 约定 —— 图标文件的位置、类型与代码生成
- Next.js opengraph-image / twitter-image 约定 —— 分享图文件与生成的标签
- Next.js generateViewport 官方文档 —— viewport / themeColor / colorScheme 的正确出口
- Next.js htmlLimitedBots 配置 —— 流式 metadata 的阻塞名单与默认清单
- Next.js sitemap.ts 文档 —— sitemap 生成与 generateSitemaps 拆分
- Next.js generateSitemaps 文档 —— 多份 sitemap 与 id 参数的版本差异
- Next.js robots.ts 文档 —— robots 规则与按 userAgent 配置
- Next.js not-found 文件约定 —— 404 页面的渲染方式、状态码与自动 noindex
- Next.js codemods —— 异步请求期 API 迁移与 metadata 到 viewport 的迁移
- schema.org BlogPosting —— 结构化数据字段定义
- Google 搜索中心:结构化数据入门 —— 结构化数据的官方要求与验证方式
- MDN:Open Graph 元数据 —— og:* 属性的标准用法
- 服务端渲染与 SEO:为什么源码里必须有正文 —— 渲染方式与收录的底层关系