how-i-built-25000-page-directory-nextjs.html
< BACK 灯光昏暗的服务器机房走廊,温暖的琥珀色光线,钢制机架向柔和焦点处延伸

我如何用 Next.js 构建了一个 25,000 页面的目录:HostList 事后分析

大约在 2022 年末,我说服自己建立一个虚拟主机目录会很直接。汇总数据、生成页面、排名、变现。很干净。我之前做过程序化 SEO 项目——为英国客户开发的本地化房产工具,一个月度访问量峰值达 4 万的 SaaS 对比网站——所以我认为 HostList 会是一个六周的项目。结果花了接近七个月。期间差点把几件事搞坏了:我的睡眠时间表、我一个初级开发的信心,还有一笔我没有预算的 Vercel 月费 180 英镑。

这是事后分析。真实的那个,不是 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.

---

大规模静态生成:没人会告诉你的事

getStaticPaths 处理 25,000 个路由的问题是这样的。它确实工作。技术上讲。但你的构建时间会让你怀疑人生。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,你就会为影响可能只有 30 页(而不是 25,000 页)的数据变化触发完整重建。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 秒。这是整个项目中最大的生活质量改进。构建时间下降到不到 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'.

分割路由树

有一件事我会做得不同,我现在也告诉 Seahawk 每一个接手大型程序化项目的开发者:尽早拆分你的路由树。不要让一个庞大的 getStaticPaths 函数试图返回 25,000 个 slug。我们把它分成了: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 表。三个真实来源。它们之间没有正常沟通。

一个刚从训练营出来的初级开发——好孩子,花了三周时间维护一个 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.

大规模保持数据新鲜度

对于这个规模的目录,数据新鲜度既是 SEO 问题,也是 UX 问题。Google 会注意到定价表显示某个计划是 £2.99/月,但它已经是 £5.99 有八个月了。我们设置了:

  • 在 Railway cron 上运行的每周爬取任务(便宜、可靠、不需要专用服务器)
  • 一个 Supabase 数据库 webhook,当 price_updated_at 列改变时触发,击中一个 Next.js 重新验证端点price_updated_atcolumn changes, hitting a Next.js revalidation endpoint
  • 在 Retool 中为大约 30 个主动阻止爬虫的提供商设置手动覆盖标志

那个重新验证端点——/api/revalidate?secret=TOKEN&path=/providers/siteground——是 Next.js 的标准功能,但将它连接到数据库 webhook 需要一些管道工作。值得花的每一分钟。/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-vs-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数据中获取

结果是1,200个实际有用的页面,而不是8,000个没用的页面。对比部分的有机流量在接下来的三个月内增长了340%。看似反直觉,直到它不再是。

这个规模下的内部链接

有25,000个页面,内部链接不能手动进行。我们构建了一个相关页面组件,在构建时查询Supabase(在getStaticProps中),根据类别和位置重叠返回五个最相关的邻近页面。无需编辑干预。不是完美的——偶尔一个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 组件——大量的状态、大量的条件渲染、在提供商页面和对比页面上都有使用。在移动设备上,它导致最大内容绘制大约为 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 预览、功能图标。我们在前两个月犯了使用 Vercel 内置图像优化来托管这些的错误。带宽成本悄悄地令人恐惧。我们把所有东西都转移到 Cloudflare R2,使用自定义域名,将 Vercel 账单从每月 £180 降至 £40。如果你在构建任何图像密集的东西,早点看一下 Cloudflare R2——免费的流出带宽在规模上真的很有用。Cloudflare R2early — the free egress is genuinely useful at scale.

---

构建管道现在实际上是什么样子的

对于想要具体情况的人:

  1. 数据收集 — Python 爬虫运行在 Railway 定时任务上,写入 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 监控五个流量最高的页面类型— BetterUptime watching the five most traffic-heavy page types

这不是什么光鲜亮丽的事情。维护起来也大多很枯燥,这正是你想要的——基础设施要能就这样运行三年而不出问题。

---

诚实的错误,按顺序排列

  1. 起步太宽泛了。25,000 个页面一直是最终目标,但我应该先上线 500 个高质量页面再扩展。结果我直接上线了全部内容,前四个月遇到了 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. 从第一天起就没有正确设置重新验证。我们浪费了两个月做完整重建,而 ISR 本可以避免这一切。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。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.

这些都是当时看起来合理的决策。这就是教程没有讲到的部分——糟糕的架构决策通常在你做的时候都有冠冕堂皇的理由。

---

常见问题

初始构建上线用了多长时间?

从第一次提交到我满意地称之为 v1 的版本,花了七个月。首个粗糙的公开版本在第四个月左右上线,但存在严重的内容不足问题,对比部分基本没用。我会说四个月是"技术上的上线",再加三个月才是"真正的成品"。

如果你现在开始新项目,会使用 App Router 吗?

可能会,对于 2023 年末之后开始的新项目来说。App Router 的服务器组件实际上很适合这种数据密集型的页面生成。但把一个现有的 25,000 页面的 Pages Router 应用迁移过来,这不是我近期要做的项目。Pages Router 仍然可用,而"可用"这点往往被低估了。

如果提供商倒闭或大幅改变他们的服务,你怎么处理?

我们在数据库中有一个状态标志——active、deprecated、redirected。Deprecated 的提供商会得到一个精简的存档页面,而不是完全删除,这样可以保留任何反向链接。Redirected 的提供商(比如一个主机商收购另一个)通过 Next.js 的 next.config.js 中的 redirects 配置处理 301 跳转。我们每月审查一次状态标志。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 可能会更快地构建,运行成本也更低。一年后再问我吧。

你怎样阻止爬虫复制整个目录?

老实说?你做不到,完全做不到。我们对 API 路由进行速率限制,在前端使用 Cloudflare 的机器人管理,并轮换一些结构化数据,这样被爬取的副本会很快过时。但如果有人想克隆一个公开的目录,他们总会找到办法。竞争优势在于数据的新鲜度和用户体验质量,而不是技术混淆。

---

结语

HostList 并不是一个成功爆款。它能赚钱——来自联盟佣金和少数几个直接广告交易——而且在我最初目标的大约 600 个关键词上排名相当不错。这很好。它是一个学习项目,同时也能产生收入,这是最好的情况。

如果你正在考虑用 Next.js 构建大规模的程序化 SEO 站点,我的诚实建议是:去做。这真的是这份工作的一个很好的技术栈。但是要构建比你认为需要的更少的内容,要把它做得比你认为有时间做的更好,而且要在写任何页面模板之前先把数据架构整理好。

技术部分是容易的。总是这样。

< BACK