Zero-downtime frontend migration
Rewriting the frontend. 170 locations still using it. Zero downtime.
A live CRA-to-Next.js migration using a V2 shell, iframe bridge, shared authentication, synchronized routing, and staged rollout.
5 min readBy Abdullah Raheel
The product could not pause
CRA V1 still served 170 locations and about 765 concurrent users. We could not freeze feature work, build a replacement in isolation, and move every customer at once. The migration had to run inside the live product.
We used the Strangler Fig pattern: put the new system around the old one, move one production responsibility at a time, and delete the compatibility layer when nothing depends on it.
Use a strangler, not a second launch
Next.js V2 became the application shell. It owned the navbar, topbar, authentication, and global chrome. Routes not yet migrated continued inside one CRA iframe, so customers saw one product while two frontend runtimes were active.
The iframe always loaded a stable dashboard base URL. It stayed mounted across parent route changes; remounting it would have reset legacy state and repeated authentication. V2 sent the initial route only from the iframe onLoad callback, after V1 had installed its message listener, and sent later changes only when the mapped route actually changed.
iframe.contentWindow?.postMessage(
{
action: 'NAVIGATE',
route: iframeRoute,
location_id: locationId,
migratedLocationIds: [...MIGRATED_LOCATION_IDS]
},
dashboardUrl
)Make two frontends behave like one
We defined both message directions explicitly. V2 sent navigation, SSO tokens, user data, and location data. V1 returned route changes, user-data requests, token-refresh requests, and Click2Call actions.
Every receiver rejected unexpected origins, and every sender targeted the configured application origin instead of using a wildcard. Origin checks were the first boundary, not complete payload validation. Token-bearing messages also needed known message types and validated fields.
if (event.origin !== dashboardUrl) return
if (event.data?.source !== 'dispo-iframe') return
if (event.data?.type !== 'ROUTE_CHANGE') return
const route = mapV1RouteToV2(event.data.route)
if (route) router.replace(route)One login had to authenticate both applications
The user authenticated once in V2. When V1 loaded, it requested the current user context; V2 returned the SSO token plus user and location data. V1 stored the token for its existing API client and requested a replacement through the same bridge after expiry.
A proactive auth hook was introduced while diagnosing an iframe startup race. It tried to push authentication before V1 asked for it, but it added another timing path and became over-eager. The later change removed that hook and kept reactive request-response auth, which left one clear owner for token timing.
Keep the address bar and browser history truthful
V2 mapped its routes to V1 paths such as /dashboard, /buyer-genie, /deals, and /my-buyers. The reverse map handled both exact routes and nested prefixes. When navigation began inside V1, V2 used router.replace so the address bar and active navigation changed without adding a second history entry for one user action.
This kept refresh, deep links, and browser back/forward coherent. A browser Back action changed the parent route, the parent sent the mapped child route, and the mounted iframe navigated without reloading the shell.
Move clients and features in controlled slices
MIGRATED_LOCATION_IDS was the rollout switch. We started with internal and staging locations, then expanded to eight client locations. Everyone outside the list stayed on the existing V1 path.
- Native V2: Navbar, Topbar, Settings, Investor Intake, SSO, and Twilio Dialer.
- Still in V1 at the recorded midpoint: Home, Genius Mode, Deals, and Buyers.
- Migration unit: one feature and selected customer locations, not one separate launch.
Replacement stayed invisible during rollout
The compatibility layer was eventually removed rather than becoming permanent architecture. The cutover deleted the iframe system and legacy generated API clients, moved surviving clients under V2, added the native sign-in path, and then cleaned routes, tests, shared dialer code, auth guards, and component structure.
For a while we maintained two routers, two session models, cross-window events, route maps, and startup races. That cost was acceptable only because each migrated route moved us closer to deleting the bridge.

