Next.jsとVercel ISRを使用して137Kリスティングディレクトリプラットフォームを構築した方法

昨年、ディレクトリプラットフォームを立ち上げました。137,000件のリスティング。これは単なる小さな取り組みではありませんでした。すべてのリスティングが独自のSEO最適化ページを持つ完全に実現されたプラットフォームでした。検索は俊敏である必要があり、そしてはい、ホスティングは手頃な価格に保つ必要がありました。では、Next.js、Vercel、およびIncremental Static Regeneration(ISR)でどうやってそれを実現したのでしょうか?シートベルトを締めてください。ここがその物語です。トリッキーな部分も含めて。

How We Built a 137K Listing Directory Platform with Next.js and Vercel ISR

目次

ディレクトリプラットフォームが見た目ほど簡単ではない理由

ディレクトリサイトは簡単に見えるかもしれません。リストページ、詳細ページ、いくつかのフィルターを加えるだけで完成だと思うかもしれません。しかし、数千件のリスティングを超えると、すべてが複雑さへと螺旋状に進みます。

実際に何が起こっているかは次の通りです:

  • 137,000以上のユニークなページで、それぞれがクローラブルでインデックス可能である必要があります
  • ファセット検索は場所、カテゴリなど複数にわたります
  • 古いデータの管理 -- リスティングは更新と削除で常に変わっています
  • SEO要件とは、クライアント側のレンダリングだけには頼られないことを意味します
  • 予算内でのホスティングは、ビルド時にすべてのページを生成することを排除します

多くの方法を検討した後、ISRを備えたNext.jsが私たちの最初の選択肢になりました。Astroも検討しました(他の取り組みで使用されています。Astro開発作業を参照)。最終的には、ISRを備えたNext.jsの動的機能が理にかなっていました。

アーキテクチャ概要

アーキテクチャは次のようになっています:

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   Vercel      │────▶│  Next.js App │────▶│  PostgreSQL  │
│   Edge CDN    │     │  (ISR)       │     │  (Neon)      │
└──────────────┘     └──────────────┘     └──────────────┘
                            │                     │
                            ▼                     ▼
                     ┌──────────────┐     ┌──────────────┐
                     │  Redis       │     │  Meilisearch │
                     │  (Upstash)   │     │  (Cloud)     │
                     └──────────────┘     └──────────────┘

スタック

コンポーネント テクノロジー 理由
フレームワーク Next.js 14 (App Router) ISRサポート、React Server Components、ルートハンドラー
ホスティング Vercel Pro Edge CDN、ISRインフラストラクチャ、分析
データベース Neon PostgreSQL サーバーレスPostgres、プレビューブランチ
検索 Meilisearch Cloud タイポトレランス、ファセット検索、高速インデックス
キャッシュ Upstash Redis レート制限、セッションキャッシュ、ISRコーディネーション
CMS(管理) カスタム管理 + Payload CMS リスティング管理、一括操作
CDN/画像 Vercel Image Optimization + Cloudinary 複数のブレークポイントでのリスティング写真

これは本質的にNext.js開発プロジェクトであり、ISRが大きな売りでした。

How We Built a 137K Listing Directory Platform with Next.js and Vercel ISR - architecture

スケールで実際に機能するISR戦略

単刀直入に言いましょう:ビルド時に137,000ページを静的に生成しようとすれば、頭痛を買っています。本当に、その苦労を招かないでください。Next.jsの並列生成であっても、ビルドは45分以上かかる可能性があり、すべてのデプロイメントが悪夢になります。

ISRを使うと、必要に応じてページを生成してエッジにキャッシュできます。デフォルトISRは素晴らしいですが、私たちにとっては調整が必要でした。

3層ページ戦略

リスティングを3つの層に分割しました:

// app/listing/[slug]/page.tsx

export async function generateStaticParams() {
  // 層1:トップ2,000件の高トラフィックリスティングを事前生成
  const topListings = await db.listing.findMany({
    where: { tier: 'premium' },
    orderBy: { monthlyViews: 'desc' },
    take: 2000,
    select: { slug: true },
  });

  return topListings.map((listing) => ({
    slug: listing.slug,
  }));
}

// 層2と層3:ISRを介したオンデマンド生成
export const revalidate = 3600; // ほとんどのリスティングで1時間

