how-i-built-25000-page-directory-nextjs.html
< BACK 薄暗いサーバールームの廊下。あたたかい琥珀色の光がスチールラックを照らし、奥へ向かってぼやけている

25,000ページのディレクトリをNext.jsで構築した方法:HostList事後分析

2022年の後半、ウェブホスティングディレクトリを構築することは簡単だと確信しました。データを集約し、ページを生成し、ランク付けし、収益化する。シンプルです。私は以前プログラマティックSEOを手がけたことがあります。イギリスのクライアント向けのローカライズされた不動産ツール、月間40,000訪問まで達したSaaS比較サイトなど。だからHostListは6週間のプロジェクトだと思いました。実際には7ヶ月近くかかりました。そしていくつかのものを壊しかけました。私の睡眠スケジュール、1人のジュニア開発者の自信、そして予算に入れていなかった月額180ポンドのVercel請求です。

これが事後分析です。LinkedInバージョンではなく、本当のものです。

---

自分自身に書いたブリーフ

HostListはシンプルであることになっていました。ウェブホスティングプロバイダーのディレクトリ(共有、VPS、専用、マネージドWordPress)で、各プロバイダーの個別ページ、比較ページ、カテゴリーページ、位置情報ベースのページ(例:「ドイツのベストホスティング」)が含まれます。計算してみてください。約400のプロバイダー×複数のページタイプ×20以上のフィルター組み合わせ。25,000ページに思ったより早く到達します。

私はほぼ思考を挟まずにNext.jsを選びました。Seahawkではより大規模なReactベースのビルドのほとんどでそれを使用しています。エコシステムは成熟し、getStaticPropsとgetStaticPathsはSEOに最適化した静的生成に適切で、個人的にはファイルベースのルーティングはこのスケールではRemixやGatsbyより理解しやすいと感じています。Next.jsalmost without thinking about it. We use it at Seahawk for most of our bigger React-based builds. The ecosystem is mature,getStaticPropsandgetStaticPathsmake sense for SEO-heavy static generation, and I personally find the file-based routing easier to reason about than Remix or Gatsby at this scale.

最初の真の決定はデータレイヤーでした。ヘッドレスCMSは割と早い段階で除外しました——25,000エントリ分のContentfulの料金を払いたくなかったし、一括プログラマティック書き込みをきれいに処理できるCMSを信用していませんでした。結局、SupabaseのPostgresデータベースに、その前に軽量なNext.js APIレイヤーを置く構成に落ち着きました。その部分は実際にうまく機能しました。複雑になったのはほぼそれ以外のすべてです。Supabase, with a lightweight Next.js API layer sitting in front of it. That part actually worked fine. It's almost everything else that got complicated.

---

スケール規模での静的生成:誰も警告してくれないこと

25,000ルートを持つgetStaticPathsについて、こういう話です。機能します。技術的には。しかし、ビルド時間はあなたの人生の選択を疑わせるでしょう。getStaticPathswith 25,000 routes. It works. Technically. But your build times will make you question your life choices.

最初の完全なビルドは4時間47分かかりました。Vercel上で。プランのリミットに注意しないと、午前2時の請求通知を引き起こすようなやつです。スマートフォンからそのSlackアラートを見つめながら、本気でWordPressを使うだけにしようかと考えました。

`fallback: 'blocking'`の罠

最初の本能は、すべてをプリレンダリングすることでした。すべてのページ、すべての組み合わせ。悪いアイデアです——そしてほとんどのチュートリアルが警告する理由ではありません(それはたいてい単に「時間がかかる」というだけです)。本当の問題はキャッシュの無効化です。ホスティング提供業者が料金を更新すると(そしてそれは常に起こります)、影響を受けたページを再ビルドする必要があります。ISRがないすべてが静的にプリレンダリングされている場合、25,000ページのうち多分30ページだけに影響するデータの変更に対して完全な再ビルドをトリガーしています。cache invalidation. When a hosting provider updates their pricing (and they do, constantly), you need to rebuild affected pages. If everything is statically pre-rendered with no ISR, you're triggering full rebuilds for data changes that affect maybe 30 pages out of 25,000.

