Next.js ISRで137K件のリスティングをVercelの予算を爆発させずに配信する方法
デプロイが夜11時に開始されます。Vercelのビルドログが10,000パス、50,000パスを超えて、89,000近くでスタールするのを見ていました。6時間後、ビルドはタイムアウトしました。137,000件のリスティングディレクトリは、ビルド時にすべてを事前レンダリングしようとしたため配信されませんでした — 11日と非常に気まずいクライアント電話を費やした誤りです。最終的に、数百万のページビューを提供し、数千の長尾キーワードでランキングし、月額209ドルでオンデマンドでページを再生成する本番システムを配信しました。これを可能にしたアーキテクチャには、すべてを事前レンダリングする本能を殺し、ISRの下でSupabaseクエリがどのようにスケールするかを再考し、応答時間を340ms削減した1つのVercel設定変更が必要でした。実際に機能したものを紹介します。
スタック: Next.js 14 (App Router)、Supabase (PostgreSQL + Edge Functions)、Vercel (ホスティング + ISR)、および健全な量の実用性。私たちは間違いを犯しました。私たちは壁にぶつかりました。完成していると思ったものを書き直しました。しかし最終的なアーキテクチャは、137,000以上の動的ページを200ms未満のグローバルTTFBで処理し、Supabaseの請求は月額100ドル未満に抑えられています。
マーケットプレイス、ディレクトリ、リスティングプラットフォームのような何かを構築している場合、これは私たちが始めたときに存在していたことを望む記事です。
目次
- このスタックを選んだ理由
- データレイヤー: スケール時のSupabase
- ページ生成戦略: ISR、SSG、および137K問題
- URLアーキテクチャとスケール時のSEO
- 検索とフィルタリング: 難しい部分
- パフォーマンス予算とエッジキャッシング
- 本番環境での監視と可観測性
- コスト分析: これが実際にいくらかかるか
- 別の方法でする
- FAQ

