From an Express SPA to Next.js 16: A Test-First Strangler Fig Migration of a Shared Auth System

本文为原文前 6,000 字符的节选翻译,完整内容请查看原文。

I worked on a frontend team that owned the unified account system for a group of creative-industry products. One login, several services — so if auth breaks, everything breaks. This post walks through how we moved that system from an Express-served React SPA to Next.js 16 (App Router) without a big-bang rewrite, and why the very first thing we wrote wasn’t application code, but E2E tests.

我曾在一个前端团队工作,负责一组创意行业产品的统一账户系统。一套登录系统对应多个服务,因此一旦认证功能崩溃,所有服务都会瘫痪。本文将介绍我们如何在不进行“大爆炸式”重写的情况下,将该系统从基于 Express 的 React 单页应用(SPA)迁移到 Next.js 16(App Router),并解释为什么我们首先编写的不是应用程序代码,而是端到端(E2E)测试。

The starting point: The account system was a React SPA served by an Express server. It had run reliably for years, but the Express server had slowly absorbed too many responsibilities, all hard-coded in one place: API routing, session management, and injecting meta tags for SEO. Two problems followed: poor separation of concerns, where every change touched the same tangled code; and rising maintenance costs, as it became unclear where new features should live.

起点:该账户系统是一个由 Express 服务器托管的 React SPA。它多年来运行稳定,但 Express 服务器逐渐承担了过多的职责,且全部硬编码在同一处:API 路由、会话管理以及为 SEO 注入元标签。这导致了两个问题:关注点分离较差,任何改动都会触及纠缠在一起的代码;维护成本上升,因为新功能该放在哪里变得模糊不清。

Big bang vs. strangler fig: Option 1 was a full rewrite (big bang), which carried too high an outage risk because several products depended on this login. Option 2 was an incremental migration (Strangler Fig pattern), keeping the old system running and moving traffic over piece by piece. Staying compatible while switching safely mattered more than anything else, so we chose the strangler fig.

“大爆炸”式重写与“绞杀者”模式:方案一是完全重写(大爆炸式),但由于多个产品依赖此登录系统,中断风险过高。方案二是增量迁移(绞杀者模式),即保留旧系统运行,并逐步迁移流量。在安全切换的同时保持兼容性至关重要,因此我们选择了绞杀者模式。

Step zero: E2E tests before any code. After choosing an incremental migration, the first thing we did was reach 100% E2E coverage of V1’s key user flows. The scariest thing when you build a replacement is “something that used to work doesn’t anymore.” Login, sign-up, password reset, social login, and linking legacy accounts were all documented as Playwright tests. These tests became the spec for V2. When V2 code passed them, we could say with confidence: “it behaves exactly like V1.”

第零步:在编写任何代码前先进行 E2E 测试。选择增量迁移后,我们做的第一件事就是实现 V1 关键用户流程 100% 的 E2E 测试覆盖。构建替代方案时最可怕的就是“以前能用的功能现在不行了”。登录、注册、密码重置、社交登录以及关联旧账户等流程都被记录为 Playwright 测试。这些测试成为了 V2 的规范。当 V2 代码通过这些测试时,我们可以自信地说:“它的行为与 V1 完全一致。”

TDD on top of the E2E suite: We followed a Red-Green-Refactor cycle. Red: run the E2E suite against V2 (it fails because there is no code). Green: write just enough code to pass. Refactor: clean up while the tests stay green. This guaranteed that “whatever worked in V1 works in V2” at the level of user scenarios, not just at the level of code.

在 E2E 套件之上的 TDD:我们遵循“红-绿-重构”循环。红:针对 V2 运行 E2E 套件(因无代码而失败)。绿:编写刚好能通过的代码。重构:在保持测试通过的前提下进行清理。这确保了“V1 中可用的功能在 V2 中同样可用”,且是从用户场景层面而非仅仅是代码层面保证的。

Why Next.js 16 + App Router: The biggest pain in the old Express server was the lack of TypeScript. We considered NestJS or Go, but decided on Next.js because the frontend team was going to maintain this system. Next.js allowed us to write UI and API in the same language (TypeScript), keep everything in one repo, use one deployment pipeline, and share types between React components and API logic.

为什么选择 Next.js 16 + App Router:旧 Express 服务器最大的痛点是缺乏 TypeScript。我们考虑过 NestJS 或 Go,但最终选择了 Next.js,因为该系统将由前端团队维护。Next.js 使我们能够用同一种语言(TypeScript)编写 UI 和 API,将所有内容保存在一个仓库中,使用统一的部署流水线,并在 React 组件和 API 逻辑之间共享类型。

The hardest part: session compatibility. V1 used express-session backed by Redis. The new Next.js app had to read and write the exact same sessions so that a user logged in on a V1 page stays logged in on a V2 page, and vice versa. We implemented the same contract on the Next.js side: parse the connect.sid cookie, verify the signature with the same secret, and read/write the same Redis key.

最困难的部分:会话兼容性。V1 使用由 Redis 支持的 express-session。新的 Next.js 应用必须读写完全相同的会话,以便用户在 V1 页面登录后在 V2 页面保持登录状态,反之亦然。我们在 Next.js 端实现了相同的契约:解析 connect.sid Cookie,使用相同的密钥验证签名,并读写相同的 Redis 键。

API strategy: switch over gradually. For now, V2 pages still call V1’s API through a Next.js rewrite proxy. V1’s Express API talks to Keycloak, and V2’s API Routes are already implemented to talk to Keycloak the same way. In a later phase, Next.js will talk to Keycloak directly and Express will drop out of the path.

API 策略:逐步切换。目前,V2 页面仍通过 Next.js 重写代理调用 V1 的 API。V1 的 Express API 与 Keycloak 通信,而 V2 的 API 路由也已实现以相同方式与 Keycloak 通信。在后续阶段,Next.js 将直接与 Keycloak 通信,Express 将从路径中移除。

Why we kept Nginx in front: In V1, Nginx served static files and Express handled the API. In V2, Next.js handles both. We kept Nginx because it performs infrastructure-level work that doesn’t belong in application code, such as security headers (HSTS, CSP), WAF (Web Application Firewall), and rate limiting.

为什么保留 Nginx 作为前端:在 V1 中,Nginx 提供静态文件,Express 处理 API。在 V2 中,Next.js 两者兼顾。我们保留 Nginx 是因为它执行了不属于应用程序代码的基础设施级工作,例如安全标头(HSTS、CSP)、WAF(Web 应用防火墙)和速率限制。