很多项目做 SEO 时,会先列一张标签清单:title 要有,description 要有,Open Graph 也补上。清单没有错,但它只覆盖了最后一步。真正决定页面能否被正确发现、理解和展示的,是一条更长的交付链:CMS 中的内容是否可信,URL 是否唯一,服务端是否返回正确状态,元数据是否和正文一致,爬虫是否有权抓取,站点地图是否持续更新,分享图片能否被社交平台读取。
这一章以虚构但接近真实业务的“星图课堂”为贯穿案例。它是一个基于 Next.js 16.1.6 App Router 的多语言课程站,课程正文来自 CMS,支持中文和英文,既有公开课程,也有草稿、下线内容和登录后才能看的私有课程。我们会从根布局写到动态课程页,再把 canonical、hreflang、分享卡片、robots.txt、sitemap、JSON-LD 和发布验收串起来。
先看“星图课堂”的一门课程:
https://learn.example.com/zh-CN/courses/nextjs-metadata;https://learn.example.com/en-US/courses/nextjs-metadata;/zh-CN/course/seo-in-nextjs 已迁移到新地址;表面上,这像是在 generateMetadata 中返回几个字段。可一旦把完整请求走一遍,你会发现至少有六个参与者:CMS、Next.js 路由、浏览器、搜索引擎抓取器、搜索结果生成系统、社交平台抓取器。它们读取的信号并不完全相同。

