DEV Community

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

Posted on AI-assisted

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

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

V1: Express does everything

Two problems followed:

  1. Poor separation of concerns. One server doing everything meant every change touched the same tangled code.
  2. 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/);
});
Enter fullscreen mode Exit fullscreen mode

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

TDD loop on top of the E2E suite

  1. Red — run the E2E suite against V2. It fails, because there is no V2 code yet.
  2. Green — write just enough code to pass.
  3. 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..."
  }
}
Enter fullscreen mode Exit fullscreen mode

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*`,
      },
    ],
  };
}
Enter fullscreen mode Exit fullscreen mode

Migration phases

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';
  }
}
Enter fullscreen mode Exit fullscreen mode

V2: Next.js behind Nginx, sharing the Redis session with V1

Nginx keeps its mature, battle-tested security features; the application logic lives in Next.js and TypeScript.

Results

  1. 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.
  2. Full compatibility. V1 and V2 share the same session. Users see no difference.
  3. 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)

Collapse
 
mujtbabbas profile image
Syed Mujtaba •

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?