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 ?? '',
    },
  };
}

三条硬规则:

  1. 只能在服务端组件里导出。写在带 'use client' 的组件中无效——元数据必须在页面渲染前于服务端解析完。
  2. 同一个路由段不能同时导出 metadata 和 generateMetadata,会直接报错。
  3. 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

从这张表能读出五条规则:

  1. 字段里写了绝对地址,metadataBase 直接被忽略,多域名场景下这正是你要的行为。
  2. 字段里用相对路径却没配 metadataBase,构建直接报错,不会给你一个半成品站点。
  3. 重复斜杠会被归一:metadataBase 写成 https://acme.com/、字段写成 /path,结果是 https://acme.com/path,不会拼出 //。
  4. 字段里的「绝对路径」按相对路径处理,从 metadataBase 的路径末尾开始接,所以 ../payments 落在 /payments 而不是往上跳一级。
  5. 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 可复制的命令

上面第一步值得展开,因为它是最容易被写错的一段。三个要点:

  1. Windows PowerShell 5.1 下必须带 -UseBasicParsing,否则会依赖 IE 解析引擎,在非交互环境里直接报错;
  2. Select-String 是按行匹配的,HTML 常常是一整行,匹配到的是整行而不是标签本身,要看清具体值得用正则提取;
  3. 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

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

上线前再逐条过一遍这六项,比反复看文档快:

  1. 每个页面的原始 HTML(不是元素面板)里能搜到 <title> 与 description;
  2. canonical 指向自己,不是首页,也不是别的页面;
  3. og:title / og:description / og:image 齐全,且 og:image 是可公开访问的绝对地址;
  4. JSON-LD 与页面上看得见的内容一致,用富媒体搜索结果测试验证过;
  5. sitemap.xml 收录了全部已发布页面,robots.txt 里的 sitemap 地址写对;
  6. 后台类页面的 meta robots 是 noindex, nofollow,robots.txt 里也加了 Disallow。

最后提醒一句边界:这些配置解决的是「让爬虫正确理解页面」,不解决收录,也不承诺排名。收录取决于内容质量与站点权重,排名取决于更广泛的竞争环境。把标签配对、源码里验到、该有的页面都有,剩下的交给内容本身。

参考资料

标签

  • #Next.js
  • #metadata
  • #SEO
  • #open-open-graph
  • #structured-structured-data

相关课程