137,000件のリスティングを持つグローバルディレクトリを Next.js、Supabase、Vercel ISR で構築する

昨年、137,000件のリスティングを含むグローバルディレクトリをリリースしました。プロトタイプではありません。「最適化は後でやろう」というMVPでもありません。数百万のページビューを処理し、数千のロングテールキーワードでランキングされ、オンデマンドでページを再生成できる本番システムです。これは、私たちがそれをどのように構築したか、そしてそれを可能にした設計上の決定についての話です。

スタック:Next.js 14(App Router)、Supabase(PostgreSQL + Edge Functions)、Vercel(ホスティング + ISR)、そして実用的な思考の健全な用量です。私たちは間違いを犯しました。壁にぶつかりました。完成したと思ったものを書き直しました。しかし、最終的なアーキテクチャは137,000以上の動的ページを処理し、グローバルではTTFB(Time To First Byte)が200ms未満であり、Supabaseの月額請求は100ドル未満に抑えられています。

同様のものを構築している場合 — マーケットプレイス、ディレクトリ、リスティングプラットフォーム — これは私たちが開始したときに存在していたことを望むこの記事です。

目次

Next.js、Supabase、Vercel ISRで137Kリスティングのグローバルディレクトリを構築

このスタックを選んだ理由

Next.js + Supabase + Vercelに決める前に、多くのオプションを評価しました。コアの要件は以下の通りです:

  1. 137,000以上のユニークなページ - 検索エンジンがクロールおよびインデックスできるもの
  2. サブ秒のページロード - グローバル(40以上の国のユーザー)
  3. 動的データ — リスティングは毎日更新され、一部は毎時間更新されます
  4. フルテキスト検索 - ファセット化されたフィルタリング機能付き
  5. 予算に優しい — これはVCからの資金を受けたムーンショットではありませんでした

Astroを検討しました(静的サイトに最適ですが、より動的なインタラクティビティが必要でした — ただし、Astro開発チームは優れたディレクトリプロジェクトを出荷しています)。WordPress + WPEngineを検討しました。AlgoliaをしたSPAの検討もしました。

Next.jsが勝った理由は、1つの強力な機能です:増分静的再生成(ISR)。ISRは、静的パフォーマンスと動的コンテンツの間で選択する必要がないことを意味していました。両方を持つことができました。

Supabaseが PlanetScale と Neon に勝った理由は、完全なパッケージです — auth、storage、edge functions、そして行レベルセキュリティを備えた本当に良いPostgres実装です。ディレクトリの場合、すべてが必要です。

Vercelは、ISRが Vercel 上で最も適切に機能するため、デプロイ対象として選ばれました(当然のことながら)。統合はネイティブです。オンデマンド検証は機能します。

自己ホスティングについては?

Railway 上で自己ホストされた Next.js セットアップをプロトタイピングしました。機能しましたが、自己ホストされた Next.js 上の ISR にはクセがあります。キャッシュ無効化の話が悪いです。独自のCDNレイヤーを管理する必要があります。3人のエンジニアのチームにとって、運用上のオーバーヘッドは節約できる月額200ドルの価値がありませんでした。

データレイヤー:大規模なSupabase

Supabaseデータベースには137,000のリスティングが保持されており、各リスティングには40〜60のフィールドがあります。カテゴリ、場所、連絡先情報、リッチ説明、画像、評価、営業時間 — すべてです。

スキーマ設計

最大の決定は、正規化されたリレーショナルスキーマを使用するか、JSONBカラムを使用したより文書指向的なアプローチを使用するかでした。私たちはハイブリッドアプローチを採用しました:

CREATE TABLE listings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug TEXT UNIQUE NOT NULL,
  title TEXT NOT NULL,
  description TEXT,
  category_id UUID REFERENCES categories(id),
  city_id UUID REFERENCES cities(id),
  country_code TEXT NOT NULL,
  coordinates GEOGRAPHY(POINT, 4326),
  contact JSONB DEFAULT '{}',
  attributes JSONB DEFAULT '{}',
  media JSONB DEFAULT '[]',
  rating_avg NUMERIC(3,2) DEFAULT 0,
  rating_count INTEGER DEFAULT 0,
  status TEXT DEFAULT 'active',
  published_at TIMESTAMPTZ,
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  search_vector TSVECTOR
);

