你的 React / Vue / Angular SPA 在路由挂载后用 document.title = "..." 或 <Helmet> / <Head> 组件设置每页标题。浏览器里标签页标题更新正确,但在 Google 上每个 URL 的 SERP 标题都是同一个通用值:MyApp、Loading...,或者服务端初始 HTML 里放的那个。
最快的修法:把标题放进服务端渲染(或预渲染)的 HTML 里,让正确的 <title> 出现在响应的第一个字节里,早于任何 JavaScript 运行。用 curl 拉原始 URL 验证。下面的内容,是这一步还不够时才需要走的弯路。
为什么会这样:Google 用两波(two-wave)来索引 JavaScript 页面。第一波读服务端返回的原始 HTML;第二波在无头 Chromium 里跑你的 JavaScript,再读渲染后的 DOM。截至 2026 年 6 月,两波之间的间隔仍然从几小时到几周不等,取决于抓取预算,而且第二波并不保证每个 URL 都跑。只在 JS 运行后才存在的标题,命运全交给第二波;写在原始 HTML 里的标题,每次都在第一波就被索引。(Google 自己的 JavaScript SEO 文档就描述了这个先抓取、后渲染的两阶段模型。)
还有一个越来越要紧的原因:AI 爬虫根本不渲染 JavaScript。 截至 2026 年 6 月,GPTBot、ClaudeBot、PerplexityBot 这类爬虫发一个 HTTP 请求、读原始 HTML、然后就走了——它们不跑浏览器引擎、不等渲染、也不会再来第二次。一份被广泛引用的 Vercel/MERJ 分析查了超过 5 亿次 GPTBot 抓取,没找到任何执行 JavaScript 的迹象,ClaudeBot、Meta 的爬虫、PerplexityBot 也都是同样的规律。只靠 JS 的标题对它们全是隐形的,于是你的页面在 AI Overviews 和聊天机器人引用里拿到的是通用或空标题。
常见原因
按真实 SPA 上的命中率排序。
1. 服务端给每个路由返回同一个 <title> 壳子
每个 URL 的初始 HTML 都是 <title>MyApp</title>,JS 挂载后才替换。Google 第一波索引的是壳子,第二波可能永远到不了。
怎么判断:curl https://example.com/any/route,每个 URL 都返回同一个 <title>。
2. 初始标题是 “Loading…” 或空
为了表示 hydration 还没完,壳子用 <title>Loading...</title>。Google 照单全收,把 Loading... 写进 SERP。
怎么判断:Google 上 site:yourdomain.com 搜一下,一堆结果都是同一个 Loading...。
3. 标题由仅客户端跑的 state hook 设置
React 里 useEffect(() => { document.title = data.title }, [data]) 只在浏览器跑,服务端渲染从不执行 effect。
怎么判断:hydration 之后浏览器 DevTools 里标题正确;view-source: 和 curl 里看不到。
4. SSR 在数据 fetch 完成前就把 head 流式输出了
streaming SSR 把 <head> 推给链路时标题数据还没解析完,刷出去的 HTML 里就没标题。
怎么判断:HTML 响应的 head 里是 <title></title> 或干脆没 title;浏览器只有在 fetch 完成后才显示正确标题。
5. 标题在客户端重定向之后才设置
/old-path 用 window.location / router push(不是 HTTP 301)跳到 /new-path。Google 爬 /old-path,拿到跳转前的壳子标题,索引下来。
怎么判断:SERP 显示跳转前 URL 配通用标题;跳转路径返回 HTTP 200 而不是 301。
6. 爬虫渲染了,但标题 hook 依赖 cookie 或鉴权
标题数据要登录后的 API 调用。Googlebot 是匿名的,API 返 401,标题 state 永远不更新。
怎么判断:爬虫模拟器(或 curl 不带 cookie)的网络面板里,标题数据接口返 401。
我属于哪一类?
curl 里 <title> 显示什么 | 浏览器标签页显示什么 | 最可能的原因 | 去看 |
|---|---|---|---|
| 每个路由都一样 | 每个路由都对 | 壳子标题,JS 替换(#1) | 第 2 步 |
Loading... 或空 | 稍等就对 | 占位符壳子(#2) | 第 2 步 |
| 只有动态路由是空的 | fetch 后才对 | 仅客户端 hook 或流式 head(#3、#4) | 第 2 / 3 步 |
| 通用值,且 URL 是过期路径 | 真实路径上才对 | 客户端重定向(#5) | 第 5 步 |
通用值,数据调用返 401 | 只有登录才对 | 鉴权后的标题数据(#6) | 第 6 步 |
开始前
- 确认症状:
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" https://example.com/some-route,读<title>。 - 决定渲染策略:全 SSR、静态预渲染(SSG),还是混合。不同方案打开不同修复路径。
- 看看框架是否已原生支持服务端 metadata:Next.js
generateMetadata、NuxtuseHead/useSeoMeta、SvelteKit<svelte:head>、Angular 配@angular/ssr的Title服务。 - 数一下要修的不同路由模板数量。经常一个共享 layout 就驱动全部。
需要收集的信息
- 5-10 个代表性 URL 的服务端渲染 HTML(是
curl的输出,不是 DevTools)。 - 同一批 URL hydration 之后的浏览器 DOM,确认客户端标题是对的。
- 框架是否支持 SSR / SSG,是否真的启用了。
- 标题的数据来源:frontmatter、API 调用、路由参数,还是 CMS。
- SERP 索引 URL 和正典 URL 之间是否有跳转。
一步步修
按影响和代价排序。
第 1 步:确认爬虫究竟看到什么
curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
https://example.com/products/widget \
| grep -oE '<title>[^<]*</title>'
如果这里返回的标题就是错的,问题在原始 HTML 里,第二波无关紧要。想看 Google 实际渲染的快照,打开 Search Console,对该 URL 跑 URL Inspection,点 Test Live URL,再点 View Tested Page。右侧面板有三个标签:HTML、Screenshot、More Info。HTML 标签是 Google 索引的渲染后 DOM;More Info 标签列出被屏蔽的资源和 console 报错。(对已索引的 URL,改用 View Crawled Page,看上次存下的渲染结果。)
第 2 步:标题生成挪到服务端
这是 #1-#4 的真正修法。在 SSR 阶段就把标题输出,让它进原始 HTML。
Next.js App Router(Next.js 15+,params 现在是异步的):
// app/products/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const product = await getProduct(slug);
return {
title: `${product.name} | Acme Store`,
description: product.summary,
};
}
Nuxt 3:
useSeoMeta({
title: () => `${product.value.name} | Acme Store`,
description: () => product.value.summary,
});
SvelteKit(数据在 +page.ts / +page.server.ts 里加载):
<svelte:head>
<title>{data.product.name} | Acme Store</title>
</svelte:head>
Angular(启用了 @angular/ssr / 服务端渲染):
import { Title } from "@angular/platform-browser";
constructor(private title: Title) {}
ngOnInit() { this.title.setTitle(`${this.product.name} | Acme Store`); }
这些都在 SSR 阶段跑,把正确标题直接放进响应 HTML。务必让 await 链在 head 被刷出去之前解析完标题数据(这同时也堵上了 #4)。
Next.js 的坑(15.2+):对动态渲染的路由,Next.js 现在会把 metadata 流式输出,把解析出的 <title> / <meta> 标签追加到 <body> 末尾,而不是放进初始 <head>——为的是压低 TTFB。Googlebot(会跑 JS、读完整 DOM)能正常处理;Next.js 也会按 User-Agent 识别 “HTML-limited bots”,对它们退回阻塞模式,把标签放进 <head>。但最干净的修法是让路由保持可静态预渲染:当 generateMetadata 不引入运行时数据(cookies()、headers()、searchParams)时,标题会对所有人都烤进初始 HTML。如果你必须动态渲染、又想让每个爬虫都在原始 <head> 里看到标题,就在 next.config.ts 里设 htmlLimitedBots: /.*/ 关掉流式输出(代价是 TTFB 变高)。见 Next.js streaming-metadata 文档。
第 3 步:SSR 大材小用就静态预渲染
不按请求变化的内容,在 build time 直接预生成静态 HTML。没有运行时服务器,对爬虫完全安全。
// next.config.* / nuxt.config.* / astro.config.*
export default { output: "static" };
Next.js App Router 里,动态路由在你提供 generateStaticParams 时会被预渲染。静态预渲染在 build time 就把标题写进 HTML。
第 4 步:纯 SPA 框架加 build-time 预渲染步骤
如果你没法上 SSR/SSG,那就爬自己的构建产物,给每个路由写出静态 HTML。截至 2026 年 6 月,选一个还在维护的工具:
- Vike(前身是
vite-plugin-ssr,npm 包现在叫vike)—— Vite 应用的预渲染模式,活跃维护中。 prerender-spa-pluginv3 —— 基于 Puppeteer(旧的 v2 用的是已弃用的 PhantomJS),适合 Webpack 构建。presite或prerenderer—— 极简、与框架无关。
注意:曾经流行的 react-snap 截至 2026 年 6 月基本已无人维护——能用,但不推荐用于新项目。把你选的工具接进 postbuild 步骤:
"scripts": {
"build": "vite build",
"postbuild": "vike prerender"
}
第 5 步:客户端重定向替换为 HTTP 301
/old-path 该跳转就在服务端 / 边缘跳,而不是用 JS:
# Netlify _redirects(或 Cloudflare/Vercel 规则,或 Express res.redirect(301, ...))
/old-path /new-path 301
爬虫直接顺着 301 走,索引 /new-path 和它的正确标题。客户端重定向会把 Google 留在 /old-path 上,配着壳子标题。
第 6 步:标题数据不要依赖鉴权
标题依赖鉴权后的数据,就给匿名爬虫准备一个公共兜底:
const title = isAuthed
? `${user.name}'s Dashboard | Acme`
: "Sign In to Acme";
或者把要鉴权的部分挪到正文,<title> 保持公开。不要按 user-agent 分支给爬虫投不同标题——那是 cloaking(见 FAQ)。
第 7 步:在修好的 URL 上请求重新索引
在 Search Console 里对高流量受影响 URL 跑 URL Inspection,点 Request Indexing。成功重抓后,SERP 标题通常 1-2 周内刷新;长尾 URL 可能要一个月甚至更久。
怎么确认已修好
curl任一路由,原始 HTML 里就有路由特定的标题(不只是在 DevTools 里)。- Search Console URL Inspection -> View Tested Page -> HTML 标签里
<title>正确。 site:yourdomain.com搜出来每个结果都有自己的、不同的标题。- 点击率随相关标题在 SERP 出现而回升。
- 可选:用 AI 爬虫 UA 抓一下,比如
curl -A "GPTBot" https://example.com/some-route | grep -i '<title>',确认标题就在原始 HTML 里(这些 bot 不跑 JS)。
长期预防
- 从第一天起,每条路由默认走服务端 metadata API(
generateMetadata、useSeoMeta、<svelte:head>、AngularTitle)。 - 代码评审里禁止把
document.title = "..."当作生产路由唯一的标题来源。 - 加 CI 断言:
curl每条路由返回独特、非占位符的<title>。 - 在 CI 里跑 Lighthouse SEO 或一个无头
curl检查,新路由上线前第一时间发现缺失标题。 - 在组件库文档里写清楚标题来源模式,让新贡献者自动按规矩来。
常见坑
- “Googlebot 能跑 JS 应该没事吧。“——第二波确实能跑,但第二波是延迟的,也不保证每个 URL 都跑;而
GPTBot/ClaudeBot/PerplexityBot则根本不跑 JS。服务端渲染的 metadata 是唯一能让每个爬虫在第一次抓取就拿到标题的路径。 - 在框架级别设了默认标题(
MyApp),忘了在某些路由场景下它会盖掉分页标题。 - React Router SPA 没 SSR 还用
<Helmet>——<Helmet>只在浏览器改document.title。 - 用
router.events.on("routeChangeComplete", updateTitle)——一样只在浏览器跑。 - 想用 Google 旧的 “dynamic rendering” 变通方案(用 Rendertron/Prerender.io 给爬虫单独投一份预渲染副本)。Google 自己的文档现在把它定性为 “a workaround and not a long-term solution”,并引导你改用 SSR、SSG 或 hydration。见 Google dynamic rendering 文档。
FAQ
Q:Googlebot 到底跑不跑 JavaScript?
跑,在第二波,大多数 URL 最终会跑。但首次索引那一遍用的是原始 HTML,只靠 JS 的标题要到第二波才浮现,可能要几天甚至几周(截至 2026 年 6 月)。服务端渲染的标题在第一波就被索引。
Q:我们是纯 SPA 框架。一定要迁到 Next.js / Nuxt 吗?
不用。加一个 build-time 预渲染步骤(Vike、prerender-spa-plugin v3、presite)给每条路由写静态 HTML,不迁框架也能解决标题索引问题。
Q:标题修了之后 SERP 多久更新?
通常 1-2 周。高流量 URL 重抓最快;长尾要一个月甚至更久。关键页面走一下 Request Indexing 加速。
Q:我能给用户保留 “Loading…” 标题,给 Googlebot 投真标题吗?
不行。给爬虫和用户投不同内容属于 cloaking,违反 Google 的垃圾内容政策。给所有人都投真标题。如果加载态是 UX 需要,在 SSR HTML 里就渲染真标题,只在正文里放转圈动画。
Q:ChatGPT 和 Claude 看得到我用 JS 设的标题吗?
看不到。截至 2026 年 6 月,主流 AI 爬虫(GPTBot、ClaudeBot、PerplexityBot)只抓 HTML、不执行客户端 JavaScript,所以只靠 JS 的标题对它们隐形。用 curl -A "GPTBot" <url> 验证——你在那份原始响应里看到的,就是它们索引到的。这是又一个把标题放进原始 HTML 的理由。
Q:我用 Next.js App Router,静态页面 curl 出来标题是对的,动态页面却没标题,为什么?
从 Next.js 15.2 起,动态渲染的路由会在初始响应之后把 metadata 流式输出到 <body>,而不是放进 <head>。会跑 JS 的爬虫(Googlebot)读得到;不跑 JS 的爬虫可能读不到。要么让路由保持可静态预渲染(generateMetadata 里别用 cookies() / headers() / searchParams),把标题烤进 HTML;要么在 next.config.ts 里设 htmlLimitedBots: /.*/,强制对所有爬虫把标题放进 <head>。
Related
- Title Tag and H1 Mismatch Causes Google Rewrites
- Google Rewrote My Title — Fix
- Duplicate Titles on Many Pages
- Meta Description Replaced by Google
- Crawled — Currently Not Indexed
标签: #SEO #排查 #spa #title-tag #rendering