revalidateを86400秒(24時間)に設定したIncremental Static Regenerationに切り替え、料金が多く含まれるプロバイダーページは3600秒にしました。これはプロジェクト全体を通じた最大のワークライフバランスの改善でした。ビルド時間は40分以下に短縮されました。トラフィック優先度の上位約2,000ページだけをプリレンダリングしていて、残りはfallback: 'blocking'でオンデマンド生成させていたからです。Incremental Static Regenerationwith arevalidateof 86400 seconds (24 hours) for most pages, and 3600 seconds for pricing-heavy provider pages. This was the single biggest quality-of-life improvement in the entire project. Build times dropped to under 40 minutes because we were only pre-rendering the top ~2,000 pages by traffic priority and letting the rest generate on-demand withfallback: 'blocking'.

ルートツリーの分割

異なるアプローチで対応すべき点が1つあります。Seahawk で大規模なプログラマティックプロジェクトに関わる全デベロッパーに伝えていることです。ルートツリーを早期に分割すること。25,000個のスラッグを返そうとする単一モノリシック getStaticPaths 関数を持つな、ということです。我々はそれを次のように分割しました:getStaticPathsfunction trying to return 25,000 slugs. We broke ours into:

  1. /providers/[slug] — 個別プロバイダーページ(約400)— individual provider pages (~400)
  2. /compare/[slugA]-vs-[slugB] — 横並び比較ページ(約8,000)— head-to-head comparison pages (~8,000)
  3. /category/[type] — カテゴリーランディングページ(約40)— category landing pages (~40)
  4. /location/[country]/[type] — 地域 × カテゴリー組み合わせ(約16,000以上)— geo × category combinations (~16,000+)
  5. /best/[use-case] — キュレーションリストページ(約600)— curated list pages (~600)

各ルートグループは独自のリバリデーション周期、独自のデータ取得ロジック、そして重要なのは独自のビルド優先度を持ちます。ロケーションページはほぼすべてオンデマンド。プロバイダーページは常にプリレンダリング。明確な分離です。

---

データパイプラインの混乱(と我々がどう修正したか)

2023年初頭、私は HostList のデータ収集側を構築する際に大きな過ちを犯しました。Python で書かれたスクレイピングスクリプト(BeautifulSoup と Webshare のローテーティングプロキシプールを使用)、手動の Google Sheet で修正、そして Supabase テーブル。3つの情報源がありました。どれも互いに適切に連携していません。

ジュニア開発者が — 良い子なんだが、ブートキャンプを出たばかり — SheetとSupabaseの同期スクリプトを3週間メンテナンスしていたんだ。カラム名が変わるたびに壊れるやつ。1週間目に完全にSheetを潰して、ちゃんとした内部管理UIを作るべきだった。最終的には Next.js API ルートと Retool ダッシュボードを使って作ったけど、そこに辿り着くのにエンジニアリング時間を60時間くらい費やしてしまった。

解決策:常に単一の情報源を持つ。データベースが正規版だ。すべてがデータベースに書き込まれる。管理UIはデータベースから読み書きする。スクレイパーはデータベースに書き込む。当たり前に聞こえる。後付けで見ると、いつもそうなんだ。one source of truth, always. The database is canonical. Everything writes to the database. The admin UI reads from and writes to the database. The scraper writes to the database. Sounds obvious. It always does, in hindsight.

スケール時のデータ鮮度を保つ

