部署 preview URL 被 Google 收录 —— 排查与修复

Vercel/Netlify 的 preview URL 出现在 Google 索引里,多半是自定义域名 preview、可被索引的最新 branch deploy,或 sitemap/canonical 盖过了自动 noindex。这里给出快速修法。

site:vercel.app your-project 一搜,发现 preview URL 被 Google 收录了:your-site-git-feature-x.vercel.appyour-site-abc123.vercel.app,还有一些早忘了的部署哈希。更糟的是某些 preview URL 在品牌词上反而排在你正式域名前面。

最快的修法:先确认出问题的那个 host 是否缺了 X-Robots-Tag: noindexcurl -I <preview-url>),把这个头加回来,再修掉任何指向 preview 的 sitemap/canonical,最后在 Search Console 的 Removals 里提交这些 URL,几小时内(而不是几周)把它们清出索引。下面是细节。

有一点变了、很多人会踩坑:截至 2026 年 6 月,Vercel 和 Netlify 对标准 preview 部署都已经会自动加上 X-Robots-Tag: noindex。所以如果 preview 被收录了,你几乎不会是”忘了加 noindex”那种情况,而是落在几个具体的边缘场景里:非生产分支上的自定义域名、可被索引的最新 branch deploy、旧的生产部署,或者一份与 header 矛盾的 sitemap/canonical。下面几节会帮你对号入座。

你属于哪一类?

现象可能原因跳到
被收录的 host 是 *.vercel.app / *.netlify.appheader 被覆盖掉,或 sitemap/canonical 盖过了它原因 1、4、6
被收录的 host 是自定义子域名(如 staging.your-site.com非生产分支上的自定义域名 —— Vercel 在这种情况下不发 noindex原因 1
只有最新的 branch deploy 被收录Netlify 默认让最新 branch deploy 保持可索引原因 1
隐身窗口打开 preview 直接进、没登录提示Deployment Protection 设成了 None原因 5
被收录的 preview 与公开仓库 PR 一一对应preview 链接被从公开 GitHub 抓走原因 3
几个月前的旧部署仍被收录旧 URL 仍可访问、还在 Google 爬队列里原因 7

常见原因

按”哪条路径泄漏”排序。

1. 出问题的那个 host 上缺了 X-Robots-Tag: noindex

这是核心诊断点。Vercel 和 Netlify 对标准 preview 部署都会自动注入 X-Robots-Tag: noindex,所以泄漏几乎都意味着被收录的那个具体 host 上没有这个头。有三种被官方记录在案的成因:

  • Vercel 把自定义域名挂在非生产分支上。 这是最常见的一种。按 Vercel 官方文档,当你把一个自定义域名(如 staging.your-site.com)分配给非生产分支时,Vercel 不会X-Robots-Tag: noindex。自动保护只覆盖系统生成的 *.vercel.app URL。
  • Netlify 的最新 branch deploy。 Netlify 会给 Deploy Preview、旧的 branch deploy、未发布的生产部署加 noindex,但默认让最新的 branch deploy 保持可索引。如果你长期维护一个分支(比如 staging),它的最新部署就是可爬的。
  • 框架或中间件覆盖了响应头,在平台 header 生效前把平台的 noindex 抹掉了。

如何识别curl -I https://<被收录的host>/ 里没有 x-robots-tag: noindex。一定要对着 Google 收录的那个确切 host 跑,而不是新开一个 preview。

2. preview URL 在公开 Slack/Discord 里被 unfurl

Slack 和 Discord 都会在贴出链接时去抓预览。这一抓如果链接后来出现在任何公开归档里(Discourse 论坛、公开 Slack 导出、GitHub issue),就足以把它推进 Google 爬队列。unfurl 本身不会收录页面,但它制造了 Google 会跟进的入站链接。

如何识别:被收录的 preview URL 能追到 Slack/Discord 的分享记录。在 Google 搜该频道的公开网页归档能找到原始链接。

3. 公开 GitHub 仓库里的 PR 评论暴露 preview 链接

Vercel/Netlify 的 GitHub 集成会自动在 PR 上贴 preview URL 评论。公开仓库这些评论对全世界可见,Google 爬公开 GitHub、跟着链接进到 preview。这只有在 preview 本身缺 noindex(见原因 1)时才会真正导致收录 —— 但它是 Google 最常用来发现这个 URL 的入口。

如何识别:被收录的 preview 与公开仓库的 PR 一一对应,按 PR 号大致顺序排列。

4. 生产代码把 preview URL 写进 sitemap

sitemap 生成脚本用了 process.env.VERCEL_URL(或 Netlify 的 process.env.URL / process.env.DEPLOY_PRIME_URL)而不是写死正式域名,于是 preview 部署的 sitemap 指向自己。sitemap 里的条目是很强的索引信号,在 Google 眼里可能盖过 header。

如何识别curl https://<preview-host>/sitemap.xml 里 URL 开头都是 preview host,不是正式域名。

5. preview 部署的 Deployment Protection 被关掉了

Vercel 新项目默认是 Standard Protectionprod_deployment_urls_and_all_previews),会把每个 preview 挡在 Vercel 登录后面。但如果有人把项目(或团队默认值)设成了 None,preview 就完全公开了。Netlify 的 branch deploy / deploy preview 密码保护则要你手动开才有。

