🗺️

20,000ページのNext.js sitemapをVercelのビルド制限をぐぐり抜けて生成する

に公開

TL;DR

  • 20,000+件の記事を持つ Next.js (App Router) サイトの sitemap.xml を ISR 化したらビルドが落ちた
  • 原因は Vercel ビルド時の prerender が 60 秒で打ち切られること(Failed to build /sitemap.xml/route ... took more than 60 seconds
  • 解決策は Next.js 16 公式の generateSitemaps で sitemap を分割し、各 chunk を独立に prerender させること
  • chunk サイズは 5,000 → 2,000 まで下げ、内部 fetch は concurrency=3 で並列化することで安定
  • sitemap-index は自動生成されないので、robots.ts に全 chunk URL を列挙してクロール導線を確保

背景・課題

運営しているコンテンツメディア(記事 20,000 件超 + 職業ハブ 700 件)で、sitemap.xml の運用方針を見直したかった。

それまでは src/app/sitemap.tsdynamic = "force-dynamic" にしていて、クローラーがアクセスするたびに microCMS から全件取得していた。Google bot だけならいいが、社内クロールや bot まで含めると無視できない頻度で API を叩く。microCMS API 負荷とレスポンス時間の両面で改善したい。

要件:

  • microCMS のコンテンツ更新を Webhook 即時反映したい(数分〜1時間遅延は不可)
  • Vercel KV や Redis 等の 追加インフラは導入したくない
  • Vercel プランは Pro

最初の方針: dynamic = "force-dynamic" を削除して revalidate = 86400(ISR)にする。これで Full Route Cache に乗り、microCMS Webhook で revalidatePath("/sitemap.xml") を叩けば即時反映できる、というシンプルな置き換え。

失敗 1: 単一ファイル ISR が Vercel ビルドで死ぬ

実装した変更はこれだけ。

src/app/sitemap.ts
- export const dynamic = "force-dynamic";
+ export const revalidate = 86400;
src/app/api/revalidate/route.ts
  revalidatePath("/[...params]", "page");
+ revalidatePath("/sitemap.xml");

ローカルの pnpm lintpnpm build も通った(ように見えた)ので Vercel に流す。すると preview build がこう言って落ちた:

Failed to build /sitemap.xml/route: /sitemap.xml (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /sitemap.xml/route: /sitemap.xml (attempt 2 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /sitemap.xml/route: /sitemap.xml after 3 attempts.
Export encountered an error on /sitemap.xml/route: /sitemap.xml, exiting the build.

/sitemap.xml の prerender が 60 秒タイムアウトを 3 回連続で踏んで build が exit した。リトライしても、20,000 件の microCMS 全件取得が 60 秒に収まらないからずっと落ちる。

よくある誤解: 「ISR は初回アクセスで生成されるんじゃないの?」

ここは検証中に何度も自問自答した。Next.js 公式ドキュメントを読むと:

  • 動的ルート ([slug]) で generateStaticParams を空配列にする → そのパスはビルド時 prerender されず、初回アクセスで生成される
  • 静的ルート(params なし) + revalidate = N必ずビルド時 prerender されるrevalidate は「期限が来たら再生成」のスケジュールでしかない

sitemap.ts は実質「単一の静的ルート」なので、後者に該当する。ISR にしても build-time prerender は不可避で、ここで 60 秒に当たっていた。

ISR の本来の利点は「ビルド後にデプロイなしで再生成できる」こと。「初回アクセスで遅延生成される」は動的ルートの dynamicParams=true 経由の挙動と混同しやすい。

(参考: Next.js: Incremental Static Regeneration)

採用しなかった代替案

選択肢を一通り並べてから採用案を決めたので、見送ったものも記録しておく。

内容 なぜ見送ったか
staticPageGenerationTimeout 延長 next.config.ts で 60s → 300s 等に伸ばす 件数増加で再度壁に当たる。サスティナブルでない
force-dynamic のまま据え置き 元の状態 クローラー毎に microCMS 全件取得 = API 負荷の元凶
unstable_cache / 'use cache' で全件メタを 1 つにキャッシュ Webhook で全件 fetch → キャッシュに保存 → sitemap は読むだけ 「初回 cache 生成」のどこかで 60s+ の fetch が必要。unstable_cache は外部から set できないので、ビルド時 or 初回ランタイムで重い fetch が走る
Vercel KV / Upstash Redis Webhook → 永続キャッシュ書込 → sitemap は読むだけ 追加インフラ導入が要件外
Cron / Deploy Hook 定期再ビルド or Cron で KV 更新 「Webhook 即時反映」要件と矛盾

結論: 「追加インフラなし + Webhook 即時反映 + サスティナブル」を全て満たすには、generateSitemaps で分割する以外なかった

採用案: generateSitemaps による分割

Next.js 16 公式の generateSitemaps は、巨大 sitemap を複数 URL に分割するための機能。本来の用途は Google の 50,000 URL 上限対策だが、副次的に 各 chunk が独立した prerender ジョブになるため、各々が個別に 60 秒タイムアウトを与えられる。

export async function generateSitemaps() {
  return [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }];
}

export default async function sitemap(props: {
  id: Promise<string>; // ← Next.js 16 で Promise<string> に変わった
}): Promise<MetadataRoute.Sitemap> {
  const id = Number(await props.id);
  // id ごとの URL 集合を返す
}

各 chunk は /sitemap/<id>.xml で配信される。/sitemap.xml 自体は使われなくなる(404)。

設計の鍵となった 4 つの判断

1. id は MAX_SITEMAP_CHUNKS まで固定列挙

generateSitemaps()ビルド時にしか評価されない。runtime で記事が増えても、デプロイし直すまで chunk 数は変わらない。

最初は「現在件数から chunk 数を計算する」案にしていたが、Codex のセルフレビューで指摘されて気づいた:

デプロイ後に記事数が増えて新 chunk が必要になっても、generateSitemaps() は再評価されない。一方 robots.ts や sitemap-index で「現在件数から id を計算」していると、実在しない /sitemap/N.xml を案内する不整合が起きる。

これを避けるため、MAX_SITEMAP_CHUNKS まで毎回固定列挙する設計にした。中身がない id は valid な空 <urlset> を返す。

src/lib/constants/sitemap.ts
export const ARTICLES_PER_SITEMAP = 2000;
export const MAX_SITEMAP_CHUNKS = 12; // capacity = 24,000 件
src/app/sitemap.ts
export async function generateSitemaps() {
  const total = await getArticlesTotalCount();
  const required = Math.ceil(total / ARTICLES_PER_SITEMAP);
  if (required > MAX_SITEMAP_CHUNKS) {
    throw new Error(
      `[sitemap] articles=${total} requires ${required} chunks, ` +
      `but MAX_SITEMAP_CHUNKS=${MAX_SITEMAP_CHUNKS}. Increase MAX and redeploy.`
    );
  }
  return [
    { id: 0 },
    ...Array.from({ length: MAX_SITEMAP_CHUNKS }, (_, i) => ({ id: i + 1 })),
  ];
}

容量超過時は build を throw で失敗させる。事故より明示的に気づくほうが安全という判断。

2. sitemap-index は作らず robots.ts に全 chunk URL を列挙

generateSitemapssitemap-index を自動生成しない。Google にどう案内するかは別途仕掛けが要る。

最初は「/sitemap-index.xml を route handler で実装する」案にしていたが、よく考えると不要だった:

  • chunk 数は固定(MAX_SITEMAP_CHUNKS = 12 → 13 本)
  • MetadataRoute.Robotssitemap フィールドは string | string[] 両対応
  • robots.txt に列挙すれば GSC は自動検出してくれる
src/app/robots.ts
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: "*", allow: "/", disallow: ["/api/", "/demo/", "/search/"] }],
    sitemap: Array.from(
      { length: MAX_SITEMAP_CHUNKS + 1 }, // id=0..MAX
      (_, i) => `${SITE_BASE_URL}/sitemap/${i}.xml`
    ),
  };
}

