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 的平台时,用于链接预览的标题、描述和图片。

这些机制都不能单独“让页面排名”。它们主要解决的是:

  1. 页面能否被发现;
  2. 抓取后能否正确理解;
  3. 重复 URL 是否能合并;
  4. 搜索结果和社交分享是否展示正确;
  5. 页面内容是否符合搜索引擎的索引条件。

例如,站点地图可以帮助搜索引擎发现 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 通常来自三种来源:

  1. layout.tsxpage.tsx 中导出的静态 metadata
  2. generateMetadata 函数;
  3. 文件约定,例如 app/sitemap.tsapp/robots.tsapp/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

标量字段如 descriptionrobots 中的部分属性,通常由更具体的配置覆盖;数组或嵌套对象则应明确检查最终生成的 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 通常需要满足:

  1. URL 使用正确的公开协议,生产环境通常是 HTTPS;
  2. 指向可访问的 200 页面;
  3. 指向允许索引的页面;
  4. 内容确实是当前页面的同一版本或规范版本;
  5. Sitemap、内部链接、Open Graph 的 URL 尽量保持一致;
  6. URL 规范化规则稳定,例如尾斜杠、大小写和编码策略一致。

反例:

<link rel="canonical" href="https://blog.example.com/articles/a" />

/articles/a 返回 404noindex,或者又把 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 路由可能返回错误。生产实现需要考虑:

  1. CMS 暂时不可用时是否返回 500;
  2. 是否允许使用最近一次成功生成的缓存;
  3. 缓存是否可能包含已下架 URL;
  4. 是否要监控 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/ogImageResponse 生成图片:

// 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 图时要区分:

  1. 当前 HTML 中的 og:image 是否正确;
  2. 该图片 URL 是否返回 200
  3. Content-Type 是否是 image/pngimage/jpeg 等图片类型;
  4. 图片尺寸和编码是否被平台接受;
  5. 平台是否尚未刷新缓存。

不能只在浏览器中打开文章页面并认为 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。

结构化数据验证

结构化数据验证至少包括三层:

  1. JSON 是否可解析;
  2. Schema.org 字段和类型是否符合规范;
  3. 目标搜索引擎的富结果要求是否满足。

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 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。