如何识别:开一个隐身窗口贴一个 preview URL —— 直接打开、没有 auth 提示。(开了 Standard Protection 的话,你会看到一个 “Log in to Vercel” 的页面。)

6. canonical 指向 preview 主机而不是正式域名

<link rel="canonical" href="${import.meta.env.SITE}/path"> 其中 SITE 按部署派生的话,preview 部署的 canonical 就会带 preview 域名。Google 读到 preview 上这个自指的 canonical,即使页面有 noindex 头,也可能把 preview 当成 canonical 版本 —— 信号互相打架会扰乱合并。(严格说,一个 noindex 的页面本就不该同时是 canonical 目标;信号混乱正是 Search Console 里”Google 选择了与用户不同的 canonical”的成因。)

如何识别:在 preview 部署上看源码,<link rel="canonical"> 的 href 开头是 preview host,不是正式域名。

7. 旧的 preview/生产 URL 仍可访问且有外链

Vercel 默认永久保留部署 URL(“feature”,做永久链)。几个月前公开贴出的 URL 现在还在 Google 爬队列里、定期回爬。旧的生产 部署 URL 尤其危险,因为它当时在线时根本没有 noindex

如何识别:被收录的 URL 里有几个月前的部署。即使今天修了配置,老的也会持续到被主动清除为止。

开始排查前

  • site:vercel.app your-projectsite:netlify.app your-project(或你的 provider 后缀)搜一下,记下数量和确切的 host。
  • Search Console 里看有没有 “Duplicate without user-selected canonical”、“Duplicate, Google chose different canonical than user” 或 “Crawled - currently not indexed”。
  • 判断被收录的 host 是系统生成的 *.vercel.app、自定义子域名,还是 Netlify 的 branch deploy —— 这决定了对应哪条原因。
  • 确认团队/项目的 Deployment Protection 级别(Vercel:Settings → Deployment Protection)。
  • 确认 preview 构建时 process.env.VERCEL_URL / process.env.URL 解析成什么(每次部署都变)。
  • 确认仓库是公开还是私有,这会显著改变发现面积。

需要收集的信息

  • 抽 5 条被收录的 URL 及其对应的 deployment ID / host。
  • 被收录的那个确切 hostcurl -I 的输出,看 x-robots-tagx-vercel-deployment-url(Vercel)或 x-nf-request-id(Netlify)。
  • canonical URL 生成代码(grep canonicalog:urlimport.meta.env.SITEVERCEL_URL)。
  • sitemap 生成代码(常在 scripts/build-sitemap.mjs 或框架自动生成)。
  • provider 的 Deployment Protection 设置(以及团队级默认值)。
  • Search Console “Pages” 报告按 vercel.appnetlify.app 过滤,统计受影响 URL 数。

分步修复

按”先止血”排序。

步骤 1:在出问题的 host 上恢复 noindex

如果 curl -I 看不到 x-robots-tag,你就是原因 1。平台的自动 header 没覆盖到你这种情况,所以要显式加上。

Vercel —— 自定义域名 preview 需要在 vercel.json 里按非生产 host 加规则。Vercel 文档建议优先用框架来发头,vercel.json 是兜底方案:

{
  "headers": [
    {
      "source": "/(.*)",
      "has": [
        { "type": "host", "value": "(?!your-site\\.com$).*" }
      ],
      "headers": [
        { "key": "X-Robots-Tag", "value": "noindex, nofollow" }
      ]
    }
  ]
}