このシンプル化のおかげで、ビルド時 id 集合 = robots.txt が案内する URL 集合が常に一致するという嬉しい副作用も得られた。

3. Webhook では全 chunk + 固定 MAX で revalidate

microCMS Webhook を受ける /api/revalidate は、全 chunk を revalidatePath する。現在件数ではなく定数 MAX_SITEMAP_CHUNKS までループするのがポイント。

src/app/api/revalidate/route.ts
revalidatePath("/[...params]", "page");
for (let i = 0; i <= MAX_SITEMAP_CHUNKS; i++) {
  revalidatePath(`/sitemap/${i}.xml`);
}

理由は chunk 数減少時のため。記事激減で 5 chunk → 3 chunk に減ったとき、現在件数で 0..3 だけ無効化すると、/sitemap/4.xml /sitemap/5.xml に古いキャッシュが残る。固定 MAX なら、不要になった chunk も毎回無効化対象に入って空 sitemap として再生成される。

4. microCMS の 100件/page 制限とページング

公式の generateSitemaps サンプルはこんな感じ:

const products = await getProducts(
  `SELECT id, date FROM products WHERE id BETWEEN ${start} AND ${end}`,
);

50,000 件を 1 クエリで一気に取れる前提。SQL なら自然だが、microCMS REST API は 1 リクエスト最大 100 件。chunk 単位の取得には必ずページングが要る。

最初は単純な直列ループで実装した:

for (
  let offset = startOffset;
  offset < startOffset + chunkSize;
  offset += 100
) {
  const response = await microcms.get({
    endpoint: "articles",
    queries: { ...queries, limit: 100, offset },
  });
  results.push(...response.contents);
}

ARTICLES_PER_SITEMAP = 5000 だと 50 calls の直列。これがまた 60 秒に当たって死んだ(後述)。

並列化したいが、Next.js のビルドワーカー(9 並列)が他ページも fetch しているため、microCMS 側も結構混雑している。並列度を上げすぎるとレート制限で 500/429 が返ってくる。

最終的に concurrency=3 のバッチ並列にした:

src/lib/microcms.ts
export async function getArticlesChunk(
  startOffset: number,
  totalCount: number,
  queries?: Omit<MicroCMSQueries, "limit" | "offset" | "ids">
): Promise<Article[]> {
  const PAGE_SIZE = 100;
  const CONCURRENCY = 3;

  const pages: { offset: number; limit: number }[] = [];
  for (let offset = startOffset; offset < startOffset + totalCount; offset += PAGE_SIZE) {
    const remaining = startOffset + totalCount - offset;
    pages.push({ offset, limit: Math.min(PAGE_SIZE, remaining) });
  }

  const collected: Article[][] = [];
  for (let i = 0; i < pages.length; i += CONCURRENCY) {
    const batch = pages.slice(i, i + CONCURRENCY);
    const responses = await Promise.all(
      batch.map(({ offset, limit }) =>
        microcms.get<MicroCMSListResponse<Article>>({
          endpoint: "articles",
          queries: { ...queries, limit, offset },
        })
      )
    );
    let shortCircuit = false;
    for (let j = 0; j < responses.length; j++) {
      const r = responses[j];
      const expected = batch[j];
      if (!r || !expected) continue;
      collected.push(r.contents);
      if (r.contents.length < expected.limit) shortCircuit = true;
    }
    if (shortCircuit) break;
  }
  return collected.flat();
}

ページ間の境界で重複・抜けが起きないよう、呼び出し側で orders: "publishedAt,id" という 一意キーを含む安定ソートを渡している。publishedAt だけだと同時刻の記事で順序がブレうる。

失敗 2: chunk サイズ 5,000 でもまだ落ちた

最初は ARTICLES_PER_SITEMAP = 5000(4 chunk で 20,000 をカバー)でいけると見込んだ。が、Vercel preview build がまた落ちた:

Failed to build /sitemap/[__metadata_id__]/route: /sitemap/1.xml (attempt 1 of 3) because it took more than 60 seconds.
Failed to build /sitemap/[__metadata_id__]/route: /sitemap/2.xml ... after 3 attempts.
Error: fetch API response status: 500

5,000 件 = 50 calls を直列で叩くと、microCMS 応答 + リトライで 60 秒に届いてしまう。500 エラーも見えていて、Next.js の 9 ワーカー並列で他ページも fetch しているため microCMS 側が混んでいた。

修正:

項目 変更前 変更後
ARTICLES_PER_SITEMAP 5,000 2,000
MAX_SITEMAP_CHUNKS 8 12(capacity 24,000)
getArticlesChunk 内部 fetch 直列 concurrency=3 で並列

理屈:

  • 直列 50 calls → 直列 20 calls + 並列 3 = 実質 ~7 batches
  • microCMS 応答 ~1s / call で 1 chunk 10〜20 秒
  • ワーカー 9 並列 × concurrency 3 = 同時 27 fetch、microCMS の通常レート制限(60 req/s 程度)の範囲内

これで Vercel preview build 完走。全 check が SUCCESS

落ち穂拾い

props.id の型は Promise<string>

Next.js 15 の docs は id: number だが、Next.js 16 では id: Promise<string> に変わった。generateSitemaps(){ id: 0 } と数値で返しても、内部で文字列化されて Promise でラップされて渡る。

export default async function sitemap(props: {
  id: Promise<string>;
}): Promise<MetadataRoute.Sitemap> {
  const id = Number(await props.id);
  // ...
}

cache: "no-store" を fetch に付けない

最初 microCMS fetch に customRequestInit: { cache: "no-store" } を仕込んでいたが、Next.js 16 では no-store がルートを動的化することがあり、ISR と矛盾する。何も指定せず Full Route Cache + Data Cache の二重キャッシュに任せるのが正解。

fields を絞っているので 2 MB 上限警告 (items over 2MB can not be cached) もまず出ないし、出ても warning で build は止まらない。

/sitemap.xml URL は 404

generateSitemaps を入れた瞬間、/sitemap.xml という URL は Next.js が処理しなくなり 404 を返すようになる。GSC では旧 URL を削除して、新 chunk URL を登録(または robots.txt 自動検出)に切り替える運用作業が必要。

学び

  • ISR は build time に prerender が走る。「初回アクセスで生成」と勘違いしやすい
  • 巨大 sitemap を Vercel に乗せる現実解は generateSitemaps 分割。本来の用途は 50K 上限対策だが、60 秒制限の壁にも効く
  • chunk サイズ・並列度は microCMS のレート制限 × Next.js のビルドワーカー並列を考慮して決める
  • generateSitemaps は sitemap-index を自動生成しないので、robots.ts に全 chunk URL を列挙するのが最小コスト
  • MAX_SITEMAP_CHUNKS の固定列挙にしておくと、「ビルド時の id 集合 = runtime クロール案内の URL 集合」が常に一致して運用事故が減る

サスティナビリティを意識するなら、staticPageGenerationTimeout を伸ばすより、chunk 設計に逃がすほうが筋がいい。記事増加に対しては MAX_SITEMAP_CHUNKS を上げて再デプロイすれば吸収できる。

参考

TamaT LLC

Discussion