CREATE INDEX idx_listings_category ON listings(category_id) WHERE status = 'active';
CREATE INDEX idx_listings_city ON listings(city_id) WHERE status = 'active';
CREATE INDEX idx_listings_country ON listings(country_code) WHERE status = 'active';
CREATE INDEX idx_listings_coordinates ON listings USING GIST(coordinates);
CREATE INDEX idx_listings_search ON listings USING GIN(search_vector);
CREATE INDEX idx_listings_slug ON listings(slug);

フィルタリング対象のもの(カテゴリ、都市、国)には構造化されたリレーショナルデータです。リスティングごとに異なる半構造化もの(連絡方法、カスタム属性、メディア配列)にはJSONBです。これにより、リレーショナルカラムに対する高速インデックスクエリと、残りの部分の柔軟性が得られました。

検索ベクトル

search_vectorカラムは重要です。トリガーで入力します:

CREATE OR REPLACE FUNCTION update_search_vector()
RETURNS TRIGGER AS $$
BEGIN
  NEW.search_vector :=
    setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
    setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B') ||
    setweight(to_tsvector('english', COALESCE(NEW.attributes->>'keywords', '')), 'C');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

これは、すべてのリスティングが Postgres 自体を通じてフルテキスト検索可能であることを意味します。最初の100Kリスティングでは外部検索サービスは必要ありません。これが壊れるときについては後で説明します。

接続プール

Supabaseは接続プーリング用にPgBouncerを使用します。ISRでは、サーバーレス関数呼び出しのバーストが発生します — 各呼び出しはデータベース接続を必要とします。プーリングがないと、数分でコネクションを枯渇させます。

サーバーレスコンテキストのすべてのプール接続文字列(port 6543)を使用し、直接接続(port 5432)をマイグレーションと管理タスクにのみ使用します。これは明らかに聞こえますが、人々を捕まえます。

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

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // Server-side only
  {
    db: { schema: 'public' },
    auth: { persistSession: false }
  }
)

ページ生成戦略:ISR、SSG、および137K問題

ここは興味深いです。そして、私たちが最大の初期の過ちを犯したところです。

ナイーブアプローチ(これをしないでください)

最初の試み:generateStaticParamsを使用してビルド時にすべての137,000ページを生成します。ビルドには4時間22分かかりました。Vercelの無料層には45分のビルド制限があります。Pro層でも6時間の上限があります。しかし、実際の問題はタイムアウトではなく、フィードバックループでした。すべてのデプロイに半日かかりました。それは機能不全です。

ISRアプローチ(実際に機能するもの)

出荷した戦略は次のとおりです:

  1. ビルド時に:トラフィックが最も多い5,000ページを静的に生成
  2. 最初のリクエストで:残りのページをオンデマンドで生成してキャッシュ
  3. 検証:時間ベース(3600秒ごと)+ Webhookを介したオンデマンド
// app/listing/[slug]/page.tsx
import { supabase } from '@/lib/supabase'
import { notFound } from 'next/navigation'

export async function generateStaticParams() {
  // Only pre-generate top listings by traffic
  const { data } = await supabase
    .from('listings')
    .select('slug')
    .eq('status', 'active')
    .order('rating_count', { ascending: false })
    .limit(5000)

  return (data || []).map((listing) => ({
    slug: listing.slug,
  }))
}

export const revalidate = 3600 // Revalidate every hour

export default async function ListingPage({ params }: { params: { slug: string } }) {
  const { data: listing, error } = await supabase
    .from('listings')
    .select(`
      *,
      category:categories(*),
      city:cities(*, country:countries(*))
    `)
    .eq('slug', params.slug)
    .eq('status', 'active')
    .single()

  if (!listing || error) notFound()

  return <ListingDetail listing={listing} />
}

オンデマンド検証

リスティング所有者がデータを更新する場合、最大1時間待つためにページをリフレッシュしたくありません。Supabase Webhooksは、Next.js APIルートをトリガーします:

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-revalidation-secret')
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { slug, type } = await request.json()

  if (type === 'listing') {
    revalidatePath(`/listing/${slug}`)
    revalidatePath(`/`) // Revalidate homepage too
  }

  return NextResponse.json({ revalidated: true })
}

