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.
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
- Injecting meta tags for SEO
Two problems followed:
- Poor separation of concerns. One server doing everything meant every change touched the same tangled code.
- Rising maintenance cost. With every new feature it was unclear where code should live. Components were mixed together, and business logic was tightly coupled to UI.
Big bang vs. strangler fig
Option 1 — Full rewrite (big bang). Start over and switch everything at once. The problem: several products depended on this login. A one-shot cut-over meant breaking compatibility everywhere at the same moment. The outage risk was too high.
Option 2 — Incremental migration (Strangler Fig pattern). Keep the old system running and move 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, linking legacy accounts — if any one of those breaks, users feel it immediately.
So we documented every critical V1 flow as a Playwright test.
// e2e/auth.spec.ts
test('redirects to My Page after a successful login', async ({ page }) => {
await page.goto('/v2/auth/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/v2/mypage');
await expect(page.locator('.user-email')).toContainText('test@example.com');
});
test('locks the account after 5 failed attempts', async ({ page }) => {
for (let i = 0; i < 5; i++) {
await page.fill('[name="password"]', 'wrong');
await page.click('button[type="submit"]');
}
await expect(page.locator('.error-message')).toContainText('locked');
});
test('sends legacy accounts to the account-linking page', async ({ page }) => {
// resultCode 203 case
await page.fill('[name="email"]', 'legacy@example.com');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/v2\/auth\/connect/);
});
These tests became the spec for V2. When V2 code passed them, we could say with confidence: "it behaves exactly like V1."
TDD on top of the E2E suite
- Red — run the E2E suite against V2. It fails, because there is no V2 code yet.
- Green — write just enough code to pass.
- Refactor — clean up while the tests stay green.
This guaranteed "whatever worked in V1 works in V2" at the level of user scenarios, not just at the level of code.
Why Next.js 16 + App Router
The biggest pain in the old Express server was that it had no TypeScript. Maintaining it without type safety kept getting harder.
| Option | Pros | Cons |
|---|---|---|
| NestJS | TypeScript, structured backend | Backend only; frontend still managed separately |
| Go + Echo | Fast, type-safe | Hard for a frontend team to own |
| Next.js App Router | TypeScript by default, full-stack | Tied to the React ecosystem |
We seriously considered writing a new backend in NestJS or Go. What decided it was ownership: the frontend team was going to maintain this system. Next.js let us:
- Write UI and API in the same language (TypeScript)
- Keep everything in one repo
- Use one deployment pipeline
- Share types between React components and API logic
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 a user logged in on a V1 page stays logged in on a V2 page, and vice versa.
express-session uses a signed cookie:
- Cookie name:
connect.sid - Cookie value:
s:{sessionId}.{HMAC_signature}
And the session lives in Redis like this:
{
"cookie": {
"originalMaxAge": 604800000,
"secure": true,
"httpOnly": true,
"path": "/",
"sameSite": "none"
},
"user": {
"id": "user123",
"email": "user@example.com",
"access_token": "eyJhbG...",
"refresh_token": "eyJhbG..."
}
}
On the Next.js side we implemented the same contract: parse connect.sid, verify the signature with the same secret, and read/write the same Redis key. With that in place, V1 and V2 share one session, and users get the same experience no matter which version serves the page.
API strategy: switch over gradually
For now, V2 pages still call V1's API through a Next.js rewrite proxy:
// next.config.js
async rewrites() {
return {
beforeFiles: [
{
source: '/front/api/:path*',
destination: `${EXPRESS_API_URL}/front/api/:path*`,
},
],
};
}
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 talks to Keycloak directly and Express drops out of the path.
Why we kept Nginx in front
In V1, Nginx served static files and Express handled the API. In V2, Next.js handles both pages and API. So why keep Nginx?
Because it still does infrastructure-level work that doesn't belong in application code:
| Role | What it does |
|---|---|
| Security headers | HSTS, X-Frame-Options, CSP, etc. |
| WAF | Web application firewall |
| Rate limiting | Request limits, DDoS mitigation |
server {
listen 443 ssl http2;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Strict-Transport-Security "max-age=31536000" always;
limit_req zone=api burst=20 nodelay;
location / {
proxy_pass http://nextjs:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
}
}
Nginx keeps its mature, battle-tested security features; the application logic lives in Next.js and TypeScript.
Results
- Stability backed by E2E tests. We hit 100% E2E coverage of V1's key flows first and built V2 against it, so the migration shipped without regressions in those flows.
- Full compatibility. V1 and V2 share the same session. Users see no difference.
- Type safety. With Zod + TypeScript, runtime errors dropped significantly. API responses and form data are typed end to end.
Phase 1 is complete. Next: turn on all V2 API Routes and retire the legacy code step by step.
Takeaway
The one thing we optimized for was stability, so we wrote the tests before the code. We documented every critical V1 flow as an E2E test and built V2 by making those tests pass.
"If the tests pass, it behaves like V1." That confidence is what made an incremental migration of a system everyone depended on actually feel safe.
I'm Henry, a frontend engineer focused on React, Next.js, TypeScript and Playwright E2E testing. This post is adapted from a write-up I originally published in Korean. I'm open to frontend roles abroad — let's connect on LinkedIn or see my work on GitHub.




Top comments (1)
The session-compatibility decision is the part I would have underlined twice. On a recent platform migration, the data move was not the hard part — keeping existing users signed in across the old and new systems was. Treating the E2E suite as the spec before writing V2 code is a discipline I wish more migration write-ups stated plainly. One question: when you switched an API route from the rewrite proxy to a native Next.js route, did you keep the same Redis session write path, or add a separate validation step for the new route?