menu
首页文章相册工具关于
search
...

Google SEO

2026/09/11
eye-

一份持续更新的 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 的动作走一遍:

  1. 抓 /posts/hello,得到 200。
  2. 读到 canonical,得知"正式地址是 /en/posts/hello"。
  3. 去抓那个地址,得到一个跳转,跳回第 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 忽略:

  1. 必须互指。 英文页列出中文页,中文页也要列出英文页。单向声明会被丢弃。
  2. 必须包含自己。 英文页要有一条 hreflang="en" 指向自己的地址,不能只列其他语言。
  3. 必须是绝对地址。 写 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 条目都指向那个会跳转的域名。

修法分两半,缺一不可:

  1. 把裸域显式添加到项目里,配置成到 www 的 301 或 308 跳转。Google 对 301 和 308 的处理相同:永久跳转,权重合并到目标。
  2. 代码里的基础 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 会被直接丢弃。

同一个站上三种不同的出错方式:

  1. 兜底路由渲染了一个页面。 app/[lng]/(404)/[...not-found]/page.js 这样的路由能接住所有未匹配的路径,但它是个普通页面组件,所以 /abc、/fr/posts、/zh/abc 都渲染出漂亮的 "Page not found",状态码 200。
  2. 动态段什么都接受。 [lng] 能匹配 robots.txt、foo.txt、xx。带点号的路径会跳过语言中间件(为了让 public/ 里的真实文件能被服务),文件不存在时第一段就落进了 [lng],首页以 <html lang="foo.txt"> 渲染出来。
  3. 不存在的文章 slug 渲染空页。 文章页写的是 if (!post) return <div>no post</div>,状态码 200。

修法一一对应:

  1. 兜底页改为抛出而不是渲染,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 状态码,渲染组件永远不会。

  1. 根 layout 拒绝任何不在配置里的 lng:
export function assertSupportedLanguage(lng) {
  if (!siteMetadata.languages.includes(lng)) notFound()
}
  1. 参数是有限已知集合的路由,用 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 时要忽略大小写。

网页打开微信小程序一些有用的 Git 命令
目录
  • canonical
  • hreflang
  • 域名统一
  • robots.txt 和 sitemap.xml
  • 真正的 404
  • meta description
  • 私密页面加 noindex
  • Open Graph
  • 站内链接
  • 验证