これにより、静的サイトのパフォーマンスと動的サイトの新鮮さの両方が得られます。ビルドは8分以内に完了します。事前に生成されていないページは、最初にアクセスされたときに作成され、エッジにキャッシュされます。

数字

メトリック フルSSG(ナイーブ) ISR(本番)
ビルド時間 4h 22m 7m 40s
デプロイ時のページ 137,000 5,000
最初のアクセス(キャッシュなし) N/A ~800ms
その後のアクセス ~120ms ~120ms
検証レイテンシー 完全再デプロイ < 2 seconds
月間ビルド分 制限を大幅に超過 ~230 minutes

Next.js、Supabase、Vercel ISRで137Kリスティングのグローバルディレクトリを構築 - アーキテクチャ

URL設計とスケールでのSEO

137,000ページでは、URLの構造は事後的ではありません — それはアーキテクチャです。すべてのURLはランキングの機会です。

URL階層

/                                    → ホームページ
/categories/[category-slug]          → カテゴリページ(48カテゴリ)
/locations/[country]/[city]          → 場所ページ
/listing/[listing-slug]              → 個々のリスティング
/search?q=...&category=...&city=...  → 検索結果(noindex)

カテゴリ + 場所の交差ページは、実際のSEO金鉱です:

/categories/restaurants/us/new-york   → "Restaurants in New York"
/categories/hotels/uk/london          → "Hotels in London"

これらの交差ページは、ISRを使用して動的に生成されます。有効な組み合わせはおおよそ12,000です。それぞれが特定のロングテールキーワードをターゲットにしています。

サイトマップ生成

137K URLで、サイトマップインデックスファイルが必要です。Googleの制限は、サイトマップあたり50,000 URLです。

// app/sitemap/[id]/route.ts
export async function GET(request: Request, { params }: { params: { id: string } }) {
  const page = parseInt(params.id)
  const perPage = 45000 // Stay under the 50K limit
  const offset = page * perPage

  const { data: listings } = await supabase
    .from('listings')
    .select('slug, updated_at')
    .eq('status', 'active')
    .order('id')
    .range(offset, offset + perPage - 1)

  const xml = generateSitemapXml(listings)
  return new Response(xml, {
    headers: { 'Content-Type': 'application/xml' },
  })
}

4つのサイトマップに分割します:sitemap-0.xmlからsitemap-3.xmlまで、サイトマップインデックスで参照されます。Google Search Consoleは提出されたURLの98%を6週間以内にインデックスしました。

構造化データ

すべてのリスティングページには、JSON-LD構造化データが含まれています。ディレクトリの場合、LocalBusinessスキーマは重要です:

const structuredData = {
  '@context': 'https://schema.org',
  '@type': 'LocalBusiness',
  name: listing.title,
  description: listing.description,
  address: {
    '@type': 'PostalAddress',
    addressLocality: listing.city.name,
    addressCountry: listing.city.country.code,
  },
  geo: {
    '@type': 'GeoCoordinates',
    latitude: listing.coordinates?.lat,
    longitude: listing.coordinates?.lng,
  },
  aggregateRating: listing.rating_count > 0 ? {
    '@type': 'AggregateRating',
    ratingValue: listing.rating_avg,
    reviewCount: listing.rating_count,
  } : undefined,
}

検索とフィルタリング:難しい部分

検索は常に難しい部分です。常に。

フェーズ1:Postgresフルテキスト検索

最初のリリースでは、Postgres tsvector検索がすべてを処理していました。それはGINインデックス付きで137Kの行に対して十分に速いです。クエリタイムの平均は40〜80msでした。

const { data } = await supabase
  .from('listings')
  .select('id, slug, title, description, category:categories(name)')
  .textSearch('search_vector', query, { type: 'websearch' })
  .eq('status', 'active')
  .eq('country_code', countryFilter)
  .order('rating_avg', { ascending: false })
  .range(0, 19)

フェーズ2:Postgresが十分でなくなったとき

約80,000リスティング時点で、複雑なファセット検索(カテゴリ+場所+テキスト+ソート)は300〜500msに達し始めました。ほとんどのアプリケーションでは許容可能ですが、ユーザーは瞬時の結果を期待していました。

