how-i-built-25000-page-directory-nextjs.html
< BACK 「Next.jsで25,000ページのディレクトリを構築した方法:HostListポストモーテム」のヒーロー画像

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

2022年の後半のどこかで、ウェブホスティングディレクトリを構築することは簡単だと自分を納得させた。データを集約して、ページを生成して、ランク付けして、マネタイズする。シンプルだ。以前にもプログラマティックSEOのプロジェクトをやったことがある――イギリスのクライアント向けのローカライズされた不動産ツール、月4万訪問のピークに達したSaaS比較サイト――だから HostList は6週間のプロジェクトだと思っていた。実際には7ヶ月近くかかった。そしていくつかのものをほぼ壊してしまった:私の睡眠スケジュール、ジュニア開発者の一人の自信、そして予算に入れていなかった月180ポンドの Vercel の請求書。Vercel bill I hadn't budgeted for.

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

---

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

HostList はシンプルであるはずだった。ウェブホスティングプロバイダーのディレクトリ――共有、VPS、専有、マネージドWordPress――各プロバイダーの個別ページ、比較ページ、カテゴリーページ、場所ベースのページ(例:「ドイツで最高のホスティング」)を備えている。計算してみよう:約400のプロバイダー × 複数のページタイプ × 20以上のフィルターの組み合わせ。思ったより速く25,000ページに到達する。WordPress -- with individual pages for each provider, comparison pages, category pages, and location-based pages (e.g. "best hosting in Germany"). Run the maths: ~400 providers × several page types × 20+ filter combinations. You get to 25,000 pages faster than you'd think.

私はほぼ考えずにNext.jsを選びました。SeahawkではReactベースのより大きなビルドの大部分にNext.jsを使っています。エコシステムが成熟していて、getStaticPropsとgetStaticPathsはSEO対策が必要な静的生成に適していて、個人的にはこのスケールではRemixやGatsbyよりもファイルベースのルーティングの方が理解しやすいと感じています。Next.js almost without thinking about it. We use it at Seahawk for most of our bigger React-based builds. The ecosystem is mature,getStaticProps and getStaticPaths make 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について言いたいことはこれです。動作します。技術的には。ただし、ビルド時間があなたの人生選択を疑わせることになります。getStaticPaths with 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時間)に、料金が多く関わるプロバイダーページでは3600秒に設定してIncremental Static Regenerationに切り替えました。これはプロジェクト全体で最も生活の質を向上させた改善でした。ビルド時間は40分未満に短縮されました。トラフィック優先度による上位約2,000ページだけを事前レンダリングし、残りはfallback: 'blocking'でオンデマンド生成させたからです。Incremental Static Regeneration with a revalidate of 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 with fallback: 'blocking'.

ルートツリーの分割

私が違うことをするだろう、そして今Seahawkの大規模なプログラム的プロジェクトに関わるすべての開発者に言うことは: ルートツリーを早期に分割することです。25,000個のスラッグを返そうとする1つのモノリシックなgetStaticPathsファンクションを持つべきではありません。私たちのものは以下に分割しました:getStaticPaths function 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を同期するスクリプトを保守していて、カラム名が変わるたびに壊れていた。一週目で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カラムが変更されたときに発火し、Next.jsの再検証エンドポイントにヒットするSupabaseデータベースウェブフックprice_updated_at column 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 content is 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つを返すrelated-pagesコンポーネントを作成した。編集による介入は不要だ。完璧ではない――稀に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 Paint of 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 R2 early -- the free egress is genuinely useful at scale.

---

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

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

  1. データ収集――Railway cronジョブ上の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 Router(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. 初日から再検証を適切に設定しなかった。完全なリビルドに 2 ヶ月を浪費したが、ISR があればそれは不要だった。We wasted two months on full rebuilds that ISR would have made unnecessary.
  3. Google スプレッドシートを保持していた。単一の情報源は 1 週目から譲歩の余地なく設定されるべきだった。Single source of truth should have been non-negotiable from week one.
  4. 比較ページの品質を過小評価した。ボリュームは戦略ではない。Volume is not a strategy.
  5. Vercel の画像最適化を長く使いすぎていました。6週間遅れて R2 に移行しました。Moved to R2 six weeks later than we should have.
  6. ルートツリーを十分に早く分割しませんでした。同じ getStaticPaths 呼び出しで高速ルートと低速ルートを混在させてから、ビルドが遅い理由を不思議に思いました。Mixed fast and slow routes in the same getStaticPaths call and then wondered why builds were slow.

これらのすべてが、その時点では妥当に思えた決定だ。それはチュートリアルが捉えられない部分だ。悪いアーキテクチャ上の決定は、実装時には聞こえの良い正当化を持っていることが多い。

---

FAQ

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

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

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

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

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

データベースにステータスフラグがある -- active、deprecated、redirected。Deprecated プロバイダーは完全削除ではなく、細いアーカイブページになり、バックリンクが保持される。Redirected プロバイダー(たとえば、あるホストが別のホストを買収した場合)は、next.config.js の Next.js redirects 設定経由で処理される301を取得する。ステータスフラグは月1回見直す。status flag 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.js redirects config in next.config.js. We review the status flags monthly.

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

正直なところ分かりません。Astro は大部分が静的なコンテンツサイトとしては興味深いし、小規模なプロジェクトで試してみています。ただ Next.js は、同じコードベース内に静的セクションと動的セクションの両方を持つ柔軟性をもたらしてくれたし、それは重要でした。完全に静的なディレクトリでインタラクティブな機能がなければ、Astro のほうが構築が速く、実行コストが安いかもしれません。1 年後にまた聞いてください。Astro is interesting for mostly-static content sites, and I've been playing with it on a smaller project. But Next.js gave us the flexibility to have both static and dynamic sections in the same codebase, which mattered. For a purely static directory with no interactive features, Astro might be faster to build and cheaper to run. Ask me again in a year.

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

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

---

最後に

HostList は飛躍的な成功ではない。お金は稼いでいる -- アフィリエイト手数料、いくつかの直接広告案件 -- そして、もともとターゲットにしていた約600の用語でまあまあのランキングを得ている。それで良い。学習プロジェクトであり、かつ収益を生み出してもいる、これ以上ないタイプだ。

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

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

< BACK