Netlify —— 在所有非生产 context 上强制 noindex(这会覆盖 Netlify 让最新 branch deploy 保持可索引的默认行为):

[[headers]]
  for = "/*"
  [headers.values]
    X-Robots-Tag = "noindex, nofollow"

[context.production]
  [context.production.environment]
    # 生产保持不带这个头 —— 只通过下面的规则区分

因为全局 [[headers]] 块对所有环境生效,要么用构建时生成的 _headers 文件按分支细分规则,要么靠 process.env.CONTEXTproduction / deploy-preview / branch-deploy)来判断,确保生产保持可索引。

框架层(任意 host 通用,以 Astro 为例):

---
const host = Astro.url.hostname;
const isProd = host === "your-site.com";
---
{!isProd && <meta name="robots" content="noindex, nofollow" />}

用生产 host 做白名单(而不是用 preview host 做黑名单)更安全 —— 新出现的 preview 后缀漏不过去。

这一步先止血,新的爬不再进入索引。已经在索引里的还要靠步骤 4 主动清掉。

步骤 2:canonical 永远指向正式主机

写死或在环境里固定,让 preview 永远不会自指:

// src/lib/site.ts
export const SITE = "https://your-site.com"; // 永远不要从 VERCEL_URL 派生
<link rel="canonical" href={new URL(Astro.url.pathname, SITE).toString()} />

这样即使在 preview 部署上,canonical 也指向正式域名,Google 会把排名信号合并过去,不再把 preview 当成独立页。

步骤 3:锁定 preview 部署访问

Vercel:Project → Settings → Deployment Protection → 设成 Standard Protection(或 All Deployments),让 preview 需要 Vercel 登录。再确认团队级默认值(Team Settings → Deployment Protection)不是 None,否则会悄悄覆盖新项目。

Netlify:Site → Site configuration → Visitor access → 给 branch deploy 和 deploy preview 开 Password protection

开了保护之后,Googlebot 撞上登录墙、根本爬不到 preview —— 这比 noindex 更彻底,因为 bot 连页面正文都看不到。

步骤 4:把已被收录的 URL 主动提交移除

针对已经在 Google 索引里的:

  1. Search Console → RemovalsNew RequestRemove all URLs with this prefixhttps://your-site-abc123.vercel.app/
  2. 提交。几小时内生效,但是临时的、大约 6 个月(按 Google Removals 文档)。它只是隐藏 URL,不会阻止爬取。
  3. 想永久清,配合步骤 1 的 noindex 头,让 Google 下次回爬时把 URL 丢掉,或者让那些已废弃的 host 返回 404/410

只有这条路径能快速清除已收录 preview,等自然回爬再掉出来可能每条要 1-4 周。长尾清理流程见 old deployment url in search

步骤 5:审 sitemap 是否泄漏

在一次 preview 构建上跑:

npm run build
grep -E "vercel\.app|netlify\.app|deploy-preview" dist/sitemap*.xml

有命中就说明生成器用了按部署变化的主机。换成写死的 SITE

import { SITE } from "./site";
const urls = posts.map((p) => `${SITE}${p.url}`);

一个不输出 sitemap(或只列正式域名 URL)的 preview,就少了一个本可盖过 noindex 的强索引信号。

步骤 6:关掉公开 GitHub PR 上的 preview 评论

公开仓库里,自动贴出的 preview 链接是主要的发现路径:

Vercel → Project → Settings → Git → 关掉 “Comments on Pull Requests”

Netlify → Site configuration → Build & deploy → Deploy notifications → 移除 GitHub PR 评论集成。

这阻止 Google 通过 GitHub 抓新的 preview URL。历史 PR 评论仍在 Google 记忆里,老的 URL 仍需走步骤 4。

步骤 7:在 preview 上提供独立的 robots.txt

兜底一层。按 host 在非生产环境上 disallow:

// src/pages/robots.txt.ts
export const GET = ({ request }: { request: Request }) => {
  const url = new URL(request.url);
  const isProd = url.hostname === "your-site.com";
  const body = isProd
    ? "User-agent: *\nAllow: /\nSitemap: https://your-site.com/sitemap.xml\n"
    : "User-agent: *\nDisallow: /\n";
  return new Response(body, { headers: { "content-type": "text/plain" } });
};

