DEV Community

Cover image for How We Ran Two Portals on the Same Domain During a React Migration (Without Users Noticing)
gustav lotz
gustav lotz

Posted on

How We Ran Two Portals on the Same Domain During a React Migration (Without Users Noticing)

Imagine going live with your rewritten app and within an hour support tickets start flooding in. You missed a critical feature, or worse — the new app can't handle production traffic and crashes under load.
That thought kept me up. So we didn't do a big bang cutover.

We kept both portals running at the same time - here's how

Instead, I pushed for a canary-based rollout — keep both portals running in parallel and shift traffic gradually. Start at 10%, monitor for issues, move to 25%, 50%, 100%. If something was wrong, only a fraction of users would hit it and we could catch problems before they became disasters. The 1000 support tickets become 100. Manageable.
The old portal was served by a Spring MVC controller rendering portal.ftl, living at the root of the domain. The new React portal was deployed as static assets in an nginx container under /new-ui/:

# Old portal (Spring MVC + FreeMarker)
https://portal.example.com/           → DashboardController → portal.ftl → AngularJS

# New portal (nginx + static assets)
https://portal.example.com/new-ui/    → nginx → React SPA`

The React router needed a basename to match where it was being served:

const router = createBrowserRouter(routes, {
  basename: '/new-ui',
  future: {
    v7_relativeSplatPath: true,
    v7_fetcherPersist: true,
    v7_normalizeFormMethod: true,
    v7_partialHydration: true,
    v7_skipActionErrorRevalidation: true,
  },
});
Enter fullscreen mode Exit fullscreen mode

The session problem

Both portals needed to share the same session — a user logged into the old portal had to arrive at the new one already authenticated, no second login. The old portal set userToken and userHash cookies on the domain. The new portal just read those same cookies on startup to bootstrap its Redux auth state:

const setupInitialAuthState = (): AuthState => {
  const userToken = Cookies.get('userToken');
  const userHash = Cookies.get('userHash');
  const isLogged = !!(userHash && userToken);

  return {
    isLogged,
    loading: false,
    hash: userHash || '',
    token: userToken ?? null,
  };
};
Enter fullscreen mode Exit fullscreen mode

That worked. But then I hit the next problem.

The mid-session flip problem

The canary could flip a user mid-session from one portal to the other. They'd been navigating around inside the old portal, then the Traefik weight rolls them over to the new UI — and they land on a route that doesn't map to the same internal structure. They bomb out.
The fix was making the login page the single decision point. Login page is server-side rendered — once a user logs in through the new UI login page, they stay in the new UI for that entire session. We used Traefik's IngressRoute with weighted services to split traffic only at the login route:

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: portal-login-split
spec:
  routes:
    - kind: Rule
      match: Host(`portal.example.com`) && PathPrefix(`/user/login`)
      services:
        - name: portal
          port: 8080
          weight: 90
        - name: kv-frontend-portal
          port: 8080
          weight: 10
Enter fullscreen mode Exit fullscreen mode

Adjust the weights over time. Traefik handles the split at the connection level — no application code changes needed to shift the percentage.

The URL problem

We couldn't have /new-ui/ showing up in the browser bar — confusing for users and bad for SEO. A Traefik middleware stripped the prefix so internally the React app still routed under /new-ui/ but users saw the same clean URLs they always had. From their perspective nothing changed.
The new frontend builds to static assets in a multi-stage Docker image — all four Nx applications compile in the build stage and get served from one nginx container with path-based routing:

FROM node:20 AS builder
ARG BUILD_ENV=dev

WORKDIR /usr/src/app
COPY . .
RUN npm install

RUN npm run build:kv-frontend:${BUILD_ENV}
RUN npx nx build kv-location --configuration=${BUILD_ENV}
RUN npx nx build kv-super-admin --configuration=${BUILD_ENV}
RUN npx nx build kv-location-dashboard --configuration=${BUILD_ENV}

FROM nginx:alpine

COPY --from=builder /usr/src/app/dist/apps/kv-frontend /usr/share/nginx/html
COPY --from=builder /usr/src/app/dist/apps/kv-location /usr/share/nginx/html/sme
COPY --from=builder /usr/src/app/dist/apps/kv-super-admin /usr/share/nginx/html/admin
COPY --from=builder /usr/src/app/dist/apps/kv-location-dashboard /usr/share/nginx/html/location-group
Enter fullscreen mode Exit fullscreen mode

This parallel setup also removed the pressure of a hard deadline. Features could be rebuilt one at a time, tested against the real production API, and switched over only when they were ready.

This is section 4 of 11 from my full write-up on migrating a 149-route AngularJS 1.5 enterprise platform to React 19 — covering the canary deployment, browser back button auth problems, custom ESLint rules for multi-tenant safety, Redux factory patterns, and the results after a year.
Full article → https://www.gustavlotz.com/blog/angularjs-1-5-to-react-19-time-jump

Top comments (0)