先月、Deluxe Astrologyで91,000ページに達しました。セレブの出生図、ブログ記事、6言語にわたるローカライズされたコンテンツ -- このサイトは単一のサイトマップファイルで対応できるサイズをはるかに超えていました。Googleのサイトマッププロトコルでは、ファイルあたり50,000 URLと50MBの未圧縮サイズに制限されています。Supabaseから動的に生成されたチャンク化されたサブサイトマップを含むサイトマップインデックス、VercelでのISRによるキャッシング、そしてGoogle Search Consoleへの単一インデックスURLの送信が必要でした。

これは私たちが実装した正確な実装です。理論的な説明ではなく -- 91Kの URLを今日処理し、変更なしで500Kまでスケールする実際のプロダクションコードです。

目次

Next.jsとSupabaseを使用した91,000ページの動的サイトマップの構築

サイトマップの制限とアーキテクチャの理解

知っておく必要のあるハード制限は次のとおりです。

制約 制限 ソース
サイトマップファイルあたりのURL数 50,000 sitemaps.org プロトコル
サイトマップあたりのファイルサイズ 50MB未圧縮 sitemaps.org プロトコル
サイトマップインデックスあたりのサイトマップ数 50,000 sitemaps.org プロトコル
Supabase .range() クエリあたりの最大値 1,000行 (デフォルト) Supabase PostgREST設定
Vercelサーバーレス関数タイムアウト (Pro) 60秒 Vercel docs 2025
Vercelレスポンスボディサイズ制限 10MB Vercel エッジキャッシング

91,000個のURLについては、最低でも2つのサイトマップファイルが必要です。ただし、すべてを単純に2つの50Kの URLバケットにダンプするわけではありません。コンテンツタイプ別に分割します -- セレブ、ブログ記事、静的ページ、ローカライズされたページ -- なぜなら、各タイプには異なる changefreqpriority、更新パターンがあるからです。これにより、より良い制御が可能になり、問題が発生した場合のGSCでのデバッグが非常に簡単になります。

Deluxe Astrologyのサイトマップ構造

最終的なサイトマップアーキテクチャは次のようになります。

/sitemap.xml                    → サイトマップインデックス (すべてのサブサイトマップを指す)
  /sitemap-pages.xml            → 静的ページ (~30 URL)
  /sitemap-blog-0.xml           → ブログ記事チャンク 0 (最大50K)
  /sitemap-blog-1.xml           → ブログ記事チャンク 1 (オーバーフロー)
  /sitemap-celebrities-0.xml    → セレブページチャンク 0 (最大50K)
  /sitemap-celebrities-1.xml    → セレブページチャンク 1 (オーバーフロー)
  /sitemap-locale-es.xml        → スペイン語ローカライズページ
  /sitemap-locale-fr.xml        → フランス語ローカライズページ
  /sitemap-locale-de.xml        → ドイツ語ローカライズページ
  /sitemap-locale-pt.xml        → ポルトガル語ローカライズページ
  /sitemap-locale-ja.xml        → 日本語ローカライズページ

各サブサイトマップはNext.js App Routerルートハンドラーで、実行時にSupabaseをクエリし、XMLを生成し、revalidate = 3600 (1時間ごと)のISRでキャッシュします。サイトマップインデックス自体もルートハンドラーです。

オフセットページネーションを使用したSupabaseクエリの設定

これは、ほとんどのチュートリアルが間違える重要な部分です。supabase.from('celebrities').select('*') をして、91,000行が戻ってくることを期待することはできません。SupabaseのPostgRESTレイヤーは、デフォルトで最大1,000行の返却に制限されています。ページネーションが必要です。

1,000の バッチでレンジベースのオフセットページネーションを使用します。

// lib/supabase-sitemap.ts
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // サーバー側用のサービスロールを使用
);

const BATCH_SIZE = 1000;

export interface SitemapEntry {
  slug: string;
  updated_at: string;
}

export async function fetchAllSlugs(
  table: string,
  selectColumns: string = 'slug, updated_at'
): Promise<SitemapEntry[]> {
  const allRows: SitemapEntry[] = [];
  let offset = 0;
  let hasMore = true;

  while (hasMore) {
    const { data, error } = await supabase
      .from(table)
      .select(selectColumns)
      .order('updated_at', { ascending: false })
      .range(offset, offset + BATCH_SIZE - 1);

    if (error) {
      console.error(`Sitemap fetch error for ${table}:`, error.message);
      break;
    }

    if (data && data.length > 0) {
      allRows.push(...data);
      offset += BATCH_SIZE;
      hasMore = data.length === BATCH_SIZE;
    } else {
      hasMore = false;
    }
  }

  return allRows;
}

