Next.js 的 sitemap 与 robots.txt:App Router 标准写法(2026)

sitemap 和 robots.txt 是否正确,直接决定 Google 索不索引你的 Next.js 站。本文给出两者的 App Router 写法,已对照 Next.js 16 验证。

Sitemap 和 robots.txt 是无聊的基础设施,直到 Google 报”已发现,尚未索引”——原因是你的 sitemap 漏了一半页面;或者某次 staging 部署残留的一行 Disallow: / 悄悄把整站从索引里抹掉。Next.js App Router 给了你两种干净、带类型的写法来生成这两个文件。每个文件挑一种,吃透它。

一句话总结

  • 100 页以内的纯静态站:手写一份 public/robots.txt 加一份生成的 sitemap 就够。只要页面来自内容集合或数据库,就用 App Router 的 app/robots.tsapp/sitemap.ts,让文件每次部署自动重建。
  • 单个 sitemap 上限是 50,000 条 URL / 50 MB(Google 的硬限制)。Next.js 不会自动拆分,超过就得用 generateSitemaps()
  • robots.txt 是抓取偏好,不是访问控制。守规矩的爬虫会遵守,但它拦不住任何人去拉一个 URL。
  • 截至 2026 年 6 月,已在 Next.js 16.2(App Router)上验证。

静态还是动态:怎么选

场景robotssitemap
营销站,固定 <100 页public/robots.txtapp/sitemap.ts 或手写 public/sitemap.xml
内容 / MDX 站,页面来自集合app/robots.tsapp/sitemap.ts 读同一份集合
超过 50,000 条 URLapp/robots.tsapp/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-SearchBotChatGPT-User(OpenAI)、Claude-SearchBotClaude-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];
}

支持的条目字段有 urllastModifiedchangeFrequencypriorityalternates.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 提交并监控

  1. Indexing → Sitemaps 提交 https://yourdomain.com/sitemap.xml。状态 1–2 天内从 “Pending” 变 “Success”。
  2. 第一个月每周看一次 Pages。覆盖率应该从几条 URL 爬到全站大部分。
  3. 如果停滞,把某条卡住的 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 搜索机器人、只屏蔽训练机器人吗?: 能。屏蔽 GPTBotClaudeBotGoogle-ExtendedCCBot 以退出模型训练,但放行 OAI-SearchBotClaude-SearchBotChatGPT-UserPerplexityBot,这样你在 AI 搜索结果里仍可被引用。
  • 什么时候才用 generateSitemaps(): 单文件超过 50,000 条 URL(或 50 MB)时。在那之下,单个 app/sitemap.ts 更简单,完全够用。

相关阅读

标签: #独立开发 #Next.js #SEO #Technical SEO #robots.txt #收录