注意:步骤 1 的 X-Robots-Tag 头也要保留。robots.txt 只能阻止爬取,并不能把 Google 已经知道的 URL 清出索引,而且一个被 Disallow 但有外链的页面仍可能以裸 URL 形式出现在结果里。真正能去索引的是 header。相关排查见 robots txt not working

如何确认已修好

  • curl -I https://<被收录的host>/ 返回 x-robots-tag: noindex, nofollow
  • curl https://<preview-host>/sitemap.xml 返回 404,或里面只剩正式域名 URL。
  • preview 部署的源码里能看到 <meta name="robots" content="noindex, nofollow">,且 canonical 指向正式域名。
  • 隐身窗口打开 preview URL 提示 Vercel 登录 / 密码(前提是已开 Deployment Protection)。
  • Search Console → Removals 里提交的 URL 标记 Approved
  • 大约 1-2 周后,site:vercel.app your-project 数量下降(Removals 立即隐藏,永久去索引要等下次回爬)。

长期预防

  • 把非生产 host 的 noindex 写进 vercel.json / netlify.toml 并入仓库,按生产 host 白名单来判断 —— 不依赖只在运行时存在的开关。
  • canonical 始终来自单一写死的 SITE 常量,绝不派生自 VERCEL_URL 这类运行时环境变量。
  • 所有非生产部署的 Deployment Protection 保持 Standard(或更严),并锁死团队级默认值,让新项目自动继承。
  • CI 加一道检查:对 preview URL 跑 curl -I,缺 x-robots-tag: noindex 直接 fail。
  • Search Console 只 claim 正式域名,绝不加 *.vercel.app 属性(那等于告诉 Google 多爬这些 host)。
  • 每季度搜一次 site:vercel.app your-project;一个 preview 被收录就足以分流品牌词。

常见坑

  • 以为是自己”忘了加 noindex 头”,其实 Vercel/Netlify 都会自动加 —— 真正的缺口通常是自定义域名 preview,或一份与之矛盾的 sitemap/canonical。
  • 只在 <meta> 里加 noindex 而不在响应头里加 —— Google 两者都认,但 Removals 请求 + header 才是快速赶出的组合。
  • 只靠 robots.txt —— 对已知 URL,Google 不会因为 Disallow 就去索引;能去索引的是 header。
  • preview 后缀做黑名单而不是用 生产 host 做白名单 —— 新后缀会漏过去。改用生产白名单。
  • 重构时把 noindex 加成全局、一不小心连生产也加上了 —— 改完务必在生产再测一次。
  • 忘了 PR 合并后 preview URL 还能活几个月,因为部署 URL 不会被自动清。见 duplicate domain versions indexed

常见问答

Q:我根本没碰过 header,既然 Vercel 会自动加 noindex,我的 preview 怎么还被收录了?

因为自动 header 有几个被官方记录的缺口。Vercel 在自定义域名挂到非生产分支时不会X-Robots-Tag: noindex,Netlify 也让最新的 branch deploy 保持可索引。对着 Google 收录的那个确切 host 跑 curl -I,如果没有这个头,你就落在这些缺口里,需要走步骤 1。

Q:已被收录的 preview URL 多久能从 Google 掉出来?

Search Console 的 Removals 几小时内隐藏(临时,约 6 个月)。永久去索引要靠 noindex 头 + Google 下次回爬 —— 每条通常 1-4 周,低流量老 URL 更久。

Q:要不要把 preview URL 301 重定向到正式域名?

对你能控制的自定义域名 staging host,要做 —— 301 到正式对应路径能传递权重、消除重复页。对你加不了按路径 redirect 的系统生成 *.vercel.app / *.netlify.app host,就靠 noindex + Removals。

Q:preview 加 noindex 会不会影响平台自己的部署检查?

不会。Vercel 和 Netlify 的部署检查看的是 HTTP 状态码(200),不看 robots 指令。部署 bot 会忽略 X-Robots-Tag

Q:自定义分支别名(比如 staging.your-site.com)也泄漏了,同一套修法?

是的,而且这是 2026 年最常见的一种。自定义域名挂在非生产分支上,正是 Vercel 不加 noindex 的场景。按 preview 处理:显式 noindex 头 + Deployment Protection,或者 301 到正式域名。

标签: #排查 #SEO #preview #Vercel #netlify