このサイズのディレクトリでは、データの鮮度はUXの問題と同じくらいSEOの関心事だ。Googleはプライシングテーブルがのままずっと £2.99/月 を表示していて、実は8ヶ月前から £5.99 になってることに気づく。こう構築した:

  • Railway クロンで動く週次スクレイプジョブ(安い、信頼性が高い、専用サーバーが不要)
  • price_updated_at カラムが変わったときに発火する Supabase データベースウェブフックで、Next.js リバリデーションエンドポイントを叩くprice_updated_atcolumn changes, hitting a Next.js revalidation endpoint
  • Retool内の手動オーバーライドフラグ(スクレイパーを積極的にブロックしてる約30のプロバイダ用)

そのリバリデーションエンドポイント — /api/revalidate?secret=TOKEN&path=/providers/siteground — は Next.js の標準機能だが、データベースウェブフックに接続するのにちょっと配管工事が要った。やった甲斐はある。/api/revalidate?secret=TOKEN&path=/providers/siteground— is a stock Next.js feature, but wiring it to a database webhook took a bit of plumbing. Worth every minute.

---

SEOアーキテクチャ:実際に針を動かしたもの

コンテンツサイトを十分に構築してきた経験から、25,000ページを持つことと25,000ページがランクインすることは別だと知っている。比較ページはその罠だった。約400プロバイダーのあらゆるA対Bの組み合わせを生成し、理論上約79,800のペアリングを得た。そのうち約8,000ページを構築した。そしてほとんどのページは、率直に言ってシンだった。

正直に告白すると、私は欲張ってしまった。SEOのロジックは健全だった。「SiteGround vs Bluehost」は実際の検索ボリュームを獲得し、比較クエリのロングテールは膨大である。しかし、個々のページの存在を正当化するために十分なユニークコンテンツを構築しなかった。Googleが比較セクションをクロールし始め、明らかにそれは自分の時間を費やす価値がないと判断した。シンコンテンツに関するGoogleの独自のガイダンスはこれについて率直であり、私はより早く自分自身に対してより率直であるべきだった。Google's own guidance on thin contentis blunt about this, and I should have been blunter with myself earlier.

回復のためにしたこと

削除した。比較ページを約8,000から約1,200に削減した。検証可能な検索ボリュームを持つペアのみ(Ahrefsで検証済み、最小月間グローバル検索数50)。その後、残りのページを以下で充実させた。

  • 構造化されたプロバイダーデータから引き出した動的な「誰に最適か」セクション
  • 実際の稼働時間データ(サードパーティの稼働時間APIと統合)
  • Trustpilotデータから利用可能なユーザーレビューサマリー

その結果、8,000ページの役に立たないものではなく、1,200ページの実際に有用なものになった。比較セクションへのオーガニックトラフィックは続く3か月間で340%増加した。直感に反するまでは直感に反する。

このスケールでの内部リンク

25,000ページでは、内部リンクを手動で行うことはできない。Supabaseをビルド時に(getStaticPropsで)クエリし、カテゴリーと場所のオーバーラップに基づいて最も関連性の高い隣接ページ5つを返す関連ページコンポーネントを構築した。編集の介入は必要ない。完璧ではない。時々VPSホスティングページが少し横道に逸れたものにリンクすることがある。しかし90%は正しく、初日から各ページが文脈的に関連のある内部リンクを持つことを意味した。getStaticProps) and returns the five most relevant adjacent pages based on category and location overlap. No editorial intervention needed. It's not perfect — occasionally a VPS hosting page links to something a bit sideways — but it's 90% right, and it meant every page had contextually relevant internal links from day one.

パフォーマンス:あなたを謙虚にさせる部分

パフォーマンス:あなたを謙虚にさせる部分

静的生成ならパフォーマンスが簡単になると思うだろう。概念的には、そうだ——事前レンダリングされたHTML、Vercelの CDN でエッジキャッシュされ、サーバーレンダリングのオーバーヘッドなし。だが25,000ページというのは、コンポーネントツリーについて悪い判断を下す25,000の機会を意味する。