Typesenseを検索層として追加しました。Algolia ではありません(スケールでは高すぎます — 月額500ドル以上を支払っていることになります)。Meilisearch ではありません(素晴らしいですが、Typesenseの地理検索は当社のユースケースに適していました)。

Typesenseは月額48ドルのHetznerインスタンスで実行されます。Supabaseから毎晩の完全なインデックス再作成+リアルタイムのWebhook更新を介して同期されます。検索クエリは現在平均8〜15msです。

検索ソリューション クエリ時間(p50) クエリ時間(p99) 月額料金 ファセット検索
Postgres FTS 45ms 320ms $0(含まれる) 制限付き
Typesense 9ms 28ms $48 優秀
Algolia ~5ms ~15ms $500+ 優秀
Meilisearch ~8ms ~22ms $48(自己ホスト) 良い

パフォーマンス予算とエッジキャッシング

最初からアグレッシブなパフォーマンスターゲットを設定しました:

  • TTFB:< 200ms(グローバルp75)
  • LCP:< 1.5s
  • CLS:< 0.05
  • 総ページ重量:< 300KB(初期ロード)

Vercel Edgeネットワーク

ISRページはVercelのエッジネットワーク — 100+ PoPs グローバルでキャッシュされます。ページが生成されてキャッシュされると、最も近いエッジロケーションから提供されます。これが、東南アジアまたは南米のユーザーでもTTFBが200ms未満のままである理由です。

画像最適化

各リスティングには1〜8個の画像があります。それは100万以上の画像になる可能性があります。Vercelの組み込み画像最適化とnext/imageを使用します:

<Image
  src={listing.media[0]?.url}
  alt={listing.title}
  width={800}
  height={600}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  loading={index === 0 ? 'eager' : 'lazy'}
  quality={75}
/>

画像はSupabaseストレージに保存され、Vercelの画像CDNを通じて提供されます。元の画像はしばしば2〜5MB。最適化後、40〜120KBです。これだけで帯域幅を約80%削減しました。

本番環境での監視と可観測性

137Kページを監視なしで本番環境で実行することは、目隠しして運転するようなものです。スタックは次のとおりです:

  • Vercel Analytics:Core Web Vitals、リアルユーザー監視
  • Sentry:エラー追跡(1日あたり約50のエラーをキャッチします。ほとんどはボットからのガベージを送信しています)
  • Supabase Dashboard:データベースパフォーマンス、クエリ分析
  • Checkly:合成監視、5分間隔での重要なパスについて
  • Google Search Console:インデックスカバレッジ、クロール統計

設定した最も価値のある監視は、インデックス付きページと合計アクティブなリスティングの数をカウントする毎日のSupabaseクエリでした。比率が95%を下回った場合、アラートが表示されます。これは悪い変更をデプロイして24時間以内にサイトマップ回帰をキャッチしました。

コスト内訳:これは実際にいくらかかるのか

人々は常にコストについて尋ねます。2025年Q1の時点での実際の月額支出は次のとおりです:

サービス プラン 月額料金
Vercel Pro $20
Vercel帯域幅(オーバーチャージ) 従量課金制 ~$35
Supabase Pro $25
Supabaseデータベース(コンピューティング) スモールインスタンス $48
Typesense(Hetzner) CX31 $48
Checkly スターター $7
Sentry チーム $26
ドメイン+ DNS(Cloudflare) 無料層 $0
合計 ~$209/month

137,000ページを提供し、月間で数百万のページビューを持つ、月額約200ドルです。WordPressを実行している従来のサーバーセットアップでそれを試してください。

同様のプロジェクトを検討しており、このようなアーキテクチャが予算にどのようにマップされるかを理解したい場合は、価格ページで、通常ディレクトリおよびマーケットプレイスプロジェクトをどのようにスコープするかを説明しています。

私たちが異なることをするもの

最初からISRを使用します。 完全なSSGを機能させようとするのに2週間を無駄にしました。その前に数学が追加されていないことを受け入れます。

最初からTypesenseを使用します。 Postgres FTSは早期には問題ありませんでしたが、プロジェクト中期に検索を移行するのは邪魔でした。月額48ドルは、リリースからの価値があったでしょう。

