Sitemap 和 robots.txt 是无聊的基础设施,直到 Google 报”已发现,尚未索引”——原因是你的 sitemap 漏了一半页面;或者某次 staging 部署残留的一行 Disallow: / 悄悄把整站从索引里抹掉。Next.js App Router 给了你两种干净、带类型的写法来生成这两个文件。每个文件挑一种,吃透它。
一句话总结
- 100 页以内的纯静态站:手写一份
public/robots.txt加一份生成的 sitemap 就够。只要页面来自内容集合或数据库,就用 App Router 的app/robots.ts和app/sitemap.ts,让文件每次部署自动重建。 - 单个 sitemap 上限是 50,000 条 URL / 50 MB(Google 的硬限制)。Next.js 不会自动拆分,超过就得用
generateSitemaps()。 - robots.txt 是抓取偏好,不是访问控制。守规矩的爬虫会遵守,但它拦不住任何人去拉一个 URL。
- 截至 2026 年 6 月,已在 Next.js 16.2(App Router)上验证。
静态还是动态:怎么选
| 场景 | robots | sitemap |
|---|---|---|
| 营销站,固定 <100 页 | public/robots.txt | app/sitemap.ts 或手写 public/sitemap.xml |
| 内容 / MDX 站,页面来自集合 | app/robots.ts | app/sitemap.ts 读同一份集合 |
| 超过 50,000 条 URL | app/robots.ts | app/sitemap.ts + generateSitemaps() |
| 多租户 / 多语言分别规则 | app/robots.ts(动态) | app/sitemap.ts(动态) |
经验法则:只要文件内容会随你发布内容而变,就用代码生成。手写 sitemap 在你第一次忘记改它的那天就开始烂掉。
哪些症状说明你真出问题了
- Next.js 站上线两周,Google 索引的文章不到 30%。
- Search Console → Sitemaps 显示 “Couldn’t fetch”、“Sitemap could not be read” 或 “0 discovered URLs”。
- 部署后的
robots.txt里有Disallow: /。这屏蔽一切,是最常见的自残式 SEO 事故。 - 你的 sitemap 返回的是
Content-Type: text/html而不是application/xml,于是 Google 默默拒绝,连个明确报错都没有。
静态 robots.txt
规则固定的话,丢一份纯文本到 public/robots.txt,Next.js 会原样放在站根:
User-agent: *
Allow: /
Disallow: /api/
Disallow: /preview/
Disallow: /drafts/
Sitemap: https://yourdomain.com/sitemap.xml
不要屏蔽 /_next/。现在的 Googlebot 会渲染页面,需要你的 JS 和 CSS 文件,屏蔽 /_next/ 可能让渲染坏掉、伤索引。只 disallow 那些确实永远不该被抓的路径。
动态 app/robots.ts
App Router 的标准写法:默认导出一个函数,返回带类型的 MetadataRoute.Robots 对象:
// app/robots.ts
import type { MetadataRoute } from 'next';
const SITE = 'https://yourdomain.com';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/api/', '/drafts/', '/preview/'] },
],
sitemap: `${SITE}/sitemap.xml`,
host: SITE,
};
}
sitemap 也接受字符串数组,适合你放一个 sitemap 索引加若干子 sitemap 的情况。host 字段在 Next.js 16 里依然有效,向遵守它的爬虫表明你的规范主机名。
屏蔽 AI 爬虫:先搞清你在屏蔽什么
AI 机器人分两类,它们是两个不同的开关(截至 2026 年 6 月):
- 训练爬虫抓内容去训练模型:
GPTBot(OpenAI)、ClaudeBot(Anthropic)、Google-Extended(Gemini 训练)、CCBot(Common Crawl)、Meta-ExternalAgent。 - 搜索 / RAG 爬虫在回答时实时拉取页面,好在 AI 搜索结果里引用你:
OAI-SearchBot和ChatGPT-User(OpenAI)、Claude-SearchBot和Claude-User(Anthropic)、PerplexityBot。
对一个要变现的内容站,通常的姿态是:屏蔽训练机器人(它们拿走你的内容、不给回报),但保留搜索机器人(它们能带来引荐流量)。一刀切全屏,会把你从 AI 回答的引用里也一起删掉。
// app/robots.ts —— 屏蔽训练爬虫,保留 AI 搜索引用
import type { MetadataRoute } from 'next';
const SITE = 'https://yourdomain.com';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/api/', '/drafts/', '/preview/'] },
{
userAgent: ['GPTBot', 'ClaudeBot', 'Google-Extended', 'CCBot', 'Meta-ExternalAgent'],
disallow: '/',
},
],
sitemap: `${SITE}/sitemap.xml`,
host: SITE,
};
}
记住 robots.txt 是自愿遵守的。Cloudflare 在 2025 年的数据显示,AI 训练抓取已经超过其余 AI 机器人活动的总和,而且不是每个运营方都守规矩。要硬拦,得在 CDN 或 WAF 层做,别指望 robots.txt。
动态 app/sitemap.ts
从页面渲染用的同一份内容源生成 sitemap,它就永远不会跑偏。双语 MDX 站示例:
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllArticles } from '@/lib/content';
const SITE = 'https://yourdomain.com';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await getAllArticles();
const staticPaths: MetadataRoute.Sitemap = [
{ url: `${SITE}/`, changeFrequency: 'daily', priority: 1.0 },
{ url: `${SITE}/about/`, changeFrequency: 'monthly', priority: 0.5 },
];
const articlePaths: MetadataRoute.Sitemap = articles.map((a) => ({
url: `${SITE}/en/articles/${a.slug}/`,
lastModified: a.updatedAt ?? a.publishedAt,
changeFrequency: 'weekly',
priority: 0.8,
alternates: {
languages: {
en: `${SITE}/en/articles/${a.slug}/`,
zh: `${SITE}/zh/articles/${a.slug}/`,
'x-default': `${SITE}/en/articles/${a.slug}/`,
},
},
}));
return [...staticPaths, ...articlePaths];
}
支持的条目字段有 url、lastModified、changeFrequency、priority、alternates.languages,以及给媒体 sitemap 用的 images / videos。Next 会把每个 alternates.languages 映射输出成 <xhtml:link rel="alternate" hreflang="..."> 标签:
<url>
<loc>https://yourdomain.com/en/articles/foo/</loc>
<lastmod>2026-05-22T00:00:00.000Z</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://yourdomain.com/en/articles/foo/" />
<xhtml:link rel="alternate" hreflang="zh" href="https://yourdomain.com/zh/articles/foo/" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://yourdomain.com/en/articles/foo/" />
</url>
超过 50,000 条 URL:generateSitemaps()
Google 把单个 sitemap 限制在 50,000 条 URL 或 50 MB(未压缩)以内。Next.js 不会替你拆,你得在默认函数旁边再导出一个 generateSitemaps()。注意 v16 的变化:id 参数现在是一个会 resolve 成字符串的 Promise,所以必须 await。
// app/articles/sitemap.ts
import type { MetadataRoute } from 'next';
import { countArticles, getArticlePage } from '@/lib/content';
const SITE = 'https://yourdomain.com';
const PER_FILE = 50_000;
export async function generateSitemaps() {
const total = await countArticles();
const count = Math.ceil(total / PER_FILE);
return Array.from({ length: count }, (_, id) => ({ id }));
}
export default async function sitemap(props: {
id: Promise<string>;
}): Promise<MetadataRoute.Sitemap> {
const id = Number(await props.id);
const articles = await getArticlePage(id * PER_FILE, PER_FILE);
return articles.map((a) => ({
url: `${SITE}/en/articles/${a.slug}/`,
lastModified: a.updatedAt ?? a.publishedAt,
}));
}
这些分片会出现在 /articles/sitemap/0.xml、/articles/sitemap/1.xml,以此类推。从 robots.ts 里引用每个分片(或一个 sitemap 索引)。
每次部署后验证
对线上 URL(不是 localhost)跑这三条检查:
curl -sI https://yourdomain.com/robots.txt | grep -i content-type
# content-type: text/plain; charset=utf-8
curl -sI https://yourdomain.com/sitemap.xml | grep -i content-type
# content-type: application/xml; charset=utf-8
curl -s https://yourdomain.com/sitemap.xml | grep -c '<loc>'
# 大致等于文章总数 + 静态页
如果 <loc> 数量远低于你的页面数,多半是 getAllArticles() 把草稿或某个你本想包含的语言过滤掉了。
在 Search Console 提交并监控
- 在 Indexing → Sitemaps 提交
https://yourdomain.com/sitemap.xml。状态 1–2 天内从 “Pending” 变 “Success”。 - 第一个月每周看一次 Pages。覆盖率应该从几条 URL 爬到全站大部分。
- 如果停滞,把某条卡住的 URL 粘进 URL Inspection 工具,读 Google 给的确切原因(“Discovered – currently not indexed”、“Crawled – currently not indexed”、“Blocked by robots.txt” 等)。
提交流程的详细走法见在 Search Console 提交 sitemap。
容易踩的坑
- staging 或 preview 部署后,robots.txt 里残留
Disallow: /。经典的整站脱索引。 - 屏蔽
/_next/,可能让 Googlebot 加载不到 CSS/JS、渲染不出页面。 - route handler 写错,sitemap 返回 HTML 而非 XML。Google 直接拒绝,且无有用报错。
- sitemap 里的 URL 和 canonical 标签结尾斜杠不一致。Google 把
/foo和/foo/当两个 URL。 - 把分页或过滤 URL(
?page=2、faceted 过滤)也宣传进 sitemap。这些该noindex,不该推。 - 只放一种语言的 URL。
/en/foo和/zh/foo是两个 URL,都该带 hreflang 替代进 sitemap。 - 每次 build 把每条 URL 的
lastModified都伪造成”现在”。Google 会学会忽略它。只从真实内容时间戳取值。
FAQ
- 内链做得好还需要 sitemap 吗?: 老站内链扎实,Google 通常不靠 sitemap 也能找全。但全新、外链很少的站,提交 sitemap 对首次发现有明显加速。
- sitemap 多久更新一次?: 每次发布。
app/sitemap.ts读 content collection 的话,每次 build 自动重生成,无需手动。 - 要不要带
lastModified?: 要,前提是它反映真实的更新时间。它帮 Google 优先重爬变了的页面。别每条都设成当前日期,否则就成了 Google 会忽略的噪声。 - 能保留 AI 搜索机器人、只屏蔽训练机器人吗?: 能。屏蔽
GPTBot、ClaudeBot、Google-Extended、CCBot以退出模型训练,但放行OAI-SearchBot、Claude-SearchBot、ChatGPT-User、PerplexityBot,这样你在 AI 搜索结果里仍可被引用。 - 什么时候才用
generateSitemaps()?: 单文件超过 50,000 条 URL(或 50 MB)时。在那之下,单个app/sitemap.ts更简单,完全够用。