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.ts を dynamic = "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 ビルドで死ぬ
実装した変更はこれだけ。
- export const dynamic = "force-dynamic";
+ export const revalidate = 86400;
revalidatePath("/[...params]", "page");
+ revalidatePath("/sitemap.xml");
ローカルの pnpm lint と pnpm 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> を返す。
export const ARTICLES_PER_SITEMAP = 2000;
export const MAX_SITEMAP_CHUNKS = 12; // capacity = 24,000 件
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 を列挙
generateSitemaps は sitemap-index を自動生成しない。Google にどう案内するかは別途仕掛けが要る。
最初は「/sitemap-index.xml を route handler で実装する」案にしていたが、よく考えると不要だった:
- chunk 数は固定(
MAX_SITEMAP_CHUNKS = 12→ 13 本) -
MetadataRoute.Robotsのsitemapフィールドはstring | string[]両対応 - robots.txt に列挙すれば GSC は自動検出してくれる
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 までループするのがポイント。
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 のバッチ並列にした:
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 を上げて再デプロイすれば吸収できる。
Discussion