关联公司 Testlify

Testlify - 营销网站迁移至 Next.js

主导了将一个超过 11,000 页的营销网站从 WordPress 替换为缓存优先的 Next.js 16 架构的架构决策,逐页与生产环境核对,以保护现有的 SEO 权重。

Testlify 的营销网站包含超过 11,000 个页面。旧有的 WordPress 架构在每次请求时都会产生服务端渲染开销和插件瓶颈,影响了页面加载速度和搜索引擎排名。我主导了将这个网站迁移到 Next.js 16 的架构决策,目前迁移工作正在推进中。

架构决策

  • 缓存优先,而非 WordPress 模式: 页面在首次请求时渲染一次,之后无限期缓存,而不是每次请求都访问数据库,也不需要为 11,000 多个页面做耗时数小时的批量重建。一个 Payload CMS webhook 只会使某次内容变更实际影响到的特定页面失效。
  • 无头 CMS 集成: 内容通过 Payload CMS 管理,这是一个由 Postgres 支撑的自托管无头 CMS,让营销团队可以编辑和发布页面,而缓存失效钩子会自动响应每一次变更。
  • 大规模迁移: 规划并执行了 11,000 多个页面的分阶段迁移,逐页对照生产环境的实时站点地图进行核对,在所有分类中保留了 URL 结构、canonical 标签和现有的 SEO 权重。

为什么它有意义

把一个超过 11,000 页的网站从 WordPress 迁移到缓存优先架构,缩短了页面加载时间,提升了技术 SEO 表现,也让营销团队可以在不依赖工程部署的情况下发布更新。

职责归属,仍在进行中

我主导了脱离 WordPress 的架构决策。这不是一个人完成的项目:自 2026 年 7 月以来,大约 15 到 20 位贡献者共提交了超过 2,500 次,最近这个月仍然活跃,截至上次状态核查,唯一剩下的步骤是把 DNS 切换到生产环境。

为什么这不是经典的 Jamstack

对于 11,000 多个页面而言,显而易见的做法是在构建时做静态生成。但这里并不适用:跨所有语言和内容类型做一次完整重建要花上好几个小时,而单次内容编辑也不应该需要重建整个网站才能生效。取而代之的是,每一个动态路由(category、author、hr-tools、hr-guide、blog、test-library 等等)都遵循同一个约定:构建过程只会为每种路由类型预渲染恰好一个样例页面,因为 Cache Components 至少需要一个参数才能构建,而构建本身必须做到零数据库查询。该路由下的其他所有页面,都在 Next.js 16 的 Cache Components 下于首次真实请求时渲染,然后无限期保持缓存(cacheLife 设置为 Next 自己的 INFINITE_CACHE 常量),直到一个 Payload CMS 的 afterChange 钩子为那个特定页面调用一个签名的重新验证端点。内容变更会在几秒内传播开来,而不是走一整套重建流程,而从访问者的角度看,这个缓存的行为仍然像一个 CDN 边缘缓存。这个重新验证端点本身经过 HMAC 签名,并用恒定时间比较来校验,而且被刻意限制为一次只能使一个标签失效:只有一个单独的、需要手动触发的管理端路径才能清除某个集合范围的整体标签,因此就算 webhook 密钥泄露,最坏情况也只是让单个页面失效,而不会一次性冲垮整个网站的缓存。

两个在生产环境中发现并从根源修复的框架 bug

构建过程中出现了两个各自独立的 Next.js 16 问题,现在都直接记录在 next.config.ts 里,这样下一个人就不用再费力重新发现一遍。第一个:cacheComponents 会默认悄悄开启 enablePrerenderSourceMaps,这在静态页面生成期间导致生产构建被 OOM 杀掉;追查到 Next 自身源码后,用一个显式的覆盖设置修复了它。第二个:把缓存生命周期设为 Infinity,在传给 Next 的构建 worker 途中会被 JSON 序列化成 null,这会悄悄把该路由降级为不缓存,而不是报错,导致所有语言的预渲染都被破坏,直到被发现为止;修复方式是使用 Next 自己的 INFINITE_CACHE 常量,而不是一个原始的 Infinity。

一个拒绝提供不完整结果的目录

test-library 目录以每页 60 条的方式从上游 API 拉取数据。如果实际返回的数量比声明的总数少了超过 2%,这次拉取会被直接拒绝,而不是作为一个不完整的目录发布出去,转而继续提供上一次已知良好的缓存快照(一个本地的 seed.json 用于覆盖冷启动或上游整体宕机的情况)。在这里,数据过期是可以接受的失败模式;而一个悄悄变得不完整的产品目录是不可接受的。

一个针对流式传输边界情况的 SEO 修复

在 Cache Components 的流式传输下,Next 自动的 notFound() 没办法在页面外壳(shell)已经开始向客户端流式传输之后再提交 404 HTTP 状态,因此一个只读取该外壳的爬虫永远不会看到它。修复方式是手动把一个 noindex 指令注入到第一次 flush 中,这样即便爬虫只停留在外壳阶段、页面其余部分尚未解析完成,也依然能拿到正确的信号。

数据模型与规模

  • 这 11,000 多个页面来自多种内容类型(category、author、hr-tools、hr-guide、leadership、test-library),并通过 next-intl 与每一种支持的语言相乘组合而成,而不是来自单一扁平的内容源。
  • 每一个生产环境中的 WordPress URL 都会在一个专门的审计文档(docs/audits/url-parity-with-production.md)中与本项目的输出进行核对:如果一个 WordPress 今天仍在提供服务的 URL,在这个项目里变成了 404,那就是流量的流失,而不是可以接受的差距。
  • 单元测试(Vitest)覆盖了站点地图生成、目录种子回退,以及相关内容轮播;Playwright 覆盖端到端流程。

自研 vs 采购

使用基于 Postgres 自托管的 Payload CMS,而不是 SaaS 无头 CMS:把内容归属权和数据库都留在自己手里,而不是为一个营销网站按坐席付费给 SaaS。使用 Next.js 自带的 Cache Components 和基于标签的失效机制,而不是手写一套 CDN/ISR 层:缓存行为来自框架本身,而不是这个团队原本得自己构建和运维的基础设施。

技术栈

Next.js 16、React、TypeScript、Payload CMS 基于 Postgres、next-intl 用于本地化、Vitest 用于单元测试、Playwright 用于端到端测试,部署在 Vercel 上。