最大のパフォーマンス問題はプロバイダー比較表だった。重いクライアント側の React コンポーネント——状態が多く、条件付きレンダリングが多く、プロバイダーページと比較ページの両方で使用されていた。モバイルでは、Largest Contentful Paint が約4.8秒に達していた。悪い。本当に悪い。購入決定の真っ最中のユーザーが主要トラフィックのサイトにとって。Largest Contentful Paintof around 4.8 seconds. Bad. Really bad for a site where the primary traffic is people mid-decision on a purchase.

これをサーバーレンダリングの静的テーブルに再構築し、インタラクティブなフィルター機能用に薄い React ハイドレーションレイヤーを追加した。LCP は1.9秒に低下した。これは魔法ではない——退屈なことを正しくやるだけだ。

画像の問題

すべてのプロバイダーはロゴを持っている。400個のロゴ、スクリーンショット、UI プレビュー、機能アイコン。最初の2ヶ月間、Vercel の組み込み画像最適化でこれらをホストするという過ちを犯した。帯域幅コストは静かに恐ろしかった。すべてを Cloudflare R2 にカスタムドメインで移動し、Vercel の請求を月180ポンドから月40ポンドに削減した。画像が多いものを構築するなら、Cloudflare R2 を早めに検討するべきだ——無料の出力は規模では本当に役に立つ。Cloudflare R2early — the free egress is genuinely useful at scale.

---

ビルドパイプラインが今実際どのようになっているか

具体的な状況を知りたい人向けに:

  1. データ収集 — Railway のクロンジョブ上の Python スクレイパー、Supabase Postgres に書き込み— Python scraper on a Railway cron job, writes to Supabase Postgres
  2. 管理層 — 手動編集、修正、プロバイダフラグ用の Retool ダッシュボード— Retool dashboard for manual edits, corrections, and provider flags
  3. Next.js アプリ — Pages ルーター(App Router が十分に安定する前に開発を始めたため)、Vercel にデプロイ— Pages router (we started before App Router was stable enough to trust), deployed on Vercel
  4. ISR + オンデマンド再検証 — トップ約2,000ページを事前ビルド、残りはオンデマンド、すべて24時間再検証— top ~2,000 pages pre-built, rest on-demand, all with 24h revalidation
  5. 画像 — Cloudflare R2、カスタムサブドメイン経由で Cloudflare CDN を前段として配信— Cloudflare R2, served via a custom subdomain with Cloudflare CDN in front
  6. 分析 — プライバシー重視のトラフィックデータには Plausible、ランキング追跡には Ahrefs— Plausible for privacy-friendly traffic data, Ahrefs for ranking tracking
  7. 稼働状況監視 — BetterUptime でトラフィックが最も多い5つのページタイプを監視— BetterUptime watching the five most traffic-heavy page types

これは派手ではありない。また、保守もほぼ退屈だが、それが3年間走らせ続けるインフラストラクチャに求めるものだ。

---

正直な失敗、番号付き

  1. 範囲が広すぎるところから始めた。25,000ページが最終的な目標だったが、500ページの高品質なコンテンツでローンチして段階的に拡大すべきだった。代わりに全てをローンチしてしまい、最初の4ヶ月間Googleのクローラー予算の問題に直面した。25,000 pages was always the goal, but I should have launched with 500 high-quality pages and expanded. Instead I launched with everything and had a Google crawl budget problem for the first four months.
  2. 初日からrevalidationを適切に設定しなかった。ISRで不要になっていたはずのフルリビルドに2ヶ月を無駄にした。We wasted two months on full rebuilds that ISR would have made unnecessary.
  3. Google Sheetを使い続けた。シングルソースオブトゥルースは初週から譲れない要件にすべきだった。Single source of truth should have been non-negotiable from week one.
  4. 比較ページの品質を過小評価した。ボリュームは戦略ではない。Volume is not a strategy.
  5. Vercelの画像最適化を長く使い続けた。R2への移行は6週間遅れていた。Moved to R2 six weeks later than we should have.
  6. ルートツリーの分割が早すぎなかった。高速ルートと低速ルートを同じgetStaticPathsコールに混在させてから、ビルドが遅い理由に気づいた。Mixed fast and slow routes in the samegetStaticPathscall and then wondered why builds were slow.

