2022 年年底,我说服自己构建一个虚拟主机目录会很简单。汇总数据、生成页面、排名、变现。干净利落。我之前做过程序化 SEO 项目——为英国客户做过本地化房产工具,还有一个 SaaS 对比网站峰值时月流量 4 万——所以我想 HostList 会是个六周的项目。结果花了接近七个月。期间差点搞坏几样东西:我的睡眠规律、一位初级开发的自信心,还有一张我没预算过的每月 £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 的构建。生态系统成熟,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.
---
大规模静态生成:没人会告诉你的事
这就是关于 getStaticPaths 处理 25,000 条路由的问题所在。它能工作。从技术上讲。但你的构建时间会让你质疑人生选择。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 秒。这是整个项目中最大的生活质量改善。构建时间降至 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 开发者都会说:尽早分割你的路由树。不要有一个单一的庞大的 getStaticPaths 函数试图返回 25,000 个 slug。我们把它分成了:getStaticPaths function trying to return 25,000 slugs. We broke ours into:
/providers/[slug] —— 单个提供商页面(~400)-- individual provider pages (~400)/compare/[slugA]-vs-[slugB] —— 一对一对比页面(~8,000)-- head-to-head comparison pages (~8,000)/category/[type] —— 分类落地页(~40)-- category landing pages (~40)/location/[country]/[type] —— 地理位置 × 分类组合(~16,000+)-- geo × category combinations (~16,000+)/best/[use-case] —— 精选列表页(~600)-- curated list pages (~600)
每个路由组都有自己的重新验证周期、自己的数据获取逻辑,最关键的是,有自己的构建优先级。位置页面几乎完全按需生成。提供商页面总是预先渲染的。干净的分离。
---
数据管道的混乱(以及我们如何解决的)
早在 2023 年初,我犯了一个错误,在 HostList 数据收集端构建得太松散了。我们有一个爬虫脚本(用 Python 编写,使用 BeautifulSoup 和来自 Webshare 的轮转代理池)、一个手动 Google Sheet 用于更正,还有一个 Supabase 表。三个真实来源。它们之间没有正常沟通。
一个刚从训练营毕业的初级开发——好孩子,花了三周时间维护一个Sheet和Supabase之间的同步脚本,每次改一个列名就会坏掉。我本应在第一周就砍掉这个Sheet,搭建一个真正的内部管理UI。我们最终用Next.js API routes和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'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数据中获取
结果是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 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秒。这不是魔法——就是把无聊的事做好了。
图片问题
每个提供商都有一个logo。400个logo,加上截图、UI预览、功能图标。我们犯了个错误,前两个月用Vercel内置的图片优化来托管这些。带宽成本悄悄地恐怖。把一切都移到了Cloudflare R2配一个自定义域名,把Vercel账单从£180/月降到了£40/月。如果你在构建任何图片重的东西,早点看看Cloudflare R2——免费出站流量在大规模下真的很有用。Cloudflare R2 early -- the free egress is genuinely useful at scale.
---
构建管道现在实际上是什么样子的
对于想要具体情况的人:
- 数据收集——Railway cron任务上的Python爬虫,写入Supabase Postgres -- Python scraper on a Railway cron job, writes to Supabase Postgres
- 管理层 -- Retool 仪表板用于手动编辑、更正和提供商标记 -- Retool dashboard for manual edits, corrections, and provider flags
- Next.js 应用 -- Pages Router(我们在 App Router 足够稳定之前就开始了),部署在 Vercel 上 -- Pages router (we started before App Router was stable enough to trust), deployed on Vercel
- ISR + 按需重新验证 -- 前 ~2,000 个页面预构建,其余按需构建,所有页面都有 24 小时重新验证周期 -- top ~2,000 pages pre-built, rest on-demand, all with 24h revalidation
- 图像 -- Cloudflare R2,通过自定义子域由 Cloudflare CDN 提供 -- Cloudflare R2, served via a custom subdomain with Cloudflare CDN in front
- 分析 -- Plausible 用于隐私友好的流量数据,Ahrefs 用于排名追踪 -- Plausible for privacy-friendly traffic data, Ahrefs for ranking tracking
- 正常运行时间监控 -- BetterUptime 监控五种流量最大的页面类型 -- BetterUptime watching the five most traffic-heavy page types
这不是什么光鲜亮丽的事情。维护起来也大多很枯燥,这正是你想要的——基础设施要能就这样运行三年而不出问题。
---
诚实的错误,按顺序排列
- 起点太宽泛。25,000 页面始终是目标,但我应该以 500 个高质量页面启动并逐步扩展。结果我以全部内容启动,前四个月出现了谷歌爬虫预算问题。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.
- 没有从第一天就正确设置重新验证。我们浪费了两个月进行完整重建,ISR 本来可以避免这些。We wasted two months on full rebuilds that ISR would have made unnecessary.
- 保留了谷歌表格。单一信息源应该从第一周就成为不可谈判的需求。Single source of truth should have been non-negotiable from week one.
- 低估了比较页面的质量。数量不是策略。Volume is not a strategy.
- Vercel 图片优化用得太久了。应该提前六周迁移到 R2,但没有。Moved to R2 six weeks later than we should have.
- 没有尽早拆分路由树。在同一个getStaticPaths调用中混合了快速和慢速路由,然后对构建为什么很慢感到困惑。Mixed fast and slow routes in the same
getStaticPathscall and then wondered why builds were slow.
这些决定在当时看起来都是合理的。教程没有涉及的就是这一点 -- 糟糕的架构决策通常在你做出时都有很好的理由。
---
常见问题
初始构建上线用了多长时间?
从第一次提交到我满意地称之为 v1 的版本,花了七个月。首个粗糙的公开版本在第四个月左右上线,但存在严重的内容不足问题,对比部分基本没用。我会说四个月是"技术上的上线",再加三个月才是"真正的成品"。
如果你现在开始新项目,会使用 App Router 吗?
可能会,对于 2023 年末之后开始的新项目来说。App Router 的服务器组件实际上很适合这种数据密集型的页面生成。但把一个现有的 25,000 页面的 Pages Router 应用迁移过来,这不是我近期要做的项目。Pages Router 仍然可用,而"可用"这点往往被低估了。
如果提供商倒闭或大幅改变他们的服务,你怎么处理?
我们在数据库中有一个状态标记 -- active、deprecated、redirected。已弃用的提供商获得一个精简归档页面而不是完全删除,这保留了任何反向链接。已重定向的提供商(例如一个主机收购另一个主机时)通过 Next.js 的 next.config.js 中的 redirects 配置处理 301 重定向。我们每月审查一次状态标记。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 可能会更快地构建,运行成本也更低。一年后再问我吧。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 的机器人管理,并轮换一些结构化数据,这样被爬取的副本会很快过时。但如果有人想克隆一个公开的目录,他们总会找到办法。竞争优势在于数据的新鲜度和用户体验质量,而不是技术混淆。
---
结语
HostList 并非一个爆炸性成功。它赚钱 -- 通过联盟佣金和一些直接广告交易 -- 并且在我最初目标的大约 600 个词条上排名相当不错。这不错。它原本是一个学习项目,碰巧也能产生收益,这是最好的那种。
如果你正在考虑用 Next.js 构建大规模的程序化 SEO 站点,我的诚实建议是:去做。这真的是这份工作的一个很好的技术栈。但是要构建比你认为需要的更少的内容,要把它做得比你认为有时间做的更好,而且要在写任何页面模板之前先把数据架构整理好。
技术部分是容易的。总是这样。
