Router Migration & Interop

Routers get replaced more often than almost any other part of a frontend. A major version arrives with a new matching syntax, an application is folded into a larger one, or a rewrite proceeds screen by screen while the old code keeps serving the rest. All three situations produce the same requirement: two routing systems must coexist over one address bar without corrupting each other’s history, and the URLs users have already bookmarked must keep working throughout. This guide covers how to do that deliberately rather than discovering the constraints during the cutover.

← Back to Framework-Specific Routing Patterns

The Problem

Only one thing in a browsing context owns the URL: the session history. Every router assumes it is that owner. Mount two and each will call pushState for its own navigations, listen for popstate, and re-render on any change it sees — including changes the other router made. The result is a set of failures that look like race conditions but are really ownership conflicts.

The first is double rendering. Router A navigates to /reports; router B hears popstate, fails to match /reports against its own table, and renders its not-found view over the top. Users see the correct page flash and then vanish.

The second is history duplication. Both routers push an entry for one user action, so Back appears to do nothing — it undoes the second push and lands on the same URL. Two presses are needed for every navigation, and the count differs depending on which router handled the click.

The third, and the one that damages more than the session, is URL contract drift. A migration that changes /users/:id/posts to /users/:id/activity because the new information architecture is tidier silently breaks every bookmark, every link in an email sent last month, and every search result. The router migrated; the contract with the outside world was never migrated with it.

A fourth appears only in micro-frontend setups: scoping ambiguity. When a hosted application owns /apps/inventory/* and the host owns everything else, both need to agree on where the boundary is, who strips the prefix, and what happens when the hosted app tries to navigate outside its own subtree.

Two routers competing for one history stack A diagram showing one browser history stack with two routers attached. Each router pushes its own entry for a single click and each reacts to the other's popstate, producing duplicated entries and a not-found view rendered over correct content. session history exactly one owner is possible Legacy router pushState + popstate New router pushState + popstate writes writes reacts to the other's writes reacts to the other's writes Double render not-found paints over the real view Duplicated entries Back needs two presses Contract drift old bookmarks 404 silently
Two routers on one history stack is not a race condition — it is an ownership conflict, and it has to be resolved by assigning ownership.

Core API & Primitives

The mechanics of coexistence rest on four things, none of them framework-specific.

A single history writer. Exactly one component may call pushState and replaceState. Everything else requests navigation through it. This is the whole design; the rest is plumbing.

A path ownership predicate. A pure function from pathname to owner. It must be total — every path has an owner, including unknown ones — and it must be the same function both routers consult, not two similar ones that drift apart.

A basename. A hosted router that owns /apps/inventory/* should be configured with that prefix so its internal route table stays written in terms of /items/:id rather than /apps/inventory/items/:id. Every mainstream router supports this: React Router’s basename, Vue Router’s history base argument, Angular’s APP_BASE_HREF, SvelteKit’s paths.base.

A redirect table. For migrations, a declarative map from old paths to new ones, applied before matching. It is the mechanism that keeps the URL contract intact when the route table changes.

// TypeScript 5.x — the interop surface, no dependencies

type Owner = "legacy" | "next";

interface NavigationBus {
  /** The single authority on which system renders a given path. */
  ownerOf(pathname: string): Owner;
  /** The only code permitted to touch the History API. */
  navigate(to: string, options?: { replace?: boolean; state?: unknown }): void;
  /** Called when the URL changed for any reason, including back/forward. */
  subscribe(listener: (pathname: string, owner: Owner) => void): () => void;
}

The bus is deliberately small. Anything larger tends to accumulate framework-specific concepts and stops being usable by both sides.

Step-by-Step Implementation

Prerequisite: a build in which both routers can be loaded, and agreement on which URL prefixes each one owns. Do not start the code until the ownership map exists as a document — it is a product decision as much as a technical one.

1. Write the URL contract down before changing anything

Enumerate every path the old system serves and mark each as kept, renamed (with its new path), or retired (with the path it should redirect to). This artefact is the migration; the code is an implementation detail of it. Paths that appear in emails, printed material, or third-party integrations should be marked kept regardless of how untidy they are.

// TypeScript 5.x — the contract as data, checked into the repository

interface RouteContract {
  from: string;              // pattern in the OLD route syntax
  to?: string;               // new path; omitted means "kept as-is"
  status: 301 | 302;         // permanent for renames, temporary while in flux
  retiredOn?: string;        // ISO date — when the redirect may be removed
}

export const contract: RouteContract[] = [
  { from: "/users/:id/posts", to: "/users/:id/activity", status: 301, retiredOn: "2027-07-30" },
  { from: "/settings/account", to: "/account", status: 301, retiredOn: "2027-07-30" },
  { from: "/reports", status: 302 }, // kept — no rewrite, listed so it is not forgotten
];

