一份持续更新的 SEO 知识清单,每个知识点按同一个结构写:它是什么、通常怎么出错、怎么修、为什么这样修是对的。例子用一个部署在
example.com上的双语 Next.js App Router 站点(英文在裸路径,中文在/zh下),但大部分规则和框架无关。遇到新的坑会继续往里加。
canonical
页面的 <head> 里可以放一行,声明"这个页面的正式地址是 X":
<link rel="canonical" href="https://www.example.com/posts/hello" />
rel 是 relationship 的缩写,描述当前页面和 href 里那个地址之间的关系。canonical 的意思是"那个地址是我的正式版本"。Google 收录页面、计算权重时,用的是 canonical 里写的地址,而不是用户实际访问的地址。
规则只有一条:canonical 必须指向一个能直接打开、返回 200 的地址,最省事的做法就是指向页面自己。
一个很常见的出错方式是这样的。假设站点的默认语言用裸路径(/posts/hello),其他语言带前缀(/zh/posts/hello)。一个中间件在内部把 /posts/x 改写成 /en/posts/x,让一个 [lng] 路由同时处理两种情况,同时把任何显式带 /en/ 的请求跳转回裸路径。文章页用的是 Next.js 的相对写法:
export const metadata = {
alternates: { canonical: "./" },
}
在请求时渲染的页面上,"./" 会按真实 URL 解析,得到 /posts/hello,没有问题。但如果文章路由是 force-static,它在构建期预渲染,那时没有请求 URL,"./" 就按内部路径解析了。页面最终对外声明的是:
<link rel="canonical" href="https://www.example.com/en/posts/hello" />
跟着 Google 的动作走一遍:
- 抓
/posts/hello,得到 200。 - 读到 canonical,得知"正式地址是
/en/posts/hello"。 - 去抓那个地址,得到一个跳转,跳回第 1 步。
页面 A 说"我不是正式的,B 才是",B 说"去看 A"。Google 无法按声明收录,只能自己猜一个地址,并把页面标记为 canonical 配置有误。一个返回 200 却声称自己不是正式版本的页面,比不写 canonical 还糟。
修法是不再依赖 "./",用语言和路径显式拼出 canonical,前缀规则收在一个 helper 里:
// lib/seo.js
const langPrefix = (lng) => (lng === fallbackLanguage ? "" : `/${lng}`)
export const localizedUrl = (lng, path = "") =>
`${siteUrl}${langPrefix(lng)}${path}`
然后每个页面通过这个 helper 声明自己的 canonical。请求时渲染的页面可以统一在 layout 里做一次,静态页面在自己的 generateMetadata 里做:
export const generateMetadata = async ({ params }) => {
const { lng, id } = await params
const post = await getPost(lng, id)
return {
alternates: { canonical: localizedUrl(lng, post.url) },
}
}
现在 /posts/hello 声明的正式地址就是 /posts/hello,中文页声明的是 /zh/posts/hello,中间不再经过任何跳转。
另外两个经常出现的 canonical 错误:
- 域名不一致。 站点把
example.com跳转到www.example.com,但 canonical 是从一个仍写着https://example.com的metadataBase生成的,于是所有 canonical 都指向一个会跳转的域名。选定一个域名,另一个用 301 或 308 跳过去,并让 base URL 和它保持一致。 - 跨语言 canonical。 把中文页的 canonical 指向英文页,理由是"英文是原文",这等于告诉 Google 把中文页从索引里去掉。每个语言版本各自是自己的 canonical,它们之间的关系用
hreflang来表达。
hreflang
hreflang 是 href language 的合写,是 <link rel="alternate"> 上的一个属性,意思是"href 里的地址是同一内容的另一个语言版本"。一个双语页面每种语言一行,再加一个兜底:
<link
rel="alternate"
hreflang="en"
href="https://www.example.com/posts/hello"
/>
<link
rel="alternate"
hreflang="zh"
href="https://www.example.com/zh/posts/hello"
/>
<link
rel="alternate"
hreflang="x-default"
href="https://www.example.com/posts/hello"
/>
值是语言代码,比如 en、zh,需要区分地区时加地区(zh-CN、zh-TW、en-US)。x-default 是特殊值,意思是"用户的语言都不匹配时,给他看这个"。
没有 hreflang 时,Google 把英文版和中文版当成两个不相干的页面:可能给中文读者展示英文版,或者认定两者重复只收录一个。有了 hreflang,Google 知道这是同一篇内容的两个语言版本,会按用户的语言展示对应版本,两个页面的权重合并计算而不是互相竞争。
三条规则,任何一条不满足,整组标签都会被 Google 忽略:
- 必须互指。 英文页列出中文页,中文页也要列出英文页。单向声明会被丢弃。
- 必须包含自己。 英文页要有一条
hreflang="en"指向自己的地址,不能只列其他语言。 - 必须是绝对地址。 写
https://www.example.com/zh/posts/hello,不能写/zh/posts/hello。
有了上面的 helper,生成这组标签只需要一个小函数:
// lib/seo.js
export function localizedLanguages(path, availableLanguages = languages) {
const map = Object.fromEntries(
availableLanguages.map((lng) => [lng, localizedUrl(lng, path)])
)
return { ...map, "x-default": localizedUrl(fallbackLanguage, path) }
}
export function localizedAlternates(lng, path, availableLanguages = languages) {
return {
canonical: localizedUrl(lng, path),
languages: localizedLanguages(path, availableLanguages),
}
}
每个页面在 metadata 里返回 alternates: localizedAlternates(lng, path) 即可,Next.js 会把 languages 这个映射渲染成 <link rel="alternate" hreflang> 标签。一个值得保留的细节:一个页面可能只有部分语言版本,所以要传入它实际存在的语言列表,只声明这些。列出一个会 404 的语言地址,会让整组标签违反第 1 条规则。
关于 hreflang 可以放在哪,还有两点:
- 同一份映射可以放进
sitemap.xml,以<xhtml:link rel="alternate" hreflang="...">的形式出现。Next.js 直接支持:在每条 sitemap 记录上返回alternates: { languages }就行。这是 Google 读取的第二个来源,和 HTML 里的标签不冲突。 - Next.js 里页面级的
alternates会整个替换掉 layout 的alternates,不是合并。如果 layout 在里面声明了别的东西,比如types下的 RSS 链接,页面必须重新声明一遍,否则它会悄悄消失。
验证的时候抓一个页面找这些标签。Next.js 输出的属性名是驼峰的 hrefLang,所以要忽略大小写:
curl -s https://www.example.com/posts/hello | grep -i hreflang
域名统一
对搜索引擎来说,example.com 和 www.example.com 是两个不同的站。必须选定一个,把另一个跳转过去,并且站点对外输出的每一个 URL 都从选定的那个生成。
怎么出错的:托管平台上项目绑定的是 www.example.com,裸域只是解析到了平台,平台"贴心地"帮你跳转。这种隐式跳转通常是 307,临时跳转。307 对 Google 的含义是"这是临时绕路,请保留原地址",于是两个域名都留在索引里,权重被拆成两份。与此同时 layout 里的 metadataBase 还写着 https://example.com,所有 canonical、og:url、RSS 链接和 sitemap 条目都指向那个会跳转的域名。
修法分两半,缺一不可:
- 把裸域显式添加到项目里,配置成到
www的 301 或 308 跳转。Google 对 301 和 308 的处理相同:永久跳转,权重合并到目标。 - 代码里的基础 URL 只在一处定义,其他全部从它派生:
// data/siteMetadata.js
const siteUrl = "https://www.example.com"
export default { siteUrl, socialBanner: `${siteUrl}/og.png` /* ... */ }
为什么要只写一处:canonical、og:url、twitter:image、RSS 的 <link>、sitemap.xml、robots.txt、JSON-LD 全都嵌着域名。各自手写迟早不一致,全部读 siteUrl 就不可能不一致。
用 curl 验证,跟随跳转并打印每一跳:
curl -sSIL https://example.com/posts/hello | grep -iE '^(HTTP/2|location)'
# HTTP/2 301 location: https://www.example.com/posts/hello
# HTTP/2 200
robots.txt 和 sitemap.xml
robots.txt 告诉爬虫哪些不要抓、sitemap 在哪。sitemap.xml 列出所有希望被收录的 URL,带最后修改时间,多语言站点还带每个 URL 的 hreflang。两者都不是可选项:没有 sitemap,小站的收录速度完全取决于 Google 什么时候碰巧顺着链接爬到。
App Router 里怎么出错的:Next.js 对这两个文件有约定,app/robots.js 和 app/sitemap.js,但只在 app/ 根目录下生效。放到 app/[lng]/ 下面,它们会挂到 /en/robots.txt 和 /zh/robots.txt,而 /robots.txt 本身会落进 [lng] 动态段,lng = "robots.txt",渲染出一个 200 的 HTML 页面。Google 看到的就是"没有 robots.txt"和"没有 sitemap"。
修法是把两个文件挪到 app/ 根目录,然后让 sitemap 对多语言站点真正正确:
// app/sitemap.js
export default async function sitemap() {
const posts = await getAllPosts()
return [
...languages.flatMap((lng) =>
STATIC_ROUTES.map((route) => ({
url: localizedUrl(lng, route),
lastModified: today,
alternates: { languages: localizedLanguages(route) },
}))
),
...posts.map((post) => ({
url: localizedUrl(post.language, post.url),
lastModified: post.lastmod || post.date,
alternates: {
languages: localizedLanguages(post.url, languagesOf(post)),
},
})),
]
}
两个要紧的细节:
- 每种语言各有自己的 URL。 中英文两个文件都输出
/posts/hello,等于同一个 URL 列了两遍,/zh/posts/hello从头到尾没出现。 alternates.languages会变成<xhtml:link rel="alternate" hreflang>。 这是 hreflang 的 sitemap 形式,Google 会和 HTML 里的标签一起读。
为什么不在 robots.txt 里 Disallow 私密页面:见下面 noindex 一节。这里的 robots.txt 只需要 Allow: / 加 sitemap 地址。
验证:
curl -sI https://www.example.com/robots.txt | grep -i content-type # text/plain
curl -s https://www.example.com/sitemap.xml | grep -c '<url>' # 页面数
真正的 404
不存在的 URL 必须返回 HTTP 状态码 404。返回 200 但内容是"找不到"的页面,Google 叫它软 404:会被抓取、有时会被收录、消耗抓取额度,任何人贴一个拼错的链接都会在你的域名下制造一个垃圾页面。真 404 会被直接丢弃。
同一个站上三种不同的出错方式:
- 兜底路由渲染了一个页面。
app/[lng]/(404)/[...not-found]/page.js这样的路由能接住所有未匹配的路径,但它是个普通页面组件,所以/abc、/fr/posts、/zh/abc都渲染出漂亮的 "Page not found",状态码 200。 - 动态段什么都接受。
[lng]能匹配robots.txt、foo.txt、xx。带点号的路径会跳过语言中间件(为了让public/里的真实文件能被服务),文件不存在时第一段就落进了[lng],首页以<html lang="foo.txt">渲染出来。 - 不存在的文章 slug 渲染空页。 文章页写的是
if (!post) return <div>no post</div>,状态码 200。
修法一一对应:
- 兜底页改为抛出而不是渲染,UI 挪到 layout 旁边的
not-found.js:
// app/[lng]/(404)/[...not-found]/page.js
import { notFound } from "next/navigation"
export default function CatchAll() {
notFound()
}
抛出 notFound() 才会让 Next.js 设置 404 状态码,渲染组件永远不会。
- 根 layout 拒绝任何不在配置里的
lng:
export function assertSupportedLanguage(lng) {
if (!siteMetadata.languages.includes(lng)) notFound()
}
- 参数是有限已知集合的路由,用
generateStaticParams加dynamicParams = false:
export const dynamic = "force-static"
export const dynamicParams = false
export async function generateStaticParams() {
return posts.map((post) => ({ lng: post.language, id: post.slug }))
}
不在列表里的参数组合在任何组件运行之前就得到路由级的 404。
关于 dynamicParams 三个容易搞错的点:
- 它作用于整条路由,不只是导出它的那一段。 Next.js 会检查一条路由的每一段,只要有一段是
false就按false处理。放在[lng]layout 上,会把没有预生成的/gallery/[imageId]也全部打成 404,因为那个子路由没有generateStaticParams。把它放在你能枚举参数的叶子页面上。 generateStaticParams生成的参数必须和真实 URL 一致。 构建时生成id: "en/hello"而真实 URL 是/posts/hello,等于给每篇文章预渲染了一个没人访问的空页,真实地址全部按需渲染。配上dynamicParams = false后,同一个 bug 会把所有真实文章打成 404。把生成的参数打印一遍,和 sitemap 里的 URL 对一对。- 外壳流式发出之后再
notFound(),状态码是 200。 树里有loading.js时,layout 先以 200 发出,页面的notFound()之后才作为流式回退到达;Next.js 会补一个<meta name="robots" content="noindex">当安慰。需要状态码本身正确时,从 layout 或generateMetadata里抛,或者用dynamicParams = false。
一次验证一组路径:
for p in /abc /fr/posts /zh/abc /foo.txt /posts/no-such-post /gallery/no-such-id; do
printf '%-24s ' "$p"; curl -s -o /dev/null -w '%{http_code}\n' "https://www.example.com$p"
done
meta description
<meta name="description"> 是 Google 优先显示在搜索结果标题下方的那段摘要。没有它,Google 自己从页面里截,通常截到的是第一段或导航栏。
怎么出错的:没有一篇文章的 frontmatter 里有 description,所以每个文章页都没有 description 标签;列表页则带着脚手架文案,比如 "This is blog list description"。脚手架文案比没有更糟,因为它真的会被展示出来。
修法没有捷径:每个页面写一句话,英文 120 到 160 个字符,中文 50 到 80 个字,说清读者能得到什么。和内容放在一起:
---
title: 深入理解 React startTransition
date: 2026-01-01
tags: ["react"]
description: "把 setState 标记成 transition 后发生了什么:后台渲染可中断、挂起时不显示 Suspense 兜底 UI、isPending 在计时什么。"
---
再把 frontmatter.description 接到 metadata.description、openGraph.description 和 twitter.description。
文案两条规则:
- 描述内容,不要复述标题。 "关于 startTransition 你需要知道的一切"就是把标题又说了一遍。写出技术点、陷阱、对比对象。
- 绝不描述不存在的内容。 正文只有一个数字的章节占位页,描述只能基于标题写。给一个空页面写"接入 MDX、配好 GFM 和代码高亮",是 Google 迟早会追究的谎言。
如果站点能从数据库或 CMS 覆盖文案,先搞清楚哪个来源优先再改文件。数据库里有一行覆盖时,改 JSON 文件对线上没有任何效果。
私密页面加 noindex
后台面板、登录、注册、重置密码页面没有搜索价值,不应该出现在结果里。工具是 robots meta 标签:
export const metadata = {
robots: { index: false, follow: false },
}
怎么出错的:后台页面对未登录用户执行 redirect("/admin/login"),看起来很安全。但树里有 loading.js 时,外壳先以 200 流式发出,跳转之后才作为客户端指令到达。Googlebot 拿到的是一个标题为 "Admin Dashboard"、带 robots: index, follow 的 200 页面。登录相关页面则还挂着 create-next-app 的脚手架 metadata(title: "Next.js"、description: "Generated by Next.js"),也没有任何 robots 指令。
修法是在每个私密路由分组的 layout 里加一个 robots 块,覆盖它下面的所有页面。顺手把登录 layout 的标题和描述换成真的。
为什么不用 robots.txt 的 Disallow: /admin:Disallow 的意思是"不要抓取"。Google 抓不到页面就看不见 noindex 标签,已经收录的页面会无限期留在索引里。noindex 要求页面可抓取。先用 noindex;等页面从索引里消失、并且确实在意抓取额度时,再考虑加 Disallow。
follow: false 是可选的。noindex, follow 是更常见的写法,允许链接权重继续流动;只在出链确实私密的地方用 nofollow,比如后台。
Open Graph
Open Graph(og:*)是一组 <meta> 标签,Facebook、微信、Twitter、Slack、Discord 用它来生成分享链接时的预览卡片。它不影响排名,影响的是分享出去有没有图和标题。
怎么出错的:
og:image指向了一个网页。https://github.com/username这样的值是 HTML 页面,不是图片。平台抓到的是 HTML,于是不显示图。og:image必须是 PNG 或 JPEG 的直接地址,最好 1200×630。og:locale写死。 layout 里locale: "zh_CN"意味着所有英文页面都声称自己是中文。按语言映射:en → en_US,zh → zh_CN。- 页面级的
openGraph会抹掉 layout 的。 Next.js 按顶层键做浅合并。列表页写openGraph: { title, description }会整个替换 layout 的对象,og:url、og:image、og:site_name、og:locale在这个页面上全部消失。alternates同理,页面就是这样悄悄丢掉 RSS 链接的。
修法是一个共享的基础对象,每个页面级 openGraph 先把它展开:
// lib/seo.js
export const siteOpenGraph = {
title: siteMetadata.title,
description: siteMetadata.description,
siteName: siteMetadata.title,
images: [siteMetadata.socialBanner],
type: "website",
}
export const ogLocale = (lng) => ({ en: "en_US", zh: "zh_CN" })[lng] ?? "en_US"
// 任意页面
openGraph: {
...siteOpenGraph,
title: t("title"),
url: localizedUrl(lng, "/posts"),
locale: ogLocale(lng),
}
图片本身,放一张 1200×630 的静态 PNG 在 public/ 里、用绝对地址引用,是最稳的选择:它作为文件被服务,原样通过语言中间件,不涉及任何跳转。Next.js 的 opengraph-image.js 约定可以按页面生成图片,但在 [lng] 段下它的地址是 /en/.../opengraph-image,会先跳转一次,而不是所有爬虫都会为图片跟随跳转。
站内链接
站内每一个 <a href> 都应该指向直接返回 200 的地址。链到一个会跳转的地址,用户每次点击多一次往返,爬虫每个页面多一次抓取。
怎么出错的:英文在裸路径、中间件把 /en/... 跳转到 /... 的站点上,所有组件都用 /${lng}/posts 拼链接。英文用户每次点击都是 /en/posts → 308 → /posts,每次抓取也是。同一批代码里还藏着两个副作用:/${lng}/${post.url} 拼出了 /en//posts/hello,因为 post.url 本身以斜杠开头;画廊网格链到 /gallery/<id> 完全不带语言,中文读者点进去落到英文页。
修法是一个把前缀规则封装起来的 helper,所有拼链接的地方都用它:
export const localizedPath = (lng, path = "") =>
`${langPrefix(lng)}${path}` || "/"
localizedPath("en", "/posts") // "/posts"
localizedPath("zh", "/posts") // "/zh/posts"
localizedPath("en", "") // "/"
所有拿当前路径和 /en/... 比较的逻辑(导航高亮、语言切换器)必须在同一次改动里一起更新。上线前 grep 一遍旧写法,剩下的合法命中只应该是中间件本身。
验证
上面每一项修复都是用 curl 验的,不是靠读代码。值得留着的命令:
# 跳转链,每跳一行
curl -sSIL https://example.com/ | grep -iE '^(HTTP/2|location)'
# 页面头部所有和 SEO 相关的标签
curl -s https://www.example.com/posts/hello \
| grep -oiE '<title>[^<]*|<link rel="(canonical|alternate)"[^>]*>|<meta (name|property)="(description|robots|og:[a-z_]+|twitter:[a-z]+)"[^>]*>'
# 一批路径的状态码
for p in /abc /posts/no-such-post; do printf '%s ' "$p"; curl -s -o /dev/null -w '%{http_code}\n' "https://www.example.com$p"; done
注意 Next.js 输出的属性名是驼峰的 hrefLang,grep 时要忽略大小写。