このスタックを選んだ理由
Next.js + Supabase + Vercelに落ち着く前に、多くのオプションを評価しました。主な要件は:
- 137,000以上の一意のページで、検索エンジンが実際にクロールしてインデックス付けできます
- 1秒未満のページ読み込みグローバル (40以上の国のユーザー)
- 動的データ — リスティングは日単位で更新され、一部は時間単位で更新されます
- フルテキスト検索ファセット化されたフィルタリング付き
- 予算に優しい — これはVC資金のムーンショットではありませんでした
Astro (静的サイトに最適ですが、より多くの動的相互作用が必要でした)。WordPress + WPEngineを見ました。Algoliaを使用した純粋なSPAについて簡単に検討しました。
Next.jsが勝ったのは1つのキラー機能のためです: 増分静的再生成。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アプローチ (実際に機能するもの)
ここで配信された戦略は:
- ビルド時に: トップ 5,000 ページ (トラフィック別) を静的に生成
- 最初のリクエスト時に: 残りのページをオンデマンドで生成してキャッシュします
- 再検証: 時間ベース (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 |

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,000URLです。
// 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 は 6 週間以内に送信された URL の 98% をインデックス付けしました。
構造化データ
各リスティングページには 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 検索がすべてを処理しました。137K 行の GIN インデックスで十分に高速です。平均クエリ時間は 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エッジネットワーク
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アナリティクス: Core Web Vitals、リアルユーザーモニタリング
- Sentry: エラートラッキング (1日に約50のエラーをキャッチします、ほとんどはボットがゴミを送信から)
- Supabaseダッシュボード: データベースパフォーマンス、クエリ分析
- Checkly: 合成監視、重要なパスで5分間隔
- Google Search Console: インデックスカバレッジ、クロール統計
設定した最も価値のあるモニタリングは、インデックス付きページ数と活動中のリスティングの総数を数える毎日のSupabaseクエリでした。比率が95%未満に低下した場合、アラートが発生します。これは、悪い変更をデプロイしてから24時間以内にサイトマップリグレッションをキャッチしました。
コスト分析: これが実際にいくらかかるか
人々は常にコストについて尋ねます。2026年Q1現在の実際の月額支出は:
| サービス | プラン | 月額費用 |
|---|---|---|
| Vercel | Pro | $20 |
| Vercel帯域幅 (追加) | 従量制 | ~$35 |
| Supabase | Pro | $25 |
| Supabaseデータベース (計算) | 小規模インスタンス | $48 |
| Typesense (Hetzner) | CX31 | $48 |
| Checkly | スターター | $7 |
| Sentry | チーム | $26 |
| ドメイン + DNS (Cloudflare) | 無料層 | $0 |
| 合計 | ~$209/月 |
月額約200ドルで137,000ページを配信し、数百万の月次ページビューを提供しています。従来のサーバーセットアップでWordPressを実行してそれを試してください。
同様のプロジェクトを検討しており、このようなアーキテクチャがバジェットにどのようにマップするかを理解したい場合は、ディレクトリとマーケットプレイスプロジェクトのスコープを設定する方法について説明しています。
別の方法でする
初日からISRで始めてください。 完全なSSGが機能するようにしようとして2週間を無駄にしました。数学が加算しないことを受け入れる前に。
最初からTypesenseを使用します。 Postgres FTSは早期には問題ありませんでしたが、プロジェクトの途中での検索の移行は破壊的でした。月額48ドルはローンチから価値があったはずです。
データ検証にもっと早く投資してください。 137Kのリスティングをさまざまなソースからインポートする場合、データ品質は悪夢でした。最初のインポート後ではなく、最初のインポート前により厳密なZodスキーマと検証パイプラインを構築すべきでした。
本番環境の実際のデータボリュームでステージングをテストしてください。 ステージング環境には500のリスティングがありました。500行で正常に機能したクエリは137Kで落ちました。現在、本番環境データの20%のランダムサンプルでステージングをシードしています。
同様のディレクトリまたはマーケットプレイスビルドを計画しており、これらの同じ落とし穴を回避したい場合は、チームに連絡してください。これを十分な回数を通過して、地雷がどこにあるかを知っています。
FAQ
100K+ リスティングディレクトリを Next.js で構築するのにどのくらい時間がかかりますか?
私たちのチームでは、初期アーキテクチャとコア機能には約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生成ページが適切なHTTP200ステータスコード (ソフト404ではなく) を返すことを確認します。また、リスティングデータを使用して各ページの一意のメタタイトルと説明を生成します。
Vercel ISRは本番環境のスケール時に信頼できますか?
私たちの経験では、はい。8ヶ月以上99.98%のアップタイムでこのセットアップを実行しています。唯一のインシデントは自分自身で引き起こされました — 再検証webhookを壊した悪いデプロイ、および15分の機能低下を引き起こしたSupabaseメンテナンスウィンドウ。Vercelのエッジキャッシュは非常に堅牢です。
大規模なディレクトリ用にAlgoliaまたはTypesenseを使用する必要があります?
それはあなたの予算によります。Algoliaは業界標準で最高の開発者体験を備えていますが、100K以上のレコードで高くなります — 月額500〜1000ドル以上を期待してください。Typesenseはセルフホストの場合、機能の90%をコストのほんの一部で提供します。Typesenseを選んで後悔していません。
137,000のリスティングを最新の状態に保つにはどうすればよいですか?
アプローチの組み合わせを使用します: 個々のリスティングが変更されるとSupabase webhooksによってトリガーされるオンデマンド再検証、安全ネットとしての時間ベースのISR再検証 (毎時)、および古いデータをチェックして一括再検証をトリガーする毎晩のバッチジョブ。リスティング所有者は、ダッシュボードからページリフレッシュを手動でリクエストすることもできます。
このアーキテクチャはSupabaseの代わりにヘッドレスCMSで機能しますか?
はい、しかしトレードオフがあります。SanityまたはcontentfullなどのヘッドレスCMS設定はコンテンツ管理側でうまく機能しますが、検索と複雑なクエリのためにデータベースが必要になる可能性があります。編集コンテンツがヘッドレスCMSに存在し、リスティングデータがPostgresに存在するハイブリッドアプローチでディレクトリプロジェクトを構築しました — それは有効なアプローチです。