2. Elect a single history owner

One router — normally the new one, because it will outlive the other — becomes the writer. The other is reconfigured to use a memory history or an injected adapter so its pushState calls are intercepted rather than executed.

// TypeScript 5.x — an adapter that makes the legacy router ask instead of act

function createLegacyHistoryAdapter(bus: NavigationBus) {
  return {
    push: (to: string, state?: unknown) => bus.navigate(to, { state }),
    replace: (to: string, state?: unknown) => bus.navigate(to, { replace: true, state }),
    // The legacy router must NOT attach its own popstate listener; the bus feeds it.
    listen: (fn: (pathname: string) => void) =>
      bus.subscribe((pathname, owner) => {
        if (owner === "legacy") fn(pathname);
      }),
  };
}

The critical line is the last one: the legacy router is only notified about paths it owns. That single filter eliminates the double-render failure, because the router that does not own a path is never asked to render it.

3. Route ownership at the top of the tree

Mount both systems under one shell that consults the predicate and renders exactly one of them. Keep the non-owning subtree unmounted rather than hidden — a hidden legacy tree still runs effects, still fetches, and still steals focus.

// TypeScript 5.x — ownership as a total function over the path space

const NEXT_PREFIXES = ["/account", "/billing", "/users"];

export function ownerOf(pathname: string): Owner {
  return NEXT_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`))
    ? "next"
    : "legacy"; // unknown paths stay with the legacy system until it is retired
}

Making legacy the fallback is deliberate: during a migration the old system knows about more paths than the new one, so unmatched routes should keep reaching it. Once the migration completes, flip the default to next and delete the predicate.

4. Apply the redirect table before matching

Run the contract at the top of the navigation path, so both cold loads and in-app navigations are normalised to current URLs before any router sees them. Use replaceState for the rewrite so the retired URL does not become a history entry the user can go Back to.

// TypeScript 5.x — normalise once, at the boundary

function applyContract(pathname: string): string | null {
  for (const rule of contract) {
    if (!rule.to) continue;
    const pattern = new RegExp(
      "^" + rule.from.replace(/:[^/]+/g, "([^/]+)") + "$",
    );
    const match = pattern.exec(pathname);
    if (!match) continue;
    // Substitute captured params positionally into the replacement path.
    let i = 1;
    return rule.to.replace(/:[^/]+/g, () => match[i++]);
  }
  return null; // no rewrite needed
}

Mirror the same table at the edge — in your CDN or origin rewrite rules — so a cold load on a retired URL is redirected before any JavaScript runs. A client-only redirect still returns a 200 for a URL that should be a 301, which teaches crawlers the wrong thing.

5. Migrate one screen at a time, verifying the seam

Move a screen by adding its prefix to the new router’s list, deleting it from the old route table, and running the full deep-link suite. Because ownership is one function, a screen’s migration is a one-line change that can be reverted in seconds — which is what makes an incremental cutover safe enough to do during business hours.

A single navigation bus arbitrating between two routers A URL change enters the bus, is normalised by the redirect contract, passed to the ownership predicate, and dispatched to exactly one router. Only the bus writes to the History API. URL change click, back, cold load Contract rewrite retired paths replaceState, never push ownerOf(path) total function one source of truth New router mounted Legacy router unmounted, not hidden Only the bus calls pushState. Each router is told about paths it owns, and nothing else.
One bus, one contract, one ownership predicate — the non-owning router is never told about a path it cannot render.

Picking a Cutover Strategy

Three shapes of migration exist, and choosing the wrong one is more expensive than any implementation detail below it.

Strategy How it works Best when Main risk
Big-bang replacement Both route tables are rewritten and released together The application is small, or the routers are close enough that the diff is mechanical Every regression lands at once, and rollback is a full revert
Route-by-route coexistence Both routers ship; ownership moves one prefix at a time The application is large or continuously deployed Two routers in the bundle for the duration; the seam needs its own tests
Strangler at the edge The CDN routes whole path prefixes to two separately deployed applications The teams are separate, or the stacks cannot share a bundle Full page load when crossing the boundary; shared state must be re-established

The middle option is the default for most product teams, and the one this guide’s code targets. It keeps every deploy small, makes each screen’s migration independently revertible, and confines the coexistence machinery to a single module that gets deleted at the end.

The edge strategy deserves more consideration than it usually gets. It has one property the others lack: the two applications share nothing at runtime, so neither can break the other. The cost is a full document load when a user crosses the boundary, which is acceptable when the boundary lines up with a natural context switch — an admin area, a checkout flow, a reporting suite — and unacceptable when it cuts through a workflow users move back and forth across all day. Draw the boundary where users already pause, and the cost disappears from their perception entirely.

Big-bang replacement is correct more often than its reputation suggests. If the route table is under fifty entries and the two routers share a matching syntax, the coexistence machinery costs more to build and remove than the rewrite costs to do. The honest test is whether you can enumerate the URL contract in an afternoon; if you can, you probably do not need a seam.

Verification & Testing

Migrations fail on the URLs nobody thought to test, so the suite should be generated from the contract rather than hand-written.

// @playwright/test v1.44 — every contract entry becomes a cold-load assertion
import { test, expect } from "@playwright/test";
import { contract } from "../src/routing/contract";

for (const rule of contract) {
  const concrete = rule.from.replace(/:[^/]+/g, "42"); // substitute a sample param

  test(`cold load of ${concrete} resolves without a 404`, async ({ page }) => {
    const response = await page.goto(concrete);
    expect(response?.status()).toBeLessThan(400);

    if (rule.to) {
      // A renamed path must land on its replacement, not merely render something.
      await expect(page).toHaveURL(rule.to.replace(/:[^/]+/g, "42"));
    }

    // And Back must leave the app, not bounce through the retired URL.
    const depthBefore = await page.evaluate(() => history.length);
    await page.goto(concrete);
    expect(await page.evaluate(() => history.length)).toBe(depthBefore + 1);
  });
}

Add one test for the seam itself: navigate from a legacy screen to a migrated screen and back, asserting that history.length grows by exactly one per navigation and that no not-found view ever appears in the DOM, even transiently. Playwright’s toHaveCount(0) on the not-found selector, evaluated immediately after the click, catches the flash that a screenshot at the end of the test would miss.

For the redirect layer, check the edge separately from the client with curl -I: a retired path should answer 301 with a Location header, not 200 with a client-side rewrite.

Performance Tuning

Never ship both routers to every user. Split them into separate chunks and load the legacy bundle only when ownerOf returns legacy. Shipping both to everyone doubles the routing cost of the migration for its entire duration, which is often a year.

Resolve redirects at the edge. A client-side rewrite costs a full application boot before the correct route even starts loading. The edge rule costs one round trip and no JavaScript.

Keep the ownership predicate allocation-free. It runs on every navigation and, in some setups, on every link render for prefetch decisions. Prefix arrays and startsWith are fine; constructing regular expressions inside it is not.

Prefetch across the seam deliberately. A hover on a link that crosses from legacy to new should prefetch the new router’s chunk. Without it the first cross-seam navigation pays for a bundle download the user has never needed before, and it is exactly the navigation most likely to be someone’s first impression of the migration.

Retire the shim on a schedule. The adapter, the predicate and the redirect table all cost bytes and complexity. The retiredOn dates in the contract exist so their removal is a planned task rather than something that never happens.

The main risk in each cutover strategy Four migration approaches with the principal risk of each and the mitigation: big-bang lands all regressions at once, coexistence doubles the bundle, the edge strategy costs a document load at the boundary, and having no strategy leads to broken bookmarks. Migration risks by cutover strategy biggest risk how to bound it big-bang replacement every regression lands at once only for tables you can enumerate in a day route-by-route coexistence two routers in the bundle split them; load the legacy one lazily strangler at the edge a full load at the boundary draw the boundary where users already pause no explicit strategy URL contract drift write the contract down before any code
The fourth row is the common one, and it is the only risk on this list that reaches users who never visit the migrated screens.

Gotchas & Failure Modes

  • Hidden is not unmounted. A legacy tree kept in the DOM with display: none still runs timers and effects and can still call focus(), producing focus jumps that look like an accessibility bug.
  • Two popstate listeners cannot be made safe by ordering. Filtering by ownership is the only reliable fix; adding guards based on which listener runs first breaks whenever a browser changes dispatch order.
  • Basename mismatches produce doubled prefixes. A hosted router configured with /apps/inventory that is also handed absolute paths generates /apps/inventory/apps/inventory/items/7.
  • Trailing slashes are a contract detail. If the old system treated /reports and /reports/ as the same page and the new one does not, half the bookmarks in the world break. Normalise explicitly and test both forms.
  • Query parameters survive rewrites only if you carry them. A redirect implementation that rebuilds the path and forgets location.search drops every filter from shared links.
  • Scroll and focus behaviour differ between routers. Users notice the inconsistency at the seam more than they notice either behaviour on its own; align them before migrating the first screen.
  • Micro-frontend children must not navigate outside their subtree directly. Route every outbound navigation through the host’s bus, or the child will push a URL the host does not own and cannot render.