export default async function ListingPage({ params }: { params: { slug: string } }) {
  const listing = await getListingBySlug(params.slug);

  if (!listing) {
    notFound();
  }

  // リスティング層に基づいた動的再検証
  // プレミアムリスティングは10分ごとに再検証
  // 標準リスティングは1時間ごとに
  // アーカイブされたリスティングは24時間ごとに

  return <ListingDetail listing={listing} />;
}

層1(2,000ページ): これらの高トラフィックリスティングはビルド時に事前生成されます。それらはほとんどのオーガニック検索トラフィックの原因です。常に準備完了です。

層2(35,000ページ): 最初にリクエストされたときに生成され、1時間キャッシュされます。これらのリスティングは安定したトラフィックを持つため、キャッシュ期限後の最初の訪問者はサーバーレンダリングされたが高速なページを取得します。その他のユーザーはキャッシュバージョンを取得します。

層3(100,000ページ): 最初のリクエスト時に生成され、24時間キャッシュされます。これらのリスティングはほぼアクションがないため、リソースを無駄にする必要はありません。

リアルタイム更新のためのオンデマンド再検証

ほとんどの場合は時間ベースの再検証でカバーされていますが、レストランのオーナーが営業時間を更新したばかりの場合はどうでしょうか?さて、私たちはルートハンドラーを使用してNext.jsのオンデマンド再検証を実装しました:

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } 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: 'Invalid secret' }, { status: 401 });
  }

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

  if (type === 'listing') {
    revalidatePath(`/listing/${slug}`);
    revalidateTag(`listing-${slug}`);
  } else if (type === 'category') {
    revalidateTag(`category-${slug}`);
  }

  return NextResponse.json({ revalidated: true, now: Date.now() });
}

管理パネルとウェブフックがこのエンドポイントと通信していれば、リスティング変更者は次のリクエストで新しいページを取得します。高速ですね?

137Kページをビルド時間を爆発させずに処理する

ビルド時間は正直なところ怖かったです!私たちが見つけたもの:

戦略 ビルド時間 最初のリクエストレイテンシ キャッシュヒットレイテンシ
フルSSG(すべての137Kページ) 約52分 約40ms 約40ms
ISR(2K事前構築) 約3.5分 約180ms(コールド) 約40ms
フルSSR(キャッシュなし) 約45秒 約250ms N/A
ハイブリッドアプローチ 約3.5分 約150ms(コールド) 約35ms

ISRアプローチは、苦痛な1時間からわずか4分以下にビルド時間を短縮しました。それはデプロイメントを恐れることと、まあ、実行中にコーヒーを飲むことの違いです。

`dynamicParams`設定

重要なヒントはここです:generateStaticParamsの外部でISRがページを生成できるようにするには、dynamicParams = trueを保つのです。明らかに聞こえるかもしれませんが、これがどのくらい頻繁に見落とされるか驚くでしょう。

export const dynamicParams = true; // オンデマンド生成を許可

平行ルートセグメント

カテゴリと場所を持つページの場合、フィルターとリスティンググリッドが独立してロードできるように平行ルートセグメントを活用しました:

// app/directory/[category]/layout.tsx
export default function CategoryLayout({
  children,
  filters,
}: {
  children: React.ReactNode;
  filters: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-[280px_1fr] gap-6">
      <aside>{filters}</aside>
      <main>{children}</main>
    </div>
  );
}

つまり、フィルター自体をキャッシュできます。フィルターを変更すれば、リスティンググリッドのみが再レンダリングされます。俊敏!

データベースと検索層

Neon上のPostgreSQL

スケーリングやプレビューブランチなどのサーバーレス利点についてNeonを選択しました。私たちの人生を楽にしてくれたような種類のもの。

リスティングテーブルは単純ですが、インデックス作成に大きく依存しています:

CREATE INDEX idx_listings_category ON listings(category_id);
CREATE INDEX idx_listings_location ON listings USING GIST(location);
CREATE INDEX idx_listings_rating ON listings(avg_rating DESC);
CREATE INDEX idx_listings_slug ON listings(slug);
CREATE INDEX idx_listings_status_tier ON listings(status, tier);

場所のGiSTインデックスはなぜですか?正確な地理空間クエリについてすべてです。「近くのコーヒーショップ」は単なるたわごとではなく、実際の計算です。

検索のためのMeilisearch