export async function fetchSlugsChunked(
  table: string,
  chunkIndex: number,
  chunkSize: number = 50000
): Promise<{ entries: SitemapEntry[]; totalCount: number }> {
  // まず、サイトマップインデックスの総数を取得
  const { count } = await supabase
    .from(table)
    .select('*', { count: 'exact', head: true });

  const totalCount = count || 0;
  const startOffset = chunkIndex * chunkSize;
  const entries: SitemapEntry[] = [];
  let offset = startOffset;
  const endOffset = Math.min(startOffset + chunkSize, totalCount);

  while (offset < endOffset) {
    const batchEnd = Math.min(offset + BATCH_SIZE - 1, endOffset - 1);
    const { data, error } = await supabase
      .from(table)
      .select('slug, updated_at')
      .order('updated_at', { ascending: false })
      .range(offset, batchEnd);

    if (error || !data || data.length === 0) break;
    entries.push(...data);
    offset += data.length;
  }

  return { entries, totalCount };
}

export function getChunkCount(totalCount: number, chunkSize: number = 50000): number {
  return Math.ceil(totalCount / chunkSize);
}

ここで注意すべき点がいくつかあります。SUPABASE_SERVICE_ROLE_KEY を使用します -- anonキーではなく -- これらのルートハンドラーはサーバー側で実行され、RLSポリシーがサイトマップクエリを遅くなることを望まないためです。fetchSlugsChunked 関数は、特定のサイトマップファイルに必要な特定のチャンクのみを取得し、データセット全体を取得しません。Vercelの60秒の関数タイムアウトで実行している場合、これは重要です。

Next.jsとSupabaseを使用した91,000ページの動的サイトマップの構築 - アーキテクチャ

サイトマップインデックスルートの構築

サイトマップインデックスは、Googleに送信する単一のURLです。すべてのサブサイトマップを参照します。

// app/sitemap.xml/route.ts
import { NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';

export const revalidate = 3600; // ISR: 1時間ごとに再生成

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

const CHUNK_SIZE = 50000;
const SITE_URL = 'https://deluxeastrology.com';
const LOCALES = ['es', 'fr', 'de', 'pt', 'ja'];

async function getTableCount(table: string): Promise<number> {
  const { count } = await supabase
    .from(table)
    .select('*', { count: 'exact', head: true });
  return count || 0;
}

export async function GET() {
  const blogCount = await getTableCount('blog_posts');
  const celebrityCount = await getTableCount('celebrities');

  const blogChunks = Math.ceil(blogCount / CHUNK_SIZE);
  const celebrityChunks = Math.ceil(celebrityCount / CHUNK_SIZE);

  const now = new Date().toISOString();

  let sitemaps = '';

  // 静的ページサイトマップ
  sitemaps += `
    <sitemap>
      <loc>${SITE_URL}/sitemap-pages.xml</loc>
      <lastmod>${now}</lastmod>
    </sitemap>`;

  // ブログサイトマップ
  for (let i = 0; i < blogChunks; i++) {
    sitemaps += `
    <sitemap>
      <loc>${SITE_URL}/sitemap-blog-${i}.xml</loc>
      <lastmod>${now}</lastmod>
    </sitemap>`;
  }

  // セレブサイトマップ
  for (let i = 0; i < celebrityChunks; i++) {
    sitemaps += `
    <sitemap>
      <loc>${SITE_URL}/sitemap-celebrities-${i}.xml</loc>
      <lastmod>${now}</lastmod>
    </sitemap>`;
  }

  // ロケールサイトマップ
  for (const locale of LOCALES) {
    sitemaps += `
    <sitemap>
      <loc>${SITE_URL}/sitemap-locale-${locale}.xml</loc>
      <lastmod>${now}</lastmod>
    </sitemap>`;
  }

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemaps}
</sitemapindex>`;

  return new NextResponse(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
    },
  });
}

ここでは、カウントクエリのみを行っていることに注意してください -- head: true はSupabaseが行データなしでカウントだけを返すことを意味します。これにより、サイトマップインデックス生成がほぼ瞬時になります。

個別のチャンク化されたサイトマップの構築

完全なページネーション付きのセレブサイトマップハンドラーは次のとおりです。

// app/sitemap-celebrities-[chunk].xml/route.ts
import { NextResponse } from 'next/server';
import { fetchSlugsChunked } from '@/lib/supabase-sitemap';

export const revalidate = 3600;

const SITE_URL = 'https://deluxeastrology.com';

export async function GET(
  request: Request,
  { params }: { params: Promise<{ chunk: string }> }
) {
  const { chunk } = await params;
  const chunkIndex = parseInt(chunk, 10);

  if (isNaN(chunkIndex) || chunkIndex < 0) {
    return new NextResponse('Invalid chunk index', { status: 400 });
  }

  const { entries } = await fetchSlugsChunked('celebrities', chunkIndex);

  const urls = entries
    .map(
      (entry) => `
  <url>
    <loc>${SITE_URL}/celebrities/${entry.slug}</loc>
    <lastmod>${new Date(entry.updated_at).toISOString()}</lastmod>
    <changefreq>monthly</changefreq>
    <priority>0.6</priority>
  </url>`
    )
    .join('');

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}
</urlset>`;

  return new NextResponse(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
    },
  });
}

