你 ship 一个 React / Vue / SPA。每个路由挂载后用 document.title = "..." 或 <Helmet> 设标题。浏览器里标题栏更新正确。Google 上每个 URL 的 SERP 标题都是同一个 —— “MyApp”、“Loading…”、或服务端初始 HTML 里返回的那个通用值。爬虫没执行设标题的代码,或者执行了但索引的是更新前的 HTML 快照。修法是把正确标题在服务端渲染的 HTML 里就 ship 出去,或者用混合渲染策略。
常见原因
按真实 SPA 上的命中率排序。
1. 服务端给每个路由返回同一个 <title> 壳子
每个 URL 的初始 HTML 都是 <title>MyApp</title>。JS 挂载后替换。Google 索引的是壳子 HTML,永远拿不到分路由的标题。
怎么判断: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。
怎么判断:浏览器 DevTools hydration 之后标题正确;view-source 和 curl 看不到。
4. SSR 流水线在标题 hook 触发之前就部分输出了
streaming SSR 把 <head> 推给链路时数据 fetch 还没结束。刷出去的 HTML 里没标题。
怎么判断:HTML 响应 head 里是 <title></title> 或干脆没 title;浏览器 fetch 完之后才正确。
5. 标题在重定向之后才设置
URL /old-path 通过 JS(不是 HTTP 301)跳到 /new-path。Google 爬 /old-path,拿到跳转前的壳子标题,索引下来。
怎么判断:SERP 显示跳转前 URL 配通用标题;跳转路径返回 HTTP 200 而不是 301。
6. 爬虫渲染了,但标题 hook 依赖 cookie 或鉴权
标题数据要登录后的 API 调用。Googlebot 是匿名的;API 返 401;标题 state 永远不更新。
怎么判断:爬虫模拟器(或 curl 不带 cookie)的网络面板里,标题数据接口返 401。
开始前
- 用
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://example.com/some-route确认症状,检查<title>。 - 决定渲染策略:全 SSR、SSG,还是混合?不同方案打开不同修复路径。
- 看看框架是否已经原生支持服务端 metadata(Next.js
metadata、NuxtuseHead、SvelteKit<svelte:head>)。 - 数一下需要修的不同路由模板数量。经常一个共享 layout 就驱动全部。
需要收集的信息
- 5-10 个代表性 URL 的服务端渲染 HTML。
- 同一批 URL hydration 之后的浏览器 DOM(确认客户端标题是对的)。
- 框架是否支持 SSR / SSG,是否启用了。
- 标题的数据来源(frontmatter、API 调用、路由参数)。
- SERP 索引 URL 和正典 URL 之间是否有跳转。
一步步修
按影响和代价排序。
第 1 步:确认 Googlebot 真正看到的内容
curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1)" \
https://example.com/products/widget \
| grep -oE '<title>[^<]*</title>'
或者 Search Console → URL Inspection → View crawled page,看 Google 用来索引的渲染后 HTML。
第 2 步:标题生成挪到服务端
Next.js App Router:
// app/products/[slug]/page.tsx
export async function generateMetadata({ params }) {
const product = await getProduct(params.slug);
return {
title: `${product.name} | Acme Store`,
description: product.summary,
};
}
Nuxt 3:
useHead({ title: () => `${product.value.name} | Acme Store` });
SvelteKit:
<svelte:head>
<title>{product.name} | Acme Store</title>
</svelte:head>
这些在 SSR 阶段跑,正确标题直接进响应 HTML。
第 3 步:SSR 大材小用就静态预渲染
不按请求变化的内容直接预生成静态 HTML:
// next.config / nuxt.config / astro.config
export default { output: 'static' };
静态预渲染在 build time 把标题写死进 HTML。零运行时成本,对爬虫完全安全。
第 4 步:纯 SPA 框架加 build-time 预渲染步骤
prerender-spa-plugin(Vue / Webpack)、react-snap 这类工具能爬你的 dev server,给每个路由写出静态 HTML。生成的 HTML 已经含有正确标题。
npm install --save-dev react-snap
# add postbuild hook
"scripts": {
"build": "vite build && react-snap"
}
第 5 步:JS 跳转替换为 HTTP 301
/old-path 该跳转就在服务端跳(Netlify _redirects、Cloudflare rules、Express 中间件):
/old-path /new-path 301
爬虫直接顺着 301 走,索引 /new-path 和它的正确标题。
第 6 步:标题数据不要依赖鉴权
标题文本依赖鉴权后的数据,就给 SSR ship 一个公共版本:
const title = isAuthed
? `${user.name}'s Dashboard | Acme`
: `Sign In to Acme`;
或者把要鉴权的部分挪到正文,标题保持公开。
第 7 步:在修好的 URL 上请求重新索引
Search Console → URL Inspection → Request indexing,对高流量受影响 URL 操作。成功重抓后 SERP 标题通常 1-2 周内更新。
验证
curl任一路由,初始 HTML 里有路由特定的标题。- Search Console → URL Inspection 的渲染 HTML 快照里标题正确。
site:yourdomain.com搜出来每个结果都有自己的、不同的标题。- 点击率随相关标题在 SERP 出现而回升。
- 框架的 metadata API 在 SSR 阶段跑,每次部署输出一致。
长期预防
- 从第一天起,每条路由默认走服务端 metadata API(
generateMetadata、useHead、<svelte:head>)。 - 代码评审里禁掉生产路由的
document.title = "..."模式。 - 加 CI 断言:
curl每条路由返回独特、非占位符的<title>。 - 在 CI 里跑 Lighthouse 或 Search Console URL Inspection,新路由上线时第一时间发现缺失标题。
- 在组件库文档里写清楚标题来源模式,让新贡献者自动按规矩来。
常见坑
- “Googlebot 能跑 JS 应该没事吧。“——它确实能跑,但渲染层是延迟的,不是每个 URL 都跑。服务端渲染的 metadata 可靠得多。
- 在框架级别设了默认标题(“MyApp”),忘了在某些路由场景下它会盖掉分页标题。
- React Router SPA 没 SSR 还用
<Helmet>。<Helmet>只在浏览器更新document.title。 - 用
nextRouter.events.on('routeChangeComplete', updateTitle)—— 这一样只在浏览器跑。 - 标题数据接口带鉴权缓存:爬虫拿 401,真人拿 200。让标题 fetch 对公开访问友好。
FAQ
Q:Googlebot 到底跑不跑 JavaScript?
跑 —— 大多数 URL 最终会跑。但首次索引那一遍用的是原始 HTML;只靠 JS 的标题在第二遍才浮现,可能要几天甚至几周。服务端渲染的标题立即被索引。
Q:我们是纯 SPA 框架。要不要迁到 Next.js / Nuxt?
不一定。用 build-time 预渲染(react-snap、vite-plugin-ssr、prerender-spa-plugin)写静态 HTML 就够了。不用迁框架也能解决标题索引问题。
Q:标题修了之后 SERP 多久更新?
通常 1-2 周。高流量 URL 重抓最快;长尾要一个月甚至更久。关键页面走一下 Request indexing 加速。
Q:我能不能给用户保留 “Loading…” 壳子标题,给 Googlebot 投真标题?
不行 —— 给爬虫和用户投不同内容属于 cloaking,违反 Google 指南。给所有人都 ship 真标题。如果 “Loading…” 是 UX 需要,hydration 一开始就替换;保证 SSR HTML 已经是真值。
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