React 基础体系 · 第 69/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
Next.js SEO:Metadata、结构化数据、Canonical、站点地图和 OG 图
先建立一个正确的 SEO 模型
搜索引擎处理一个页面,大致会经历以下阶段:
flowchart LR
A[发现 URL] --> B[抓取 HTML]
B --> C[解析 Metadata]
B --> D[提取链接与结构化数据]
C --> E[建立页面候选信息]
D --> E
E --> F[规范化重复 URL]
F --> G[决定是否索引]
G --> H[搜索结果展示]
这里的几个概念职责不同:
- Metadata:告诉浏览器、搜索引擎和社交平台页面的标题、摘要、语言、预览图等信息。
- 结构化数据:用机器可读的 Schema.org 数据描述页面实体,例如文章、产品、面包屑。
- Canonical:声明多个可访问 URL 中,哪个 URL 是该内容的首选规范地址。
- 站点地图:向搜索引擎提供一组可发现、值得抓取的 URL。
- OG 图:当页面被分享到支持 Open Graph 的平台时,用于链接预览的标题、描述和图片。
这些机制都不能单独“让页面排名”。它们主要解决的是:
- 页面能否被发现;
- 抓取后能否正确理解;
- 重复 URL 是否能合并;
- 搜索结果和社交分享是否展示正确;
- 页面内容是否符合搜索引擎的索引条件。
例如,站点地图可以帮助搜索引擎发现 URL,但不会强制搜索引擎抓取或索引它;Canonical 是一个规范化提示,不是绝对命令;结构化数据可能帮助页面获得富结果,但不能保证一定出现。
本文使用 Next.js App Router 的 Metadata API 和文件约定。示例假设项目采用现代 TypeScript、React 19 兼容的 Next.js 版本。Metadata API 属于服务端能力,因此示例会明确说明 Server Component 与 Client Component 的边界。
Next.js 中 Metadata 的基本机制
Metadata 是什么
Metadata 是页面文档 <head> 中描述页面的元素,例如:
<title>Next.js SEO 入门</title>
<meta name="description" content="学习 Next.js 中的 SEO 配置。" />
<link rel="canonical" href="https://example.com/articles/nextjs-seo" />
<meta property="og:title" content="Next.js SEO 入门" />
在 Next.js App Router 中,Metadata 通常来自三种来源:
layout.tsx或page.tsx中导出的静态metadata;generateMetadata函数;- 文件约定,例如
app/sitemap.ts、app/robots.ts、app/opengraph-image.tsx。
静态 Metadata 适合所有页面共享或不依赖请求数据的内容:
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
default: 'WR BLOG',
template: '%s | WR BLOG',
},
description: '面向工程师的 React 与前端技术文章。',
metadataBase: new URL('https://blog.example.com'),
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
这段配置可能生成类似以下内容:
<title>WR BLOG</title>
<meta name="description" content="面向工程师的 React 与前端技术文章。" />
当子页面设置了标题时,template 会参与拼接:
// app/articles/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '文章列表',
};
结果通常是:
<title>文章列表 | WR BLOG</title>
default 用于没有单独标题的页面,template 用于子路由标题的统一包装。根布局中的 title.template 不会把它自己再次套在自身的 default 标题上。
Metadata 的层级合并
App Router 中,布局和页面可以分别定义 Metadata。路由越深,越具体的配置通常会覆盖越上层的同名字段。
例如:
app/
├── layout.tsx
└── articles/
├── layout.tsx
└── [slug]/
└── page.tsx
请求 /articles/nextjs-seo 时,Next.js 会沿路由树处理根布局、articles 布局和动态页面的 Metadata。
可以把结果理解为:
根布局 Metadata
↓ 合并
articles 布局 Metadata
↓ 合并
[slug] 页面 Metadata
标量字段如 description、robots 中的部分属性,通常由更具体的配置覆盖;数组或嵌套对象则应明确检查最终生成的 HTML,不要假设所有字段都按照业务直觉深度合并。
尤其是 Open Graph 图片:
export const metadata: Metadata = {
openGraph: {
images: ['/default-og.png'],
},
};
如果子页面提供自己的 openGraph.images,不要假设它一定会自动保留父级图片并追加;需要时应在应用层明确构造最终数组。
动态 Metadata:generateMetadata
文章详情页的标题、摘要和 Canonical 通常依赖数据库或 CMS 数据,因此需要 generateMetadata:
// app/articles/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
type Article = {
slug: string;
title: string;
excerpt: string;
publishedAt: string;
coverImage?: string;
};
async function getArticle(slug: string): Promise<Article | null> {
const response = await fetch(
`https://cms.example.com/api/articles/${encodeURIComponent(slug)}`,
{
next: {
revalidate: 300,
},
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`CMS request failed: ${response.status}`);
}
return response.json() as Promise<Article>;
}
type PageProps = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata(
{ params }: PageProps,
): Promise<Metadata> {
const { slug } = await params;
const article = await getArticle(slug);
if (!article) {
return {
title: '文章不存在',
robots: {
index: false,
follow: false,
},
};
}
return {
title: article.title,
description: article.excerpt,
alternates: {
canonical: `/articles/${encodeURIComponent(article.slug)}`,
},
openGraph: {
type: 'article',
title: article.title,
description: article.excerpt,
url: `/articles/${encodeURIComponent(article.slug)}`,
publishedTime: article.publishedAt,
images: article.coverImage
? [{ url: article.coverImage, alt: article.title }]
: ['/og-default.png'],
},
};
}
export default async function ArticlePage({ params }: PageProps) {
const { slug } = await params;
const article = await getArticle(slug);
if (!article) {
notFound();
}
return (
<main>
<h1>{article.title}</h1>
<p>{article.excerpt}</p>
</main>
);
}
这里有三个需要区分的错误路径:
- CMS 返回
404:文章不存在,页面应进入notFound()或生成明确的不可索引响应。 - CMS 返回
500、网络异常:通常应让错误边界处理,而不是把错误文章 Metadata 当成正常页面输出。 - CMS 返回空字段:这是数据质量问题。若标题或摘要为空,应该有明确的降级策略,而不是生成空的
<title>。
上例把查询逻辑写了两次:一次在 generateMetadata,一次在页面组件。Next.js 对相同请求通常可以进行请求记忆或缓存复用,但不要把这个行为当作任意异步函数都自动缓存。应根据 Next.js 版本、请求写法和缓存配置验证实际行为;如果数据读取成本高,可以抽取统一的数据访问层,并明确其缓存策略。
Metadata 与 Server Component 的边界
Metadata API 只能在 Server Component 中使用。以下写法不成立:
'use client';
export const metadata = {
title: '客户端页面',
};
原因不是客户端不能修改 DOM,而是 Next.js 的 Metadata 生成阶段属于服务端路由渲染流程。带有 'use client' 的模块进入 Client Component 图,不能导出页面级 Metadata。
如果标题依赖客户端状态,例如用户点击后改变筛选条件,那么它不属于稳定的页面 SEO Metadata。可以使用 document.title 修改浏览器标题,但这通常发生在初始 HTML 生成之后,不能替代服务端 Metadata。
更合理的边界是:
服务端根据 URL、数据库和路由参数生成:
title
description
canonical
Open Graph
客户端根据交互状态更新:
局部 UI 状态
临时浏览器标题
不需要被搜索引擎索引的交互信息
React Server Components 的关键点是:服务端组件可以在服务端读取数据并输出页面结果,客户端组件只在需要交互的边界处使用。SEO 相关的稳定信息应尽量在服务端确定。
Canonical:如何处理重复 URL
Canonical 的定义
Canonical URL,也称规范 URL,是页面在一组内容相同或高度相似的 URL 中声明的首选地址。
例如,以下 URL 可能都显示同一篇文章:
https://blog.example.com/articles/nextjs-seo
https://blog.example.com/articles/nextjs-seo
https://blog.example.com/articles/nextjs-seo?ref=homepage
https://blog.example.com/article/nextjs-seo
如果业务确认前三个 URL 是同一资源的不同参数形式,可以在它们的 HTML 中声明:
<link
rel="canonical"
href="https://blog.example.com/articles/nextjs-seo"
/>
Canonical 的实际意图是:
多个可抓取 URL
↓
内容相同或近似
↓
选择一个稳定、公开、可访问的规范 URL
↓
在其他 URL 中用 rel="canonical" 指向它
它不是 HTTP 重定向,也不是 noindex:
- 301/308 重定向:用户和爬虫都被送到另一个 URL;
noindex:请求不要把当前 URL 放入索引;- Canonical:当前 URL 仍然可以访问,但声明另一个 URL 是首选版本。
在 Next.js 中生成 Canonical
推荐在根布局设置站点基准地址:
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://blog.example.com'),
};
然后页面使用路径:
export const metadata: Metadata = {
alternates: {
canonical: '/articles/nextjs-seo',
},
};
或者动态页面根据数据库中的规范 slug 生成:
return {
alternates: {
canonical: `/articles/${article.slug}`,
},
};
也可以直接使用绝对 URL:
return {
alternates: {
canonical: `https://blog.example.com/articles/${article.slug}`,
},
};
metadataBase 的作用是让相对 URL 可以解析为绝对 URL。生产环境不要把它留成开发地址,也不要无条件相信请求中的 Host 头:
// 不建议直接这样做
const canonical = `https://${request.headers.get('host')}/articles/${slug}`;
如果反向代理、Host 头或协议头可以被外部影响,就可能生成攻击者域名的 Canonical 或 OG URL。应使用受信任的环境变量:
// lib/site.ts
export const siteUrl = new URL(
process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000',
);
并在生产环境明确配置:
NEXT_PUBLIC_SITE_URL=https://blog.example.com
Canonical 的一致性条件
一个健康的 Canonical 通常需要满足:
- URL 使用正确的公开协议,生产环境通常是 HTTPS;
- 指向可访问的
200页面; - 指向允许索引的页面;
- 内容确实是当前页面的同一版本或规范版本;
- Sitemap、内部链接、Open Graph 的 URL 尽量保持一致;
- URL 规范化规则稳定,例如尾斜杠、大小写和编码策略一致。
反例:
<link rel="canonical" href="https://blog.example.com/articles/a" />
但 /articles/a 返回 404、noindex,或者又把 Canonical 指向 /articles/b。这会构成 Canonical 链甚至循环,搜索引擎只能自行判断,声明的信号可能被忽略。
带筛选参数的列表页还需要业务决策:
/articles?tag=react
如果筛选页有独立内容、独立标题和可索引价值,可以有自己的 Canonical;如果参数只改变排序、追踪或 UI 状态,则通常规范化到无参数列表页。不能把所有带参数 URL 机械地指向首页。
结构化数据:从 HTML 描述实体关系
结构化数据是什么
普通 HTML 主要描述文档结构:
<h1>Next.js SEO</h1>
<time datetime="2025-01-01">2025 年 1 月 1 日</time>
结构化数据进一步告诉机器:
这是一个 Article;
它的标题是某个值;
作者是某个人;
发布时间是某个时间;
它属于某个网站;
它有一个规范 URL。
Web 生态中常见的结构化数据词汇来自 Schema.org。搜索引擎会根据自己的规则决定哪些类型可以用于富结果。
重要区别是:
Schema.org 允许表达某种实体
≠
搜索引擎保证为该实体显示富结果
即使 JSON-LD 格式正确,页面也可能因为内容质量、搜索意图、政策、抓取状态或搜索引擎当前策略而不显示特殊结果。
JSON-LD 的基本形式
Next.js 页面通常使用 JSON-LD:
type ArticleJsonLd = {
'@context': 'https://schema.org';
'@type': 'Article';
headline: string;
description: string;
datePublished: string;
dateModified?: string;
mainEntityOfPage: {
'@type': 'WebPage';
'@id': string;
};
author: {
'@type': 'Person';
name: string;
};
image?: string[];
};
function ArticleJsonLd({ data }: { data: ArticleJsonLd }) {
const json = JSON.stringify(data).replace(/</g, '\\u003c');
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: json }}
/>
);
}
页面中使用:
const url = new URL(`/articles/${article.slug}`, siteUrl);
const jsonLd: ArticleJsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.excerpt,
datePublished: article.publishedAt,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url.href,
},
author: {
'@type': 'Person',
name: 'WR BLOG',
},
image: article.coverImage ? [article.coverImage] : undefined,
};
return (
<main>
<ArticleJsonLd data={jsonLd} />
<h1>{article.title}</h1>
<p>{article.excerpt}</p>
</main>
);
dangerouslySetInnerHTML 在这里用于输出原始 JSON-LD。不能把未经处理的用户输入直接拼接进 <script>。即使 JSON 字符串在语法上有效,内容中出现 </script> 也可能提前结束脚本标签,因此示例将 < 转义为 \u003c。
不要把 JSON-LD 写成 JavaScript 变量:
// 错误:搜索引擎需要的是 script 中的 JSON-LD 文本
<script type="application/ld+json">
{jsonLd}
</script>
应让最终 HTML 中的脚本内容是合法 JSON:
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Article","headline":"..."}
</script>
Article、BreadcrumbList 和 WebSite
文章页通常至少有三类可考虑的结构化数据。
Article
描述文章本身:
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Next.js SEO",
"datePublished": "2025-01-01T08:00:00Z",
"author": {
"@type": "Person",
"name": "作者名"
}
}
headline、作者、发布时间必须与页面可见内容一致。不能在 JSON-LD 中写一个页面正文不存在的标题,也不能为了满足字段要求伪造更新时间。
BreadcrumbList
描述层级导航:
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: '首页',
item: siteUrl.href,
},
{
'@type': 'ListItem',
position: 2,
name: '文章',
item: new URL('/articles', siteUrl).href,
},
{
'@type': 'ListItem',
position: 3,
name: article.title,
item: new URL(`/articles/${article.slug}`, siteUrl).href,
},
],
};
面包屑中的名称和层级应该与页面实际导航一致。最后一项是否包含 item,应按照目标搜索引擎支持的规范验证,不要仅依赖示例代码。
WebSite
站点级信息更适合放在根布局或首页,并且只声明确实存在的站点能力。例如站内搜索结构化数据需要真实可用的搜索 URL 模板,不能为了生成搜索框而填写不存在的接口。
Server Component 与结构化数据
结构化数据不需要客户端交互,因此应优先在 Server Component 输出:
数据库数据
↓
Server Component
├── 可见 HTML
└── JSON-LD
如果把 JSON-LD 放到一个只在客户端挂载的组件中,初始 HTML 可能没有该数据。搜索引擎是否执行 JavaScript、何时执行、是否采用执行后的结果,都不是应用可以完全控制的,因此不能把核心结构化数据依赖在客户端 useEffect 上。
站点地图:把 URL 集合交给爬虫
站点地图解决什么问题
Sitemap 是 XML 格式的 URL 集合:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://blog.example.com/articles/nextjs-seo</loc>
<lastmod>2025-01-01</lastmod>
</url>
</urlset>
它表达的是:
这些 URL 是站点希望搜索引擎发现和考虑抓取的地址
它不表达:
- 页面一定会被抓取;
- 页面一定会被索引;
lastmod一定会被采信;- 页面一定排名更高。
Sitemap 中应放入真正希望被索引的规范 URL,而不是:
- 登录页;
- 404 URL;
- 重定向 URL;
- 带大量追踪参数的 URL;
- 明确
noindex的页面; - 仅用于内部筛选状态的 URL。
使用 app/sitemap.ts
Next.js 支持通过文件约定生成 /sitemap.xml:
// app/sitemap.ts
import type { MetadataRoute } from 'next';
const siteUrl = new URL(
process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000',
);
type ArticleSummary = {
slug: string;
updatedAt: string;
isPublished: boolean;
};
async function getPublishedArticles(): Promise<ArticleSummary[]> {
const response = await fetch('https://cms.example.com/api/articles?status=published', {
next: {
revalidate: 600,
},
});
if (!response.ok) {
throw new Error(`Failed to load sitemap data: ${response.status}`);
}
return response.json() as Promise<ArticleSummary[]>;
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await getPublishedArticles();
const staticEntries: MetadataRoute.Sitemap = [
{
url: new URL('/', siteUrl).href,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: new URL('/articles', siteUrl).href,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.8,
},
];
const articleEntries: MetadataRoute.Sitemap = articles
.filter((article) => article.isPublished)
.map((article) => ({
url: new URL(`/articles/${article.slug}`, siteUrl).href,
lastModified: new Date(article.updatedAt),
changeFrequency: 'monthly',
priority: 0.7,
}));
return [...staticEntries, ...articleEntries];
}
请求:
curl -i http://localhost:3000/sitemap.xml
预期可以看到 XML 响应,内容中包含 /、/articles 和已发布文章 URL。
代码中每个字段的语义不同:
url:必须是绝对 URL;lastModified:内容实际修改时间,不是每次构建时间;changeFrequency:对爬虫的建议,不是强制抓取频率;priority:相对优先级,不是搜索排名分数。
因此,如果每次部署都把所有页面的 lastModified 设置为当前时间,就会产生不真实的更新信号,也可能增加无效抓取。
大型站点的分片问题
Sitemap 协议对单个 Sitemap 文件的 URL 数量和文件大小有上限,常见规范上限是每个文件最多 50,000 个 URL、未压缩不超过 50 MB。大型站点应使用 sitemap index,把 URL 分到多个 Sitemap 文件中。
Next.js 版本对 Sitemap 分片和生成方式可能存在差异。不要把某个版本的实验性或新增 API 直接复制到所有项目;如果超出单文件规模,应先确认当前版本的 Metadata Route 文档,再实现索引文件、分页查询和失败重试。
数据库读取失败时,Sitemap 路由可能返回错误。生产实现需要考虑:
- CMS 暂时不可用时是否返回 500;
- 是否允许使用最近一次成功生成的缓存;
- 缓存是否可能包含已下架 URL;
- 是否要监控 Sitemap 的 URL 数量和响应状态。
Sitemap 的数据源如果不可靠,最坏结果不是“少几个 URL”,而是整份 Sitemap 无法读取,或者长期包含错误地址。
robots.txt 与 Sitemap 的关系
robots.txt 负责抓取规则和 Sitemap 地址,不等价于 Metadata 中的 robots:
// app/robots.ts
import type { MetadataRoute } from 'next';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/private/'],
},
],
sitemap: `${siteUrl}/sitemap.xml`,
};
}
访问:
curl -i http://localhost:3000/robots.txt
这里有两个常见误解:
robots.txt禁止抓取,不一定等于 URL 永远不会出现在搜索结果中;- HTML 中的
noindex通常需要爬虫先访问页面才能看到,因此不能用robots.txt阻止抓取后再期待爬虫读取该页面的noindex。
如果页面绝对不能公开,应该使用认证、权限控制或在 HTTP 层阻止访问,而不是仅依赖 SEO 元数据。
OG 图:社交分享预览的生成链路
Open Graph 是什么
Open Graph 是一组常用于链接分享预览的元标签:
<meta property="og:title" content="Next.js SEO" />
<meta property="og:description" content="..." />
<meta property="og:url" content="https://blog.example.com/articles/nextjs-seo" />
<meta property="og:type" content="article" />
<meta property="og:image" content="https://blog.example.com/og/nextjs-seo.png" />
Twitter/X 等平台可能还读取 twitter:* 标签。平台最终如何抓取、缓存和展示预览,由平台自身决定。
OG 图不是浏览器页面中的 <img>,也不是文章正文的封面。它是分享抓取器根据 Metadata 找到的图片 URL,再由平台下载和缓存的资源。
静态 OG 图
在 App Router 中,可以使用文件约定:
app/
├── opengraph-image.png
├── twitter-image.png
└── layout.tsx
也可以在路由目录下提供页面专属图片:
app/articles/[slug]/
├── opengraph-image.png
├── page.tsx
└── ...
文件约定会让 Next.js 为对应路由生成相关 Metadata。文件优先级和路由范围应结合当前 Next.js 版本确认;简单规则是把站点默认图片放在根级,把页面或路由分组特有图片放在更具体的目录中。
使用 metadata 也可以手动配置:
export const metadata: Metadata = {
openGraph: {
title: 'Next.js SEO',
description: '学习 Next.js Metadata、Canonical 与 Sitemap。',
type: 'article',
images: [
{
url: '/og-default.png',
width: 1200,
height: 630,
alt: 'Next.js SEO',
},
],
},
twitter: {
card: 'summary_large_image',
images: ['/og-default.png'],
},
};
使用相对图片路径时,metadataBase 应该已在根布局设置,否则最终绝对 URL 可能不符合预期。
动态生成 OG 图
Next.js 支持 next/og 的 ImageResponse 生成图片:
// app/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const alt = 'WR BLOG';
export const size = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
export default function OpenGraphImage() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '80px',
background: '#111827',
color: 'white',
}}
>
<div style={{ fontSize: 32 }}>WR BLOG</div>
<div style={{ fontSize: 72, marginTop: 24 }}>
React 与 Next.js 技术文章
</div>
</div>
),
size,
);
}
该文件对应的输出是一个图片响应,而不是 HTML 页面。它适合生成带有统一品牌信息的默认 OG 图。
动态文章图片可以根据路由参数读取文章标题,但需要考虑:
- 图片生成请求是否需要访问 CMS;
- CMS 故障时是否能返回默认图片;
- 字体文件是否能在部署运行时正确加载;
- 生成环境是否支持所需运行时;
- 图片 URL 是否可被社交平台公开访问;
- 平台是否已经缓存旧图。
图片生成函数中的数据错误不应让整个文章页面的 HTML Metadata 失效。实践中可以让页面始终使用一张稳定的默认图片,并把动态图片作为增强能力。
OG Metadata 与图片缓存
社交平台经常缓存 OG 图片和页面 Metadata。即使服务器已经修复:
页面已更新
↓
平台再次抓取页面
↓
平台仍可能展示旧缓存
因此诊断 OG 图时要区分:
- 当前 HTML 中的
og:image是否正确; - 该图片 URL 是否返回
200; Content-Type是否是image/png、image/jpeg等图片类型;- 图片尺寸和编码是否被平台接受;
- 平台是否尚未刷新缓存。
不能只在浏览器中打开文章页面并认为 OG 图正确,因为浏览器页面本身不会自动展示社交分享预览。
一个完整的文章页实现
下面把 Metadata、Canonical、结构化数据和 OG 图放到同一个动态文章页中。
// app/articles/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
const siteUrl = new URL(
process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000',
);
type Article = {
slug: string;
title: string;
excerpt: string;
contentHtml: string;
publishedAt: string;
updatedAt: string;
authorName: string;
coverImage?: string;
};
async function getArticle(slug: string): Promise<Article | null> {
const response = await fetch(
`https://cms.example.com/api/articles/${encodeURIComponent(slug)}`,
{
next: { revalidate: 300 },
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Failed to fetch article: ${response.status}`);
}
return response.json() as Promise<Article>;
}
function safeJsonLd(value: unknown): string {
return JSON.stringify(value).replace(/</g, '\\u003c');
}
type PageProps = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata(
{ params }: PageProps,
): Promise<Metadata> {
const { slug } = await params;
const article = await getArticle(slug);
if (!article) {
return {
title: '文章不存在',
robots: {
index: false,
follow: false,
},
};
}
const canonicalUrl = new URL(
`/articles/${encodeURIComponent(article.slug)}`,
siteUrl,
);
return {
title: article.title,
description: article.excerpt,
alternates: {
canonical: canonicalUrl.href,
},
openGraph: {
type: 'article',
url: canonicalUrl.href,
title: article.title,
description: article.excerpt,
siteName: 'WR BLOG',
locale: 'zh_CN',
publishedTime: article.publishedAt,
modifiedTime: article.updatedAt,
images: [
{
url: article.coverImage
?? new URL('/opengraph-image.png', siteUrl).href,
width: 1200,
height: 630,
alt: article.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: article.title,
description: article.excerpt,
images: [
article.coverImage
?? new URL('/opengraph-image.png', siteUrl).href,
],
},
};
}
export default async function ArticlePage({ params }: PageProps) {
const { slug } = await params;
const article = await getArticle(slug);
if (!article) {
notFound();
}
const canonicalUrl = new URL(
`/articles/${encodeURIComponent(article.slug)}`,
siteUrl,
);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
'@id': `${canonicalUrl.href}#article`,
headline: article.title,
description: article.excerpt,
datePublished: article.publishedAt,
dateModified: article.updatedAt,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': canonicalUrl.href,
},
author: {
'@type': 'Person',
name: article.authorName,
},
publisher: {
'@type': 'Organization',
name: 'WR BLOG',
},
image: article.coverImage
? [article.coverImage]
: [new URL('/opengraph-image.png', siteUrl).href],
};
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: safeJsonLd(jsonLd),
}}
/>
<article>
<h1>{article.title}</h1>
<p>{article.excerpt}</p>
<div
dangerouslySetInnerHTML={{ __html: article.contentHtml }}
/>
</article>
</main>
);
}
这个示例中存在两个需要生产化处理的点。
第一,contentHtml 只有在它已经经过可信 CMS 清洗时才能使用 dangerouslySetInnerHTML。结构化数据的转义不能替代正文 HTML 的 XSS 防护。
第二,文章内容、Metadata、Sitemap 必须共享同一套 URL 规则。否则可能出现:
页面 Canonical:
https://blog.example.com/articles/nextjs-seo
Sitemap:
https://blog.example.com/articles/NextJS-SEO/
OG URL:
https://www.example.com/article/nextjs-seo
这会让搜索引擎和社交平台看到互相矛盾的身份信号。应抽取 URL 构造函数:
// lib/urls.ts
export function articleUrl(slug: string): URL {
const siteUrl = new URL(
process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000',
);
return new URL(`/articles/${encodeURIComponent(slug)}`, siteUrl);
}
然后在页面、JSON-LD 和 Sitemap 中复用它。
noindex、状态码和 Metadata 的关系
SEO Metadata 不是 HTTP 状态码的替代品。
不同情况的正确表达
| 情况 | 推荐处理 |
|---|---|
| URL 不存在 | 返回 404,并使用 notFound() |
| 内容暂时不可用 | 根据业务返回错误边界或合适的 5xx |
| 内容存在但不应进入搜索索引 | robots: { index: false },同时确保页面可被抓取 |
| 内容已永久迁移 | 301/308 重定向 |
| 重复内容的首选版本 | alternates.canonical |
| 不希望爬虫抓取路径 | robots.txt,但它不是安全控制 |
例如:
export const metadata: Metadata = {
robots: {
index: false,
follow: true,
},
};
这通常会生成:
<meta name="robots" content="noindex, follow" />
但如果该页面同时在 robots.txt 中被禁止抓取,爬虫可能看不到这个 Meta robots。两者的作用点不同:
robots.txt:是否允许先抓取
Meta robots:抓取到页面后是否允许索引
对于不存在的文章,返回一个 200 页面并显示“文章不存在”,比返回 404 更容易造成软 404 和无效 URL 扩散。
诊断:先看最终响应,不要只看源码配置
SEO 问题必须检查最终生成结果。metadata 对象写对了,不代表部署后的 HTML 一定正确。
检查页面 HTML
curl -L -s https://blog.example.com/articles/nextjs-seo \
| grep -Ei '<title|description|canonical|og:|application/ld\+json'
应重点检查:
<title>...</title>
<meta name="description" content="..." />
<link rel="canonical" href="https://blog.example.com/articles/nextjs-seo" />
<meta property="og:title" content="..." />
<meta property="og:image" content="https://..." />
<script type="application/ld+json">...</script>
还应检查 HTTP 状态:
curl -I https://blog.example.com/articles/nextjs-seo
curl -I https://blog.example.com/articles/not-found
curl -I https://blog.example.com/sitemap.xml
curl -I https://blog.example.com/robots.txt
常见异常和推导关系如下:
| 现象 | 可能原因 |
|---|---|
<title> 仍是默认值 |
子路由没有生成 Metadata,或读取数据失败后走了错误页面 |
| Canonical 是 localhost | metadataBase 或站点环境变量未配置 |
| Canonical 缺少协议 | 使用了不完整的相对配置或 URL 构造错误 |
| JSON-LD 不存在 | 组件未进入初始服务端输出,或条件渲染没有满足 |
| Sitemap 是空的 | CMS 返回空数组、过滤条件错误或查询缓存异常 |
| Sitemap 包含草稿 | 查询没有按发布状态过滤 |
| OG 图在浏览器正常但分享仍旧 | 社交平台缓存未更新,或抓取器无法访问图片 |
| 页面显示 404 文本但状态码是 200 | 使用了普通组件渲染错误页,没有调用 notFound() |
| Canonical 指向另一个站点 | 直接信任代理传入的 Host 或环境配置错误 |
检查 URL 一致性
对一篇文章,至少比较以下四处:
页面地址
<link rel="canonical">
og:url
JSON-LD 中的 mainEntityOfPage.@id
Sitemap 中的 loc
在没有业务理由时,它们应表示同一个规范资源。带追踪参数的当前请求 URL 不应被直接复制成 Canonical。
结构化数据验证
结构化数据验证至少包括三层:
- JSON 是否可解析;
- Schema.org 字段和类型是否符合规范;
- 目标搜索引擎的富结果要求是否满足。
JSON 能解析,只能证明语法成立。例如:
{
"@type": "Article",
"headline": ""
}
它可能是合法 JSON,却不代表满足文章结构化数据的内容要求。
验证时还要对照可见页面:作者、标题、日期、图片等字段不能只存在于 JSON-LD 中而不出现在真实内容里。结构化数据应描述页面,不应制造页面不存在的事实。
常见错误和反例
只在客户端设置标题
'use client';
import { useEffect } from 'react';
export function Title() {
useEffect(() => {
document.title = '文章标题';
}, []);
return null;
}
这只能修改已经加载完成的浏览器标签页,初始 HTML 没有稳定的页面 Metadata。对于依赖搜索抓取和分享预览的标题,不应把它作为唯一来源。
给所有页面使用同一个 Canonical
export const metadata = {
alternates: {
canonical: 'https://blog.example.com/',
},
};
如果所有文章都指向首页,搜索引擎会收到“这些文章都由首页代表”的信号。除非页面确实是首页的同一内容,否则这是错误的规范化。
把 Sitemap 当作索引清单
把 URL 放进 Sitemap 只表示“建议发现和抓取”。如果页面返回 404、被 noindex、需要登录,或者内容质量不足,仍然可能不被索引。
在 JSON-LD 中堆砌不存在的字段
例如页面没有评分、评论和产品价格,却在 JSON-LD 中添加这些字段。结构化数据必须由真实页面内容支持,否则可能被搜索引擎忽略,严重时会触发结构化数据政策问题。
用 robots.txt 保护后台
Disallow: /admin/
这不是访问控制。任何人都可以请求 /admin/,而且 robots.txt 本身还会暴露路径存在。后台必须使用认证和授权,robots 规则只能作为爬虫行为建议。
每次请求都生成随机 OG 图 URL
如果每次生成的图片 URL 都变化,社交平台缓存会不断产生新对象,调试和缓存清理都会变复杂。应根据稳定的文章版本或内容哈希生成 URL,并在内容确实变化时更新。
生产环境的设计取舍
静态生成还是请求时生成
Metadata、Sitemap 和 OG 图都可能读取外部数据。选择缓存策略时,要同时考虑新鲜度和故障隔离:
静态或长缓存:
成本低、响应稳定,但内容更新有延迟
短时间重新验证:
平衡新鲜度和请求成本,但依赖缓存系统
每次请求实时读取:
数据最新,但 CMS 故障会直接影响页面或元数据
文章标题和摘要通常可以短时间缓存;下架文章的可见性需要更快同步时,应设计缓存失效机制,而不是简单把所有请求改成实时读取。
Preview 图片失败的降级
文章页面不应因为动态 OG 图生成失败而无法访问。可靠的降级链可以是:
专属文章 OG 图
↓ 生成失败
站点默认 OG 图
↓ 默认图也不存在
不输出错误 URL,而不是输出 404 图片地址
这要求 openGraph.images 中的 URL 真实可访问。一个指向不存在资源的 <meta property="og:image"> 比没有该标签更难诊断。
多语言站点
多语言页面除了 Canonical,还需要表达语言之间的替代关系:
export const metadata: Metadata = {
alternates: {
canonical: 'https://blog.example.com/zh/articles/nextjs-seo',
languages: {
'zh-CN': 'https://blog.example.com/zh/articles/nextjs-seo',
'en-US': 'https://blog.example.com/en/articles/nextjs-seo',
},
},
};
这里的 languages 表达的是同一内容的语言版本,不能替代 Canonical。每个语言页面通常应有自己的 Canonical,并在语言映射中相互引用。语言代码、默认版本和缺失翻译页面需要由站点路由规则统一决定。
交付前的验证闭环
一个文章页的 SEO 交付不能只检查 React 组件是否渲染。建议按以下因果链验证:
路由输入
↓
服务端数据读取
↓
页面状态码与正文
↓
Metadata 输出
↓
Canonical / OG / JSON-LD 一致性
↓
Sitemap 是否收录同一规范 URL
↓
robots 是否允许预期的抓取路径
↓
外部爬虫和分享平台是否能访问资源
以 /articles/nextjs-seo 为例,最终条件至少应是:
GET /articles/nextjs-seo → 200
页面有唯一且准确的 <title>
页面有与正文一致的 description
canonical 指向该文章的规范绝对 URL
og:url 与 canonical 一致
og:image 返回可公开访问的图片
JSON-LD 是合法 JSON,且描述真实可见内容
sitemap.xml 包含同一个规范 URL
robots.txt 没有意外禁止该路径
如果文章已下架,则验证另一条路径:
GET /articles/old-slug → 404 或明确重定向
sitemap.xml 不再包含 old-slug
页面不会继续生成可索引的正常文章 Metadata
Metadata、结构化数据、Canonical、Sitemap 和 OG 图分别作用于文档理解、实体理解、URL 规范化、URL 发现和分享预览。只有让它们共享同一个服务端数据源、URL 规范和错误处理策略,Next.js 的 SEO 输出才会从“配置看起来正确”变成“最终响应彼此一致”。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:Next.js Node 与 Edge Runtime:API、限制、依赖和部署选择
- 下一篇:React Compiler:自动记忆化、编译约束、迁移、诊断和性能验证
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论