ブログサイトマップは同じパターンに従いますが、異なる優先度と変更頻度があります。

// app/sitemap-blog-[chunk].xml/route.ts
import { NextResponse } from 'next/server';
import { fetchSlugsChunked } from '@/lib/supabase-sitemap';

export const revalidate = 3600;

const SITE_URL = 'https://deluxeastrology.com';

export async function GET(
  request: Request,
  { params }: { params: Promise<{ chunk: string }> }
) {
  const { chunk } = await params;
  const chunkIndex = parseInt(chunk, 10);

  const { entries } = await fetchSlugsChunked('blog_posts', chunkIndex);

  const urls = entries
    .map(
      (entry) => `
  <url>
    <loc>${SITE_URL}/blog/${entry.slug}</loc>
    <lastmod>${new Date(entry.updated_at).toISOString()}</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.8</priority>
  </url>`
    )
    .join('');

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}
</urlset>`;

  return new NextResponse(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
    },
  });
}

Next.jsルーティングを設定して動的セグメントを処理する必要があります。App Routerでは、フォルダー名は括弧を使用します。

app/
  sitemap.xml/
    route.ts
  sitemap-pages.xml/
    route.ts
  sitemap-blog-[chunk].xml/
    route.ts
  sitemap-celebrities-[chunk].xml/
    route.ts
  sitemap-locale-[lang].xml/
    route.ts

括弧内のフォルダー名のアプローチがファイルシステムやIDEに問題を与える場合 (時々そうなります) は、代わりに next.config.ts のルート書き換えを使用します。

// next.config.ts
const nextConfig = {
  async rewrites() {
    return [
      {
        source: '/sitemap-blog-:chunk(\\d+).xml',
        destination: '/api/sitemap-blog/:chunk',
      },
      {
        source: '/sitemap-celebrities-:chunk(\\d+).xml',
        destination: '/api/sitemap-celebrities/:chunk',
      },
      {
        source: '/sitemap-locale-:lang.xml',
        destination: '/api/sitemap-locale/:lang',
      },
    ];
  },
};

export default nextConfig;

静的ページサイトマップ

静的ページサイトマップについては、ほぼ変更されないため、URLをハードコードします。

// app/sitemap-pages.xml/route.ts
import { NextResponse } from 'next/server';

export const revalidate = 86400; // 静的ページは1日1回で十分

const SITE_URL = 'https://deluxeastrology.com';

const staticPages = [
  { path: '/', priority: '1.0', changefreq: 'daily' },
  { path: '/about', priority: '0.7', changefreq: 'monthly' },
  { path: '/solutions/birth-chart', priority: '0.9', changefreq: 'weekly' },
  { path: '/solutions/compatibility', priority: '0.9', changefreq: 'weekly' },
  { path: '/solutions/transit-report', priority: '0.9', changefreq: 'weekly' },
  { path: '/blog', priority: '0.8', changefreq: 'daily' },
  { path: '/celebrities', priority: '0.8', changefreq: 'daily' },
  { path: '/contact', priority: '0.5', changefreq: 'yearly' },
  { path: '/pricing', priority: '0.7', changefreq: 'monthly' },
];

export async function GET() {
  const now = new Date().toISOString();

  const urls = staticPages
    .map(
      (page) => `
  <url>
    <loc>${SITE_URL}${page.path}</loc>
    <lastmod>${now}</lastmod>
    <changefreq>${page.changefreq}</changefreq>
    <priority>${page.priority}</priority>
  </url>`
    )
    .join('');

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}
</urlset>`;

  return new NextResponse(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=3600',
    },
  });
}