これらはすべて、その時点では合理的に思える判断だった。チュートリアルが捉えられない部分はここだ—悪いアーキテクチャ決定は通常、その時点での判断には良さそうな正当化理由を持っている。

---

FAQ

初回ビルドでローンチするまでにどのくらいかかった?

最初のコミットから v1 と言える状態まで 7 ヶ月。最初の粗いパブリック版は 4 ヶ月目辺りで公開されていましたが、コンテンツ量が深刻に不足していて、比較セクションはほぼ使い物になりませんでした。「技術的には公開」まで 4 ヶ月、そこから「実際に使える」状態まであと 3 ヶ月といったところです。

今から始めるなら App Router を使いますか?

2023 年後半以降に始める新規プロジェクトであれば、おそらくはい。App Router のサーバーコンポーネントは、この種のデータ量が多いページ生成に実際に適しています。ただし、既存の 25,000 ページの Pages Router アプリを移行するのは、近い将来取り組むプロジェクトではありません。Pages Router はまだ動いていますし、「動く」というのは過小評価されているんです。

ビジネスを辞めたり、サービス内容を大きく変更したプロバイダーにはどう対応していますか?

データベースにステータスフラグを持たせています — アクティブ、非推奨、リダイレクト。非推奨のプロバイダーは完全削除ではなく、簡潔なアーカイブページが表示されるので、バックリンクが保持されます。リダイレクト対象のプロバイダー(例えば、あるホストが別のホストを買収した場合)は next.config.js の Next.js redirects 設定で 301 を処理しています。ステータスフラグは月 1 回確認しています。statusflag in the database —active,deprecated,redirected. Deprecated providers get a slim archive page rather than a full removal, which preserves any backlinks. Redirected providers (e.g. when one host acquires another) get a 301 handled via the Next.jsredirectsconfig innext.config.js. We review the status flags monthly.

もう一度やるなら Next.js の代わりに何を使いますか?

正直なところ分かりません。Astro は大部分が静的なコンテンツサイトとしては興味深いし、小規模なプロジェクトで試してみています。ただ Next.js は、同じコードベース内に静的セクションと動的セクションの両方を持つ柔軟性をもたらしてくれたし、それは重要でした。完全に静的なディレクトリでインタラクティブな機能がなければ、Astro のほうが構築が速く、実行コストが安いかもしれません。1 年後にまた聞いてください。

スクレイパーがディレクトリ全体をコピーするのを防ぐには?

正直なところ、完全には防げません。API ルートをレート制限して、フロントエンドで Cloudflare のボット管理を使い、構造化データの一部をローテーションしてスクレイプされたコピーがすぐに古くなるようにしています。ただ、公開されているディレクトリをクローンしたいと思う人がいれば、必ず方法を見つけるでしょう。競争優位性は技術的な難読化ではなく、データの鮮度と UX の品質なんです。

---

最後に

HostListは爆発的な成功を収めているわけではありません。アフィリエイト手数料や数件の直接広告契約で収入を得ており、当初ターゲットにしていた600程度の検索キーワードで合理的なランキングを達成しています。これで十分です。収益も生み出すラーニングプロジェクトでしたが、これ以上ない形だと思います。

大規模なプログラマティックSEOサイトをNext.jsで構築することを検討しているなら、率直なアドバイスはこうです。やるべきです。本当に優れたスタックです。ただし、必要だと思うよりも少なく構築し、時間があると思うよりもずっと丁寧に構築し、ページテンプレートを一つ書く前にデータアーキテクチャを整理してください。

技術の部分は簡単です。いつもそうです。

< BACK