データ検証にもっと早く投資します。 様々なソースからインポートされた137Kリスティングでは、データ品質は悪夢でした。最初のインポートの前に、より厳格なZodスキーマと検証パイプラインを構築するべきでした。本番環境で壊れた数千のレコードを見つけた後ではなく。

ステージングでリアルなデータボリュームをテストします。 ステージング環境には500リスティングがありました。500行で機能したクエリは137Kで崩壊しました。本番環境データの20%のランダムサンプルをシードするようになりました。

ディレクトリまたはマーケットプレイスビルドを計画していて、これらの同じ落とし穴を回避したい場合は、チームに連絡してください。私たちはこれを十分な回数を経験しており、地雷がどこにあるかを知っています。

FAQ

Next.jsで100K以上のリスティングディレクトリを構築するのにどのくらい時間がかかりますか? 当社のチームの場合、初期アーキテクチャとコア機能には約10週間かかりました。データインポート、クリーニング、検証は追加で3〜4週間かかりました。キックオフから本番リリースまでの合計は約14週間でした。以前これを行ったNext.js開発チームで作業している場合、2〜3週間短縮できます。

Supabaseはディレクトリ用に100,000以上の行を処理できますか? 絶対に。Supabaseは数百万の行を処理するPostgresで実行されます。キーは適切なインデックスです — 最も照会されるカラムのインデックスがないと、パフォーマンスが低下します。上記で説明したインデックスでは、137K行のクエリは単一レコードの検索で一貫して50ms未満で返されます。

大規模なサイトのISRとSSGの違いは何ですか? SSG(静的サイト生成)はデプロイ時にすべてのページをビルドします。ISR(増分静的再生成)はデプロイ時にサブセットをビルドし、残りをオンデマンドで生成します。10,000ページ以上のサイトの場合、ISRは実質的に必須です — 完全なSSGビルドは合理的なデプロイサイクルにとって遅すぎることになります。

137,000の動的に生成されたページでSEOをどのように処理しますか? 3つが最も重要です:複数のファイルに分割されたサイトマップ生成、すべてのリスティングページでのユニークな構造化データ(JSON-LD)、ISR生成ページが適切なHTTP 200ステータスコード(ソフト404ではない)を返していることを確認します。また、リスティングデータを使用して各ページの一意のメタタイトルと説明を生成します — メタコンテンツは複製されていません。

本番環境でのスケールでのVercel ISRの信頼性はありますか? 当社の経験では、そうです。8か月以上このセットアップを実行しており、アップタイム99.98%を実現しています。唯一のインシデントは自己インフリクトでした — 検証Webhookを破った悪いデプロイ、および15分間の性能低下を引き起こしたSupabaseメンテナンスウィンドウ。Vercelのエッジキャッシュは確実です。

大規模なディレクトリにはAlgoliaまたはTypesenseを使用する必要がありますか? 予算に依存します。Algoliaは業界標準で最良の開発者エクスペリエンスですが、100Kレコードを超えると高くなります — 月額500〜1000ドル以上を期待します。Typesenseは、自己ホスト時に機能の90%を機能の一部でコストで提供します。当社はTypesenseを選択し、後悔していません。

137,000リスティングを最新に保つにはどうしますか? 複数のアプローチの組み合わせを使用します:個々のリスティングが変更されたときにSupabase Webhookによってトリガーされるオンデマンド検証、時間ベースのISR検証(毎時間)をセーフティネット、および陳腐なデータをチェックして一括検証をトリガーするしる毎晩のバッチジョブ。リスティング所有者は、ダッシュボードを通じてページのリフレッシュを手動でリクエストすることもできます。

このアーキテクチャは、Supabaseの代わりにヘッドレスCMSで機能させることができますか? はい、トレードオフがあります。ヘッドレスCMSセットアップ:SanityまたはContentfulなどが、コンテンツ管理側で機能しており、検索と複雑なクエリのためのデータベースが引き続き必要です。編集コンテンツがヘッドレスCMSに存在し、リスティングデータが Postgres に存在するハイブリッドアプローチでディレクトリプロジェクトを構築しました — これは有効なハイブリッドアプローチです。