hreflangを使用したローカライズされたサイトマップ

ここで興味深い部分が始まります。多言語コンテンツの場合、xhtml:link 要素と hreflang 属性が必要です。各ローカライズされたサイトマップは、各ページのすべての代替言語バージョンを参照します。

// app/sitemap-locale-[lang].xml/route.ts
import { NextResponse } from 'next/server';
import { fetchAllSlugs } from '@/lib/supabase-sitemap';

export const revalidate = 3600;

const SITE_URL = 'https://deluxeastrology.com';
const ALL_LOCALES = ['en', 'es', 'fr', 'de', 'pt', 'ja'];

export async function GET(
  request: Request,
  { params }: { params: Promise<{ lang: string }> }
) {
  const { lang } = await params;

  if (!ALL_LOCALES.includes(lang)) {
    return new NextResponse('Invalid locale', { status: 404 });
  }

  const entries = await fetchAllSlugs('localized_pages');
  // このロケールを持つページをフィルタリング
  const localeEntries = entries.filter((e: any) => e.locale === lang);

  const urls = localeEntries
    .map((entry: any) => {
      const alternates = ALL_LOCALES.map(
        (loc) =>
          `    <xhtml:link rel="alternate" hreflang="${loc}" href="${SITE_URL}/${loc}/${entry.slug}" />`
      ).join('\n');

      return `
  <url>
    <loc>${SITE_URL}/${lang}/${entry.slug}</loc>
    <lastmod>${new Date(entry.updated_at).toISOString()}</lastmod>
    <changefreq>monthly</changefreq>
    <priority>0.6</priority>
${alternates}
  </url>`;
    })
    .join('');

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}
</urlset>`;

  return new NextResponse(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
    },
  });
}

ISR再検証戦略

すべてのサイトマップルートに revalidate = 3600 を設定します。つまり、VercelはキャッシュされたXMLを最大1時間提供し、次のリクエストでバックグラウンドで再生成します。91Kページについて、これは最適なポイントです -- 新しいコンテンツが同じ日に表示されるのに十分な頻度ですが、Supabaseにハンマーを振るうほど積極的ではありません。

コンテンツが公開されたときのオンデマンド再検証については、再検証エンドポイントを追加します。

// app/api/revalidate-sitemap/route.ts
import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const { secret, paths } = await request.json();

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // 特定のサイトマップパスを再検証
  const targetPaths = paths || ['/sitemap.xml'];
  for (const path of targetPaths) {
    revalidatePath(path);
  }

  return NextResponse.json({ revalidated: true, paths: targetPaths });
}

その後、SupabaseデータベースWebhook (またはPostgresトリガー経由の pg_net) を設定して、celebrities または blog_posts テーブルが更新されるたびにこのエンドポイントを呼び出します。

コンテンツタイプ別の優先度と変更頻度

ここに使用する優先度マトリックスがあります。Googleは prioritychangefreq を主に無視していると言っていますが、他のクローラー (Bing、Yandex) はまだそれらを使用しており、害はありません。

コンテンツタイプ 優先度 変更頻度 根拠
ホームページ 1.0 毎日 最高の重要性、頻繁に更新
ソリューション/機能 0.9 毎週 コア製品ページ
ブログリスト 0.8 毎日 定期的に新しい投稿
ブログ記事 0.8 毎週 コンテンツは時々更新
セレブページ 0.6 毎月 作成後はほぼ変更されません
ローカライズページ 0.6 毎月 翻訳更新は稀
連絡先/法的 0.5 毎年 ほぼ変更されません

lastmod 値は重要であり、データベースの updated_at 列から常に来るべきです -- new Date() にハードコードしないでください。Googleは lastmod を使用してクローリングを優先順位付けし、すべてのページが今すぐ変更されたと言っている場合、Googleは最終的に lastmod を完全に無視します。

Google Search Consoleへの送信

ここが簡潔な部分です。GSCで:

  1. 左側のサイドバーで サイトマップ に移動
  2. https://yourdomain.com/sitemap.xml (インデックスURLのみ) を入力
  3. 送信 をクリック

それだけです。個別のサブサイトマップを送信しないでください。Googleはインデックスを読み、すべての子を自動的に検出します。数時間以内に「成功」ステータスが表示され、インデックス化されたURL数は2〜4週間で増加します。

91KのURLについて、Googleが最初の月以内に70~90%をインデックス化することを期待します。残りのページは通常、コンテンツが薄い、重複コンテンツの問題、またはGoogleのクロール予算配分では単に低優先度です。

また、robots.txt にサイトマップを追加します。

# robots.txt
User-agent: *
Allow: /

Sitemap: https://deluxeastrology.com/sitemap.xml

Googleがページをインデックスしない場合のデバッグ

これはほとんどの人が立ち往生する場所です。91Kの URLを送信しましたが、GSCには40Kしかインデックスされていません。ここで私たちが従う体系的なデバッグチェックリストです。

誤ったノインデックスタグをチェック

これが#1の原因です。スポットチェックを実行します。

curl -s https://deluxeastrology.com/celebrities/some-slug | grep -i 'noindex'

また、Next.jsレイアウトまたはページメタデータで noindex を設定していないか確認してください。一般的な間違いは、数千のページに適用されるレイアウトで noindex を設定することです。

// 悪い例: これはこのレイアウトを使用するすべてのページをノインデックス化
export const metadata = {
  robots: { index: false, follow: true },
};

robots.txtがクローリングをブロックしていないか確認

ブラウザで https://yourdomain.com/robots.txt をチェックします。動的ルートを誤ってブロックしていないか確認します。Vercelでは、Googlebotに403を返すミドルウェアがないかも確認します。

GSCでクロールエラーを確認

ページページがインデックスされない理由 に移動します。一般的な問題:

  • "クロール済み - 現在インデックスされていません": Googleはページを見ましたが、インデックス化しないことにしました。通常、コンテンツが薄い。
  • "検出 - 現在インデックスされていません": GoogleはURLが存在することを知っていますが、まだクローリングしていません。クロール予算の問題。
  • "ノインデックスタグで除外": 自明。タグを修正してください。
  • "正規タグなし重複": 適切な正規タグを追加します。

内部リンクでオーファンページを修正

大規模サイトの場合、これは非常に重要です。セレブページがサイトマップを通じてのみ検出可能で、それらを指す内部リンクがない場合、Googleはそれらのクローリングを優先事項にしません。追加します:

  • セレブページのグループにリンクするカテゴリ/リストページ
  • 各セレブページ上の関連セレブリンク
  • 高トラフィックページの「トレンド」または「最近更新」セクション
  • 構造化データを使用したブレッドクラム ナビゲーション

個別URLを検証

特定の未インデックスページでGSCのURL検査ツールを使用します。Googleが見たのを正確に表示します -- レンダリングされたHTML、エラー、モバイルユーザビリティの問題、インデックス化ステータス。

サイトマップレスポンスヘッダーをチェック

サイトマップルートが適切なヘッダーを返すことを確認します。

curl -I https://deluxeastrology.com/sitemap.xml

Content-Type: application/xml と200ステータスが表示されます。キャッシュが古くなった場合に304 Not Modifiedレスポンスが表示されている場合、Googleがサイトマップの再読み込みをスキップする可能性があります。

パフォーマンスとコストのベンチマーク

2025年初期の本番展開からの実際の数字は次のとおりです。

メトリック
サイトマップ内のURL総数 91,247
サイトマップインデックス生成時間 ~120ms (カウントクエリのみ)
個別サイトマップ生成 (50K URL) ~4.2秒
サイトマップ再生成ごとのSupabaseクエリコスト ~$0.01
すべてのファイルを合わせたサイトマップXMLサイズ ~8.4MB未圧縮
月あたりのサイトマップのVercel帯域幅 ~2.1GB (主にGooglebot)
Vercel Pro プラン コスト ユーザーあたり月$20
Supabase Pro プラン コスト 月$25
30日後のGSCインデックス化率 送信されたURL の84%
コンテンツ公開からサイトマップ更新までの時間 ≤1時間 (ISR) または ~5秒 (オンデマンド)

大きなポイント: このセットアップ全体は実行に基本的にコストはかかりません。サイトマップ生成はVercelとSupabaseの請求の四捨五入エラーです。

類似した大規模プロジェクトを構築していて、アーキテクチャにヘルプが必要な場合、複数のクライアントサイト全体でこれを実行しています。Next.js開発機能 または ヘッドレスCMS開発作業 を確認してください。Astroベースのサイトで同様のスケール要件がある場合、Astroのエンドポイント を使用して同様のソリューションを構築しました。

完全に機能するコードはGitHub gistとして利用可能です: すべてのルートハンドラー、Supabaseクエリライブラリ、および next.config.ts 書き換え。プロジェクトがより カスタムな何かが必要な場合 -- マルチテナントサイトマップ、リアルタイム再検証、または1M+ ページのサイトマップ -- ご連絡ください のでスコープアウトします。

FAQ

単一のサイトマップファイルは何個のURLを含めることができますか?

サイトマッププロトコルでは、ファイルあたり最大50,000 URLと50MB未圧縮ファイルサイズが許可されます。50K以上のページを持つサイトでは、複数のチャンク化されたサイトマップファイルを参照するサイトマップインデックスが必要です。実際には、ほとんどのサイトマップジェネレータは45,000~50,000 URLでチャンク化して、安全マージンを残します。

next-sitemapを使用すべきですか、それとも カスタムルートハンドラーを構築すべきですか?

next-sitemap (v4+) はより単純なセットアップに最適で、自動チャンキングをうまく処理します。しかし、91K+ の動的ページで、コンテンツタイプ固有の優先度、hreflangを持つローカライズされたサイトマップ、微調整されたISR制御については、カスタムルートハンドラーはより多くの制御を与えます。コンテンツタイプごとに異なる再検証間隔が必要で、サイトマップ構造をGSCデバッグワークフローに一致させたかったため、カスタムを使用しました。

Google Search Consoleに各個別サイトマップファイルを送信しますか?

いいえ。サイトマップインデックスURL のみ (例: https://yourdomain.com/sitemap.xml) を送信してください。Googleはインデックスを読み、参照されているすべてのサブサイトマップを自動的に検出および処理します。個別ファイルの送信は不要であり、GSCダッシュボードを雑然とさせます。

大規模動的サイトのサイトマップはどのくらいの頻度で再生成する必要がありますか?

ほとんどのコンテンツ豊富なサイトでは、ISR (revalidate = 3600) による時間ごとの再生成が良いデフォルトです。非常に頻繁にコンテンツを公開する場合は、データベースWebhook でトリガーされるオンデマンド再検証と組み合わせます。すべてのリクエストで再生成しないでください -- これはキャッシングを無効にし、Supabaseの負荷が不必要に増加します。

Googleがサイトマップに含まれるすべてのURLをインデックスしていないのはなぜですか?

最も一般的な原因は: 誤った noindex メタタグ、robots.txt ブロック、シン/重複コンテンツ、内部リンクがないオーファンページ、クロール予算の制限です。GSCの「ページ」レポートで「ページがインデックスされない理由」を確認して、特定の理由を確認してください。大規模サイトの場合、オーファンページへの内部リンクを改善することに焦点を当てます -- これが最大のレバーであることが多いです。

サイトマップの priority 値は実際にGoogle のランキングに影響しますか? Googleは公開で prioritychangefreq の値をほぼ無視していると述べています。ただし、Bingおよびその他の検索エンジンはそれらを使用しています。lastmod フィールドが最も重要なサイトマップシグナルです -- データベースの実際のコンテンツの変更を反映し、現在のタイムスタンプではないことを確認します。

Supabaseの1,000行制限を使用してサイトマップクエリをどのように処理しますか?

Supabase の .range(offset, offset + batchSize - 1) メソッドを使用して1,000のバッチでページネーションします。現在のサイトマップチャンクのすべての行をフェッチするまでループします。カウントのみのクエリ (サイトマップインデックスで使用) については、.select('*', { count: 'exact', head: true }) を使用します。これはすべての行データを転送せずにカウントだけを返します。

このアプローチは500K または100万ページを処理できますか?

はい、少しの調整で。チャンク化されたアーキテクチャは直線的にスケーリングします -- 100万ページは約20個のサブサイトマップを生成します。主な懸念はVercelの60秒の関数タイムアウトです。50K-URL サイトマップを生成することです。制限に達した場合は、チャンクサイズを25,000または10,000 URLに減らします。サイトマッププロトコルでは、単一のインデックスで最大50,000 のサイトマップを許可するため、インデックスレベルの制限に達することはすぐに続きます。