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.
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.
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.
Gotchas & Failure Modes
- Hidden is not unmounted. A legacy tree kept in the DOM with
display: nonestill runs timers and effects and can still callfocus(), producing focus jumps that look like an accessibility bug. - Two
popstatelisteners 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/inventorythat is also handed absolute paths generates/apps/inventory/apps/inventory/items/7. - Trailing slashes are a contract detail. If the old system treated
/reportsand/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.searchdrops 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.
Related
- Migrating from Vue Router 3 to 4 — the concrete API changes, the removed wildcard syntax, and the compatibility shims worth writing.
- Mounting a Micro-Frontend Router in a Host App — basenames, ownership boundaries, and cross-boundary navigation.
- Migrating from React Router v5 to v6 — the same problem inside one framework’s major version bump.
- Framework-Specific Routing Patterns — how each router expresses the primitives an adapter has to bridge.
- Fallback Routing Strategies — what should happen to paths neither system claims.