リストが私たちのように膨張していれば、PostgreSQLのテキスト検索は十分ではなく、そこがMeilisearchの出番です。主に価格(Algoliaは30ドル/月対200ドル以上)とその印象的なタイポトレランスについて私たちを破りました。

// lib/search.ts
import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST!,
  apiKey: process.env.MEILISEARCH_API_KEY!,
});

export async function searchListings(query: string, filters: FilterParams) {
  const index = client.index('listings');

  return index.search(query, {
    filter: buildFilterString(filters),
    facets: ['category', 'city', 'priceRange', 'rating'],
    limit: 24,
    offset: filters.page * 24,
    attributesToHighlight: ['name', 'description'],
  });
}

5分ごとに、リスティングはジョブと同期します。念のため週に1回完全に再インデックスしています。安全な方が良いですね?

スケーリングのSEO:サイトマップ、構造化データ、およびクローリング予算

137,000ページを持つプラットフォームの場合、SEOは単なるナイスツーハブではなく、それは死活問題です。ここがその方法です:

動的サイトマップ

1つのサイトマップファイルに137,000個のURLをダンプすることはできません。仕様によると制限は50,000URLです。だから、私たちは何をしますか?セグメント化されたピースを指す、サイトマップインデックスを生成します:

// app/sitemap.ts
import { MetadataRoute } from 'next';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // これはサイトマップインデックスを生成します
  const totalListings = await db.listing.count({ where: { status: 'active' } });
  const sitemapCount = Math.ceil(totalListings / 10000);

  const sitemaps = [];

  for (let i = 0; i < sitemapCount; i++) {
    sitemaps.push({
      url: `${process.env.NEXT_PUBLIC_URL}/sitemap/${i}.xml`,
      lastModified: new Date(),
    });
  }

  return sitemaps;
}

セグメント化されたサイトマップは、それぞれ10,000リスティングを含んでいます。Googleは約8,000~12,000ページを毎日掘っています。

構造化データ

すべてのリスティングページには、LocalBusinessスキーママークアップが詰め込まれています:

function generateStructuredData(listing: Listing) {
  return {
    '@context': 'https://schema.org',
    '@type': 'LocalBusiness',
    name: listing.name,
    description: listing.description,
    address: {
      '@type': 'PostalAddress',
      streetAddress: listing.address,
      addressLocality: listing.city,
      addressRegion: listing.state,
      postalCode: listing.zip,
    },
    aggregateRating: listing.reviewCount > 0 ? {
      '@type': 'AggregateRating',
      ratingValue: listing.avgRating,
      reviewCount: listing.reviewCount,
    } : undefined,
    geo: {
      '@type': 'GeoCoordinates',
      latitude: listing.lat,
      longitude: listing.lng,
    },
  };
}

このような構造化データはランキングを加速させ、Googleが正確な情報を大きく優先します。

パフォーマンスベンチマーク

2025年初期現在のライブサイトからの実メトリクス:

メトリック ターゲット
最大コンテンツful Paint (LCP) 1.1s (p75) < 2.5s
最初の入力遅延 (FID) 12ms (p75) < 100ms
累積レイアウトシフト (CLS) 0.02 (p75) < 0.1
最初のバイトまでの時間 (TTFB) 85ms(キャッシュ) / 190ms(コールドISR) < 200ms
Lighthouseパフォーマンススコア 94-98 > 90
ビルド時間 3分22秒 < 5分
キャッシュヒット率 94.7% > 90%

そのキャッシュヒット率が高いですか?はい、ページの94.7%がVercelのエッジCDNから直接来ています。追加の計算は必要ありません。スピードとコストの両方にとって勝利です。

Vercelでのコスト内訳

ドルセントに手に入れましょう。良い割引について話すことに誰が嫌いですか?

サービス 月額コスト(2025) 注記
Vercel Pro $20/席 プロレベルの機能と制限の場合
Vercel帯域幅 約$55 ISRキャッシュを持つ約600GB/月
Vercelサーバーレス関数 約$40 ISR作業 + API用
Neon PostgreSQL $19(スケールプラン) 10GB ストレージ、スケーラブルコンピュート
Meilisearch Cloud $30 500K docs、専用インスタンス
Upstash Redis $10 1日平均10Kコマンド
Cloudinary $25 画像ストレージと変換
合計 約$199/月 137Kページ、月間約200K訪問者の場合