可以把这条链理解成一次“对账”。正文说课程已发布,HTTP 就不该返回登录页;canonical 指向中文课程,hreflang 就不该把英文地址写成同一个 URL;JSON-LD 说有三位讲师,页面上也应该能找到这三位讲师。信号互相冲突时,搜索引擎只能自己猜。
SEO 不能承诺排名。Next.js 能帮助我们稳定交付可抓取页面和机器可读信号,但搜索引擎仍会根据内容质量、站点信誉、用户需求和自身系统决定是否索引、使用哪个标题以及如何排序。
本章代码以 Next.js 16.1.6、React 19.2.3、App Router 为基线。版本很重要:App Router 中的动态 params 已经是 Promise,generateSitemaps 传入的动态 id 也采用 Promise 形式。把旧教程直接复制过来,常见问题不是 SEO 思路错了,而是类型和运行时约定已经变化。
“星图课堂”的相关目录先约定成这样:
app/
├── layout.tsx
├── robots.ts
├── sitemap.ts
├── manifest.ts
├── favicon.ico
├── icon.png
└── [locale]/
├── layout.tsx
└── courses/
└── [slug]/
├── page.tsx
├── opengraph-image.tsx
└── twitter-image.tsx
lib/
├── cms.ts
├── seo.ts
└── site.ts
assets/
└── NotoSansSC-Subset.ttf先建立唯一、稳定、不会随请求漂移的生产站点地址:
// lib/site.ts
const siteUrl = process.env.SITE_URL
if (!siteUrl && process.env.NODE_ENV === 'production') {
throw new Error('生产环境必须配置 SITE_URL')
}
export const SITE_URL = new URL(
siteUrl ?? 'http://localhost:3000'
)这里故意不把 VERCEL_URL 当作生产 canonical 的默认值。预览域名常随构建变化;如果它进入 canonical、Open Graph 或 sitemap,同一页面就会不断换“官方地址”。生产 SEO 地址应由 SITE_URL=https://learn.example.com 明确控制。
它们会在最终 HTML 和关联资源中汇合,但写法、优先级和缓存方式不同。
根布局适合保存全站默认值:站点名、标题模板、基础地址、默认摘要,以及不随具体课程改变的验证信息。它不应该猜测某门课程的标题和图片。
// app/layout.tsx
import type { Metadata } from 'next'
import type { ReactNode } from 'react'
import { SITE_URL } from '@/lib/site'
export const metadata: Metadata = {
metadataBase: SITE_URL,
title: {
default: '星图课堂',
template: '%s | 星图课堂',
},
description: '面向开发者的中文与英文课程平台。',
applicationName: '星图课堂'
metadataBase 让 alternates.canonical、Open Graph 图片等 URL 字段可以安全使用相对地址。Next.js 的公开 API 合同要求:需要绝对 URL 的元数据字段若使用相对值,却没有配置 metadataBase,会导致构建错误。16.1.6 的社交图片解析器内部确实有部署域名或 localhost 回退,并会在部分场景发出警告,但这不是应依赖的生产地址合同。绝对 URL 不会被 metadataBase 改写,因此外部 CDN 图片仍可直接写完整地址。
title 中的 default 和 template 要成对理解:
default 是当前布局及没有提供标题的后代页面的兜底标题;template 只作用于后代 segment 提供的标题;page.tsx 中同时定义模板和普通标题,模板不会给本页标题加后缀;title.absolute 会绕过上层模板,适合确实需要完整自定义标题的页面。课程页返回 title: 'Next.js 元数据与 SEO' 时,根布局会得到 Next.js 元数据与 SEO | 星图课堂。如果页面返回下面的值,就会绕过模板:
const metadata: Metadata = {
title: {
absolute: '2026 星图开发者大会',
},
}不要在根布局手写一个默认 keywords 列表,然后期待它提升 Google 网页排名。Google 明确说明其网页排名不使用 keywords meta。真正要维护的是准确标题、清楚摘要、可抓取正文、规范 URL 和可信内容。
“星图课堂”用 [locale] 路由承载语言。页面的可见语言应该反映在 <html lang="zh-CN"> 上,而不只存在于 hreflang 中。zh-CN 使用连字符;Open Graph 的 locale 常写成 zh_CN,二者不要混用。
Metadata API 会按路由从根到叶依次解析:根布局、语言布局、课程布局、课程页面。后面的 segment 可以覆盖前面的字段,但嵌套对象采用浅合并,不会逐个属性深合并。

假设根布局已经提供完整的 Open Graph:
export const metadata: Metadata = {
openGraph: {
type: 'website',
siteName: '星图课堂',
locale: 'zh_CN',
images: ['/og/default.png'],
},
}课程页只想换标题,于是写成:
export const metadata: Metadata = {
openGraph: {
title: 'Next.js 元数据与 SEO',
},
}最终不是“保留 siteName、locale、images,只换标题”,而是课程页的整个 openGraph 替换上层对象。上层字段会消失。

第一种是让叶子页面一次性返回完整对象。课程详情页本来就要根据 CMS 内容构造标题、摘要、URL 和图片,这通常最清楚。
第二种是把共享片段抽成普通函数:
// lib/seo.ts
import type { Metadata } from 'next'
export function buildSocialMetadata(input: {
title: string
description: string
pathname: string
image: string
locale: 'zh_CN' | 'en_US'
}): Pick<Metadata, 'openGraph' | 'twitter'> {
return
第三种是在 generateMetadata 的第二个参数中读取已解析的父元数据,只继承确实需要的值:
import type {
Metadata,
ResolvingMetadata,
} from 'next'
import { getPublishedCourse } from '@/lib/cms'
type Props = {
params: Promise<{
locale: string
slug: string
}>
}
export async function generateMetadata(
{ params }: Props,
读取 parent 不是每页都要做。父结果也需要被解析,继承太多会让最终输出难以推断。我更建议把稳定规则放进构建函数,把 parent 留给“明确要追加父图片”这类场景。
Next.js 的公开 API 把文件式元数据定义为更高优先级;目录层级之间,更深 segment 的分享图也会取代上层分享图。不过,16.1.6 安装源码中的同一 segment 合并是按字段处理的:如果当前 metadata 或 generateMetadata 明确拥有 openGraph.images 或 twitter.images,合并器会保留程序化图片;没有明确图片时,约定文件才会补入并替换继承图片。manifest 文件、图标文件和 favicon 又分别走不同分支。
这意味着工程上不该同时配置两套来源,再靠一句“谁优先”猜结果。让一个字段只有一个所有者:课程分享图由 opengraph-image.tsx 管,就不要在同一 segment 再写 openGraph.images;决定使用 CMS 图片数组,就不要再放冲突的约定文件。排错时同时检查当前 segment、父 segment 和对象字段,最后以生成的 HTML 为准。
不要把 Metadata 的合并想成 CSS 层叠。它没有按嵌套键逐层补齐的机制。只要子 segment 返回了 openGraph、robots 等嵌套对象,就应检查最终生成的全部字段,而不是只检查这次新增的一项。
静态页面可以直接导出 metadata,也可以导出同步的 generateMetadata。后者不要求一定是异步函数。
// app/about/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: '关于星图课堂',
description: '了解星图课堂的课程编辑和审核方式。',
}
export default function AboutPage() {
return <main><h1>关于星图课堂</h1></main>
}下面的同步函数也合法:
import type { Metadata } from 'next'
export function generateMetadata(): Metadata {
return {
title: '课程订阅方案',
description: '比较个人与团队订阅方案。',
}
}
export default function PricingPage() {
return <main><h1>课程订阅方案</h1></main>
}同一个 route segment 只能二选一:不能同时导出 metadata 和 generateMetadata。这两个导出只支持 Server Component,标记了 'use client' 的页面或布局不能导出它们。
课程页需要 CMS 数据,因此使用异步 generateMetadata。Next.js 16 中的 params 和页面 searchParams 都是 Promise;布局没有 searchParams:
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPublishedCourse } from '@/lib/cms'
import { buildCourseMetadata } from '@/lib/seo'
type CoursePageProps = {
params: Promise<{
locale: 'zh-CN' | 'en-US'
slug: string
}>
searchParams: Promise<
Record
searchParams 不该无条件参与标题。像 utm_source 这样的跟踪参数不改变课程事实,后面处理 canonical 时再明确归一化规则。
同一渲染中,如果 generateMetadata 和页面发出 URL 与请求选项相同的 fetch,Next.js 会自动记忆化这次请求。该能力覆盖 generateMetadata、generateStaticParams、布局、页面和 Server Component:
// lib/cms.ts
export async function getPublishedCourse(
locale: string,
slug: string
): Promise<Course | null> {
const response = await fetch(
`${process.env.CMS_URL}/courses/${locale}/${slug}`,
{
headers: {
这项“请求记忆化”解决的是同一次 React 服务端渲染中重复调用相同 fetch 的问题。它不等于跨请求的持久缓存,也不代表 CMS 内容会永久缓存。跨请求缓存、重新验证和标签失效属于数据缓存策略,应按课程更新频率单独设计。
如果数据来自数据库 SDK 或 CMS 客户端而不是 fetch,可以用 React 的 cache 在一次服务端渲染中去重:
import { cache } from 'react'
import { cms } from '@/lib/cms-client'
export const getPublishedCourse = cache(
async (locale: string, slug: string) => {
return cms.course.findPublished({
locale,
slug,
})
}
)把函数定义在模块作用域,让元数据和页面导入同一个函数。不要分别维护 getCourseForSeo 与 getCourseForPage,否则字段、状态过滤和缓存规则迟早分叉。
元数据读取失败和“课程不存在”不是一件事。CMS 返回 500、超时或鉴权失败时,应抛出错误并进入错误处理与监控;不要吞掉所有异常后返回 noindex,那会把真实故障伪装成内容下线。
标题和摘要是页面描述信号,不是能强制搜索结果照抄的广告位。Google 可能结合页面主标题、链接文字和其他可见内容改写标题,也可能从正文选择更匹配查询的摘要。
课程标题应让用户快速辨认内容。对“星图课堂”来说,下面的值交给根模板即可:
return {
title: 'Next.js 元数据与 SEO',
}浏览器最终得到 Next.js 元数据与 SEO | 星图课堂。不要在 CMS 课程标题中也保存品牌后缀,否则模板会重复品牌。也不要为了关键词写成下面这样:
Next.js SEO 教程 Next.js Metadata 教程
Next.js 搜索优化 完整课程 2026 星图课堂标题还有三个边界:
<h1>、浏览器标题和 Open Graph 标题可以为不同载体做轻微调整,但核心主题应一致;一段可用的课程摘要通常回答“学什么、面向谁、覆盖哪些问题”:
return {
description:
'用 Next.js 16 Metadata API 为多语言课程页配置 canonical、分享卡片、sitemap 和结构化数据,并建立发布验收流程。',
}不要在摘要中承诺页面没有的内容,例如“免费证书”“保证排名第一”。搜索引擎未必采用 description,但准确摘要仍能给搜索系统和分享工具一份清楚的候选文本。
function toPlainText(value: string): string {
return value
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function truncate
这里的 160 是编辑约束,不是搜索引擎保证展示的固定长度。团队真正需要的是“没有 HTML、没有重复空白、不过度冗长、截断后仍能读懂”。
课程链接可能带上多种查询参数:
/zh-CN/courses/nextjs-metadata
/zh-CN/courses/nextjs-metadata?utm_source=wechat
/zh-CN/courses/nextjs-metadata?ref=teacher
/zh-CN/courses/nextjs-metadata?tab=transcriptcanonical 告诉搜索引擎:哪些 URL 表示同一个主要内容,哪个地址是首选版本。对 Google 来说它是强信号,不是必须服从的指令;站内链接、重定向与 sitemap 仍要指向同一规范地址。它不是重定向,也不会替浏览器改地址。
import type { Metadata } from 'next'
export function coursePathname(
locale: string,
slug: string
): string {
return `/${locale}/courses/${
encodeURIComponent(slug)
}`
}
export function buildCourseMetadata(
course: Course
):
根布局配置了 metadataBase,相对路径会组合成绝对 canonical。
utm_source、ref 通常不改变正文,可以 canonical 到无参数地址。可如果 ?tab=transcript 展示一份独立、可索引且有稳定入口的文字稿,就不能不加判断地归一到课程首页。
canonical 也不能替代迁移。旧 slug 已废弃时,应返回 308 永久重定向到新 slug;如果旧地址仍返回 200,只加 canonical 会让用户和爬虫继续浪费请求,也会留下站内链接污染。
先列出页面可能出现的 URL 形态,包括语言前缀、尾斜杠、大小写、旧 slug、分页和跟踪参数。
再判断每种 URL 是否提供独立且有搜索价值的内容。内容等价的地址归到同一个 canonical,内容不同的地址保留自己的规范 URL。
对永久迁移的地址配置服务端重定向,并把站内链接、sitemap、JSON-LD 和分享链接一并改成新地址。
最后抓取生产 HTML,核对响应状态、最终 URL 和 canonical 是否指向同一个公开页面。
中文和英文课程是同一内容的不同语言版本。每个版本都应该 canonical 指向自己,列出自己和其他已发布翻译,使用真实可访问 URL,并让语言代码与正文一致。还可以提供 x-default 指向语言选择页或默认版本。

type CourseTranslation = {
locale: 'zh-CN' | 'en-US'
slug: string
published: boolean
}
function buildLanguageAlternates(
translations: CourseTranslation[]
): Record<string, string> {
const languages = Object.fromEntries(
translations
.filter
这里把 translations 定义为“包含当前版本在内的全部语言记录”。如果 CMS 接口只返回其他语言的兄弟记录,就要先把当前课程加入数组再调用;否则会漏掉指向自身的 alternate。
课程元数据可以这样组装:
const pathname =
`/${course.locale}/courses/${course.slug}`
return {
alternates: {
canonical: pathname,
languages: buildLanguageAlternates(
course.translations
),
},
}如果英文翻译仍是草稿,中文页不要提前声明英文 alternate。否则爬虫顺着链接访问,只会遇到 404、登录页或 noindex 页面。Google 建议多语言版本互相列出:中文页指向英文页,英文页也应指回中文页,并都包含自身。
hreflang 解决“首选页面有哪些语言或地区版本”。不要把英文页 canonical 到中文页来表示关联。这样等于告诉搜索引擎英文页是中文页的重复版本,和保留英文结果的目标冲突。
hreflang 是匹配信号,不是自动翻译机制。页面正文、导航、标题和结构化数据都应使用对应语言。仅换一个 locale 路径并复制中文正文,不会得到合格的英文版本。
搜索结果、聊天软件和社交平台使用页面信息的方式不同。搜索引擎可能改写标题与摘要;Open Graph 抓取器更依赖明确的 og:* 字段;X 使用 Twitter Card 字段,并可能在缺失时回退到 Open Graph。

一份完整的课程社交元数据可以由同一个课程事实构造:
import type { Metadata } from 'next'
const OG_LOCALE = {
'zh-CN': 'zh_CN',
'en-US': 'en_US',
} as const
export function buildCourseMetadata(
course: Course
): Metadata {
const pathname =
`/${course.locale}/courses/${course
Open Graph 协议最核心的字段是 og:title、og:type、og:image、og:url。OGP 没有标准的 course 类型,所以课程落地页使用 website,课程语义交给后面的 Schema.org Course。只有真正的文章页才使用 article;其 article:author 应指向作者资料 URL,不要把讲师姓名硬塞成作者 URL。课程页还会提供摘要、站点名、locale 和图片替代文本。图片 URL 必须允许未登录的外部抓取器访问,不能依赖 Cookie、短期签名或公司内网。
浏览器标题可使用根模板带品牌,社交标题则保留更干净的课程名:
浏览器:Next.js 元数据与 SEO | 星图课堂
Open Graph:Next.js 元数据与 SEO这不是冲突,因为载体的空间和任务不同。真正的冲突是标题说“免费完整课”,正文却要求付费且只有目录预览。
发布前还应确认分享图片返回 200,Content-Type 与真实格式一致,URL 不会过期,中文在缩略图中能读,alt 准确描述内容。社交平台有自己的缓存;换图后仍看到旧图时,先抓生产 HTML,再用对应平台调试工具重新抓取。
App Router 会识别约定文件,并自动生成标签或资源。对稳定、与目录绑定的资源来说,文件约定比在对象里手写 URL 更直观。
静态 Open Graph 图片还可以配同目录的 opengraph-image.alt.txt,Twitter 图片可配 twitter-image.alt.txt。Next.js 对文件大小有限制:Open Graph 图片最大 8 MB,Twitter 图片最大 5 MB,超过限制会导致构建失败。
// app/manifest.ts
import type { MetadataRoute } from 'next'
export default function manifest():
MetadataRoute.Manifest {
return {
name: '星图课堂',
short_name: '星图',
description: '面向开发者的多语言课程平台。',
start_url: '/',
display: 'standalone',
background_color: '#fffaf0',
theme_color: '#5b4bdb',
Manifest 主要服务安装体验,不等于“配置后就是完整 PWA”。离线策略、Service Worker、图标安全区域和安装条件仍要单独处理。
团队可以让全站图标由根目录文件维护,课程分享图由课程 segment 的动态文件维护,标题、摘要、canonical 和语言关系由 generateMetadata 维护。同一字段不在文件与对象中重复声明,既符合文件式元数据的对外优先级合同,也避开 16.1.6 内部按字段合并造成的歧义。
动态课程多时,不适合让编辑逐张制作 1200×630 分享图。opengraph-image.tsx 可以用 ImageResponse 根据课程数据生成图片。

// app/[locale]/courses/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getPublishedCourse } from '@/lib/cms'
export const alt = '星图课堂课程分享图'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
const fontData =
ImageResponse 支持 TTF、OTF 和 WOFF,不支持 WOFF2。它不会自动拥有浏览器里的中文系统字体;没有对应字形,标题可能出现方框或缺字。
生成图片的 bundle 上限是 500 KB,其中包括 JSX、CSS、字体和嵌入图片。示例用 process.cwd() 从项目根目录定位字体,避免让相对层级随路由目录变化。完整 CJK 字体往往远超预算,所以“把整套中文字体放进去”通常行不通。可以维护合法的字体子集、使用体积受控且覆盖目标语言的字体,或改用预生成图片管线。字体子集还要在 CMS 发布时逐字验证,因为今天能显示的字,不代表下周标题中的生僻字也能显示。示例只注册 400 字重,标题也使用 400;如果要用 700,必须注册匹配字重并重新核算包体。
ImageResponse 支持 flexbox 和一部分 CSS,不支持完整浏览器布局能力,尤其不要依赖 CSS Grid。示例显式使用 display: 'flex',就是为了适配 Satori 的渲染模型。输出格式由 contentType 确认,URL、响应头和实际字节必须一致。
至少测试超长中文、英文长单词、数字符号、缺封面、课程不存在和 CMS 超时。只用“你好世界”测试字体和布局,覆盖不了真实课程标题。
robots.txt 控制抓取器可以请求哪些路径;页面里的 robots meta 控制已抓取页面是否允许进入索引、是否跟踪链接。二者解决的问题不同。

// app/robots.ts
import type { MetadataRoute } from 'next'
import { SITE_URL } from '@/lib/site'
export default function robots():
MetadataRoute.Robots {
return {
rules: [{
userAgent: '*',
allow: '/',
disallow: [
'/api/',
'/admin/',
],
}],
sitemap:
这不是安全边界。robots.txt 是公开建议,恶意客户端可以忽略;私有课程必须依靠认证和授权。
一个 URL 即使被 robots.txt 禁止抓取,搜索引擎仍可能依据外部链接收录它,只是无法读取页面内容。更麻烦的是,爬虫无法抓页面,就看不到页面里的 noindex。如果目标是让一个仍可公开访问的页面退出索引,通常要允许爬虫访问并返回:
import type { Metadata } from 'next'
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
nocache: true,
googleBot: {
index: false,
follow: false,
noimageindex: true,
},
},
}“私有”尤其要分清:SEO 配置不能阻止越权读取。服务端先验证用户能否访问课程,再决定是否返回正文;缓存键也必须隔离身份。robots 只是避免公开搜索展示的附加措施。
如果先用 robots.txt 屏蔽目录,再给其中页面加 noindex,爬虫可能永远读不到这条 noindex。先确定目标是节省抓取、阻止索引,还是保护数据,再选择机制。
sitemap 是站点主动提交的公开规范 URL 清单。它不保证收录,但能帮助搜索引擎发现页面和理解更新时间。清单里只放希望被索引、能够稳定返回成功响应的 canonical URL。
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { SITE_URL } from '@/lib/site'
import { listPublishedCourses } from '@/lib/cms'
export default async function sitemap():
Promise<MetadataRoute.Sitemap> {
const courses = await listPublishedCourses()
return courses.map((course) =>
lastModified 必须来自真实内容变化。不要给所有 URL 填 new Date(),否则每次构建都会声称整个站刚刚更新。课程时间应来自会改变主要内容的修改,例如标题、正文、讲师或封面;浏览次数和后台查看时间不应推动它变化。Google 不使用 sitemap 中的 priority 和 changefreq,无需花时间计算这些值。
单个 sitemap 最多 50,000 个 URL,未压缩大小最多 50 MB。大型课程站要使用 generateSitemaps。Next.js 16 中传入 sitemap 函数的 id 是 Promise:
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { SITE_URL } from '@/lib/site'
import {
countPublishedCourses,
listPublishedCoursesPage,
} from '@/lib/cms'
const PAGE_SIZE = 50_000
export async function generateSitemaps():
Promise<Array<{ id: string }>> {
const total = await

CMS 分页要保证顺序稳定,最好使用不可变 ID 或游标。若内容持续写入时只依赖 offset,生成多个分片期间新增一条记录,可能让后续页面重复或漏掉课程。
CMS 超时后返回空数组看似让构建“成功”,实际可能把大量 URL 从 sitemap 中移除。更稳妥的做法是让生成失败并触发告警,或保留上一次成功产物。还应检查 URL 数量波动:
昨日公开课程:12,480
本次 sitemap:37
允许波动阈值:±10%
结论:阻断发布并检查 CMSsitemap、站内链接和 canonical 应使用同一套 URL 构建函数。三处各自拼路径,很容易出现一个带尾斜杠、一个漏语言前缀、一个仍使用旧 slug。
结构化数据帮助搜索引擎识别“这是课程、由谁提供、有哪些面包屑关系”。它不会自动带来富媒体结果,也不能替代可见正文。Schema.org 类型合法,只说明词汇结构成立;Google 是否支持某种富媒体展示,还有自己的功能、质量和地区限制。

Course 本身就是 Schema.org LearningResource 的子类型,因此不要为同一门课创建两个互不关联的实体。下面把学习资源属性直接写进 Course,再用 @graph 放入独立的 BreadcrumbList:
// app/[locale]/courses/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { SITE_URL } from '@/lib/site'
import { getPublishedCourse } from '@/lib/cms'
type Props = {
params: Promise<{
locale: 'zh-CN' | 'en-US'
slug: string
}>
}
export default async function CoursePage({
params
JSON.stringify(...).replace(/</g, '\\u003c') 会把小于号转义,降低 CMS 文本中恶意字符串提前结束 <script> 的风险。它不是完整输入安全方案;CMS 内容仍应经过权限、校验和审核。
下面是错误方向:
export const metadata: Metadata = {
other: {
'application/ld+json':
JSON.stringify(jsonLd),
},
}metadata.other 用来生成额外的 <meta name="..." content="...">,不会生成 type="application/ld+json" 的 <script>。JSON-LD 应像前一个示例那样直接渲染在页面或布局中。
页面上看不到、用户无法验证的事实,不要写进 JSON-LD:
aggregateRating;offers;inLanguage: 'en-US';Google 当前的 Course list 富媒体结果还有额外门槛:至少标记三门课程,并在课程汇总页或单页集合中提供 ItemList;单个详情页的 Course 要与这样的汇总页配套。该功能目前只在英文结果中提供。满足这些条件也只是获得展示资格,不保证一定出现;中文页上的 Schema.org 语义仍可帮助机器理解内容,但不要承诺 Course list 展示。上线前应查看对应的 Search Central 文档,而不是只看 Schema.org 验证通过。
SEO 最容易在“非正常页面”上露出问题。公开课程通常只是 200;真正需要设计的是它不存在、改地址、未发布或需要登录时会发生什么。
import { notFound } from 'next/navigation'
export default async function CoursePage({
params,
}: Props) {
const { locale, slug } = await params
const course = await getPublishedCourse(
locale,
slug
)
if (!course) notFound()
notFound() 会终止当前 segment 的渲染并注入 noindex。不过要理解流式响应的状态差异:如果响应已经开始流式传输,后来才发现不存在,可能保留 200;非流式情况下会返回 404。如果业务要求严格的 HTTP 404,就应在流开始前完成关键存在性检查,不要先渲染耗时外壳,再在深层组件里判断。
import {
notFound,
permanentRedirect,
} from 'next/navigation'
export default async function CoursePage({
params,
}: Props) {
const { locale, slug } = await params
const result = await resolveCourseRoute(
locale,
slug
)
if (result.kind
permanentRedirect 使用 308。迁移后还要更新站内链接、canonical、sitemap、hreflang 和 JSON-LD;重定向只是兜底,不是让旧地址永久留在站内导航中的理由。
草稿地址需要鉴权,响应应避免被共享缓存,并附加 noindex。即使预览 URL 很难猜,也不要假设没人会转发:
import type { Metadata } from 'next'
export function generateMetadata(): Metadata {
return {
title: '课程预览',
robots: {
index: false,
follow: false,
},
}
}真正的权限检查必须发生在服务端。未授权访问可以返回登录页、404 或 403,选择取决于是否希望暴露资源存在性,但不能先把草稿正文渲染到客户端再隐藏。
付费或组织内部课程最好把公开落地页与私有学习页分开:
公开落地页:
/zh-CN/courses/nextjs-metadata
可索引,展示课程介绍
私有学习页:
/zh-CN/learn/nextjs-metadata/lesson-1
服务端鉴权,通常 noindex,不进入 sitemap这样既保留公开搜索入口,又避免把完整受限内容暴露给爬虫。登录页本身是否索引也应明确配置,通常没有搜索价值。
App Router 默认使用 React Server Components。服务端组件输出的标题、正文和链接会进入服务端响应,不要求浏览器先执行客户端 JavaScript 才看到主要内容。对课程站来说,这是稳健默认值。
下面这种页面虽然能在浏览器工作,但首个 HTML 只有加载状态,数据还依赖客户端请求:
'use client'
import { useEffect, useState } from 'react'
export default function CoursePage() {
const [course, setCourse] =
useState<Course | null>(null)
useEffect(() => {
fetch('/api/course/current')
.then((response) => response.
Google 能渲染 JavaScript,但渲染要排队,也可能遇到接口失败、Cookie 条件或资源限制。课程主要内容没有必要承担这层不确定性。更合适的方式是在 Server Component 中读取 CMS,把交互小部件留给 Client Component。
Next.js 可以流式输出动态元数据:页面 UI 先返回,元数据解析完成后追加到响应 body,浏览器会识别相关标签。这能减少慢元数据阻塞页面显示的时间。
对于只能读取原始 HTML、不能执行 JavaScript 的特定机器人,Next.js 会根据默认的 HTML-limited bot 列表阻塞页面流,等待元数据完成后把它放进 <head>。常见社交抓取器包含在默认处理范围内,大多数项目不需要修改 htmlLimitedBots。
当前项目没有配置 htmlLimitedBots,因此使用 Next.js 16.1.6 的默认 HTML-limited bot 正则。如果确有特殊抓取器无法读取流式元数据,可以在 next.config 中覆盖它;这里是替换默认列表,不是向默认列表追加。把它设为 /.*/ 会关闭流式元数据并让请求等待元数据,通常会增加响应时间。应先拿真实 User-Agent 和响应证据验证,再改配置。
“在浏览器开发者工具里能看到标签”不等于所有抓取器都看到了相同结果。验收时要同时检查普通请求、HTML-limited 社交抓取器和搜索引擎工具返回的内容。
动态元数据依赖 CMS 时,应让元数据与页面共用记忆化的数据函数,为 CMS 设置超时和监控,对公开数据设计稳定的重新验证,并避免让分享图片依赖短期签名。上游故障时还要区分“内容不存在”和“服务暂时失败”。
现在把“星图课堂”的一次课程发布走完。目标不是检查源代码里“看起来写了”,而是验证生产环境最终交付的事实。
在 CMS 中确认课程状态、语言、slug、标题、摘要、封面、讲师、发布时间和更新时间。草稿翻译不能进入公开 alternate 与 sitemap。
用统一 URL 构建函数生成 canonical、语言 alternate、Open Graph URL、JSON-LD URL 与 sitemap URL,确认它们都使用生产域名。
请求课程 URL,检查最终响应状态、重定向链、<title>、description、canonical、robots、Open Graph 和 Twitter 标签。
请求分享图片,检查状态码、Content-Type、尺寸、中文字体、长标题和无封面场景。
下面的命令不能替代搜索引擎工具,但能快速发现生产域名、状态和头部标签错误:
COURSE_URL='https://learn.example.com/zh-CN/courses/nextjs-metadata'
curl --silent \
--show-error \
--location \
--dump-header /tmp/course.headers \
--output /tmp/course.html \
"$COURSE_URL"
rg -n \
'<title|canonical|robots|og:|twitter:|application/ld\+json' \
/tmp/course.html
curl --silent \
--show-error \
--user-agent 'facebookexternalhit/1.1' \
"$COURSE_URL
中文课程在浏览器里正常打开,但搜索结果仍显示旧标题;微信分享没有图片;英文课程偶尔出现在 sitemap,打开后却是预览登录页。请写出排查顺序。
一套可维护的 SEO 实现,应该让团队能从 CMS 的一条课程记录推导出全部公开事实:
发布状态
→ HTTP 状态与权限
→ 规范 URL 与语言关系
→ 标题、摘要和社交预览
→ sitemap 与结构化数据
→ 发布后的抓取验收如果这些输出由同一份课程数据和同一套 URL 函数构建,改名、翻译和迁移就有清楚的落点。下一章讨论性能优化时,我们会继续处理缓存、流式渲染和资源加载,但 SEO 的边界已经明确:性能策略可以改变“多快交付”,不能改变页面是否存在、是否公开以及哪个 URL 才是规范版本。
解析 JSON-LD,检查小于号转义、实体 URL、面包屑、语言和页面可见事实是否一致,再用 Google 富媒体结果测试验证受支持类型。
检查 robots.txt 与 sitemap。确认公开课程可抓取,草稿和私有页不在 sitemap,真实更新时间没有被构建时间覆盖。
分别模拟普通浏览器、搜索抓取器和社交抓取器,确认流式元数据与 HTML-limited bot 行为符合预期。
发布后用 Search Console URL 检查和日志监控观察抓取、索引与错误趋势;排名变化不应被当成单次发布的即时验收条件。