JavaScript 动态设置的标题未被 Google 索引

SPA 首次渲染之后才改 `document.title`,Googlebot 索引的却是原始占位符。SERP 每条都显示 "Loading..." 或站点首页标题。

你 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-sourcecurl 看不到。

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。

标题数据要登录后的 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、Nuxt useHead、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(generateMetadatauseHead<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 已经是真值。

标签: #SEO #排查 #spa #title-tag #rendering