137,000ページを持つビーストを実行するのに月額$200未満。従来のサーバーセットアップと比較して?VM、管理DBs、CDNs、およびそれをすべて面倒を見るフルタイムDevOpsでお金を流血させるでしょう。

このスケールでプレイしていて、チャットをしたい場合は、お問い合わせください。または価格を見てください。

我々が犯した過ちと変更したいこと

過ち1:初日からオンデマンド再検証をセットアップしなかった

当初、時間ベースの再検証だけに依存していました。判りますか、悪い動きです。リスティングオーナーは彼らの情報を調整してすぐにチェックします。古いデータを見ていますか?自信ブースターではありません。再検証はMVPである必要がありました。

過ち2:サイトマップの複雑さを過小評価した

サイトマップの最初の試みは、すべてを1つのサーバーレス関数に詰め込みました。キューのタイムアウト。Vercelはタイムアウトする前に10秒(Pro では60秒)を与えます。私たちは学びました。それらをセグメント化してください。

過ち3:画像最適化コスト

当初、Vercelはすべてのリスティング写真最適化を処理しました。画像の大量は、多くのコストを意味しました。Cloudinaryとその義務を分割し、Vercelの魔法をUI必需品のために予約しました。

過ち4:React Server Componentsを十分に積極的に使用しなかった

初期ページの一部には、多すぎる'use client'コマンドが詰め込まれていました。結果?配送された過度なJavaScript。サーバーコンポーネントに再フォーカスすると、JavaScriptバンドルは羽毛のように軽くなります(62%削減!)。

今度は何が違うか

次回は、Next.jsを確実にPayload CMSのようなものとペアにしますが、スクラッチから管理パネルをハッキングする代わりに初日から。その時間節約がどんだけ大きかったことか!

また、標準ISRキャッシュを超えたクエリ結果について、Vercelの最新のunstable_cache(または今ではcache)を密接に考慮してもいいでしょう。

FAQ

Next.js ISRは本当に数十万ページを処理できますか?


絶対に。我々は歩いて歩みました。generateStaticParamsを使用してトップトラフィックページ(通常1~5%)を事前生成し、残りをISRに任せてください。Vercelのエッジはそこから引き継ぎ、グローバルに高速ロード時間を保証します。

Vercelで大規模ディレクトリサイトを実行するのにどのくらいの費用がかかりますか?


私たちにとって、月あたり約$199で137Kリスティングおよび月間200,000訪問者です。費用は変わることはありますが、その素敵なキャッシング歩幅を打つと、ISRは大きく節約できます。

ISRとディレクトリサイトのSSRの違いは何ですか?


ISRは再検証間隔ごとに1回ページを生成してキャッシュしますが、SSRはすべてのリクエスト時にスクラッチからページを生成します。リスティングデータが毎分変わらないシナリオではISRの方が効率的です。

静的に生成されたディレクトリで検索をどのように処理しますか?


検索インタラクションはMeilisearchに直接進み、APIコールで対応します。検索結果はクライアント側でレンダリングされ、一方、リスティングページはISRでサポートされています。これは静的と動的の最高の組み合わせです。

ディレクトリサイトのISRに使用する再検証間隔はどのようにすべきですか?


変更頻度によってですね。層状アプローチを使用します:プレミアムに10分、標準に1時間、静かなリスティングに24時間。即座の変更のためのオンデマンド再検証を散らしてください。

タイムアウトなしで137,000ページのサイトマップをどのように生成しますか?


セグメント化があなたの友人です。それらを10,000チャンクに切り分けてください。サイトマップインデックスを通じてルート化してください。各チャンクはタイムアウト制限内に快適に留まるべきです。

ディレクトリプラットフォームの構築に最適なフレームワークはNext.jsですか?


はい、重いトレーダーの場合、特にISR付きです。超シンプルで、めったに変わらないリストですか?Astroは軽量オプションになります。両方を作成しました。選択はワークロードとニーズに基づいています。

ISRでユーザーエクスペリエンスを傷つけるスタイルデータを防ぐにはどうしますか?


時間ベースとオンデマンド再検証をブレンドしたいです。つまりSWRまたはReact Queryを使用したクライアント側で超新鮮なデータペアを作ります。ISRはシェルを与えている間、リアルタイムは選択的に光ります。