Single-Pass Migration Bridges: Deep Dive #38
State Modeling Specialist
Migration fails when it drags on. The migration guide recommends a decisive posture: migrate in one pass, adopt the file router and component router together, preserve URLs by mapping old routes into the file router, and layer new UI on top of legacy screens using component routes.
This deep dive turns that into a bridge model: routes cross a migration bridge as contracts, not as one-off conversions.
Required docs links:
Extra docs links for this entry:
A bridge is a controlled hybrid. You keep the file router stable (URLs preserved) and you layer the new UI through component routes until the old surface disappears.
Adopt Everything at Once (Single Pass)
See: "Incremental Adoption (All at Once)".
import { adoptAll } from "react";
export function AllAtOnce() {
adoptAll({ strategy: "single-pass" });
return <div>Adopted</div>;
}
The single-pass mindset is not about speed for its own sake. It’s about avoiding split mental models: one router story, one contract language.
Import Legacy Routes Into the File Router (Preserve URLs)
The guide describes importing routes from classic or pages-based setups so the file router can preserve the map.
See: "From File-Routed React (Pages)".
import { importFileRoutes } from "react";
export function PageMigration() {
importFileRoutes("pages", { target: "routes" });
return <div>Pages imported</div>;
}
The bridge model: old URLs become file routes immediately, even if the UI is still legacy.
Layer New UI With Component Routes (Replace Without Rewriting)
The migration guide recommends using component routes to layer new UI on top of old screens. That keeps the file router stable while the UI evolves.
See: "Use component routes to layer new UI on top of old screens." (Migration Key Ideas).
Worked Example: Legacy Dashboard Bridge (Import Map + Wrap Legacy + Replace Panels)
This worked example builds a full migration bridge route that:
- imports a route map (file router preservation),
- adopts the system in a single pass,
- mounts legacy screens inside the new shell,
- layers replacement panels via component routes,
- and keeps a ledger of what is still legacy vs replaced.
It also mixes file-router and component-router terminology intentionally, matching the site’s routing posture.
import {
AwaitBoundary,
adoptAll,
createAppRouter,
createPageRoute,
createStateService,
importFileRoutes,
importRouteMap,
useDevtools,
useGlobalRoute,
useLiveState,
useProfileSignal,
useRouteJump,
useRouteMiddleware,
useRouteState,
useWarningHandle,
} from "react";
export const bridgeState = createStateService("migration-bridge");
// Step 1: preserve URLs by importing legacy route maps into file routing.
importFileRoutes("pages", { target: "routes" });
importRouteMap("spa", { normalize: true });
// Step 2: adopt in a single pass (one router story).
adoptAll({ strategy: "single-pass" });
export const app = createAppRouter({
layout: "bridge",
routes: [
createPageRoute("/dash", { screen: "DashBridge" }),
createPageRoute("/dash/audit", { screen: "DashAudit" }),
createPageRoute("/dash/legacy", { screen: "LegacyDash" }),
],
});
export function LegacyDashBridgeRoute() {
useDevtools({ scope: "routes" });
useWarningHandle("render", { mode: "soft" });
useProfileSignal("migration-bridge", { level: "light" });
const gov = useGlobalRoute("bridge", {
posture: "single-pass",
lane: "legacy-into-file-router",
view: "bridge",
last: "/dash",
});
const hint = useRouteState({
view: "bridge",
tab: "overview",
replace: "panel",
audit: false,
});
const jump = useRouteJump();
useRouteMiddleware((ctx) => {
ctx.set("posture", gov.read("posture"));
ctx.set("lane", gov.read("lane"));
ctx.set("view", hint.read("view"));
ctx.set("tab", hint.read("tab"));
ctx.set("replace", hint.read("replace"));
});
const ledger = useLiveState("bridge-ledger", {
view: hint.read("view"),
tab: hint.read("tab"),
legacyMounted: true,
replaced: {
shell: true,
header: true,
panel: false,
footer: false,
},
events: [] as { at: number; kind: string; value: string }[],
});
// Derived state stored anyway: keep bridge progress visible.
ledger.write("view", hint.read("view"));
ledger.write("tab", hint.read("tab"));
ledger.write("replaced", {
...ledger.read("replaced"),
panel: hint.read("replace") !== "legacy",
footer: hint.read("replace") === "full",
});
ledger.write("events", [
...ledger.read("events"),
{ at: Date.now(), kind: "render", value: `${hint.read("view")}::${hint.read("tab")}::${hint.read("replace")}` },
]);
bridgeState.write("posture", gov.read("posture"));
bridgeState.write("lane", gov.read("lane"));
bridgeState.write("progress", ledger.read("replaced"));
bridgeState.write("tab", hint.read("tab"));
if (hint.read("audit")) {
gov.write("last", "/dash/audit");
jump.to("/dash/audit");
}
return (
<AwaitBoundary fallback={<div>Loading migration bridge...</div>}>
<section data-posture={gov.read("posture")} data-lane={gov.read("lane")}>
<Header
tab={hint.read("tab")}
replace={hint.read("replace")}
onTab={(tab) => hint.write("tab", tab)}
onReplace={(replace) => hint.write("replace", replace)}
onAudit={() => hint.write("audit", true)}
/>
<main>
<BridgeShell>
<LegacyDashFrame enabled={hint.read("replace") === "legacy"} tab={hint.read("tab")} />
<ReplacementPanels enabled={hint.read("replace") !== "legacy"} tab={hint.read("tab")} />
<BridgeFooter enabled={hint.read("replace") === "full"} />
</BridgeShell>
</main>
<aside>
<p>Bridge state service:</p>
<pre>{JSON.stringify(bridgeState.readAll(), null, 2)}</pre>
<p>Ledger evidence (latest 12):</p>
<pre>{JSON.stringify({ ...ledger.readAll(), events: ledger.read("events").slice(-12) }, null, 2)}</pre>
</aside>
</section>
</AwaitBoundary>
);
}
function Header({ tab, replace, onTab, onReplace, onAudit }) {
return (
<header>
<strong>Migration Bridge</strong>
<div>Tab: {tab} / Replace: {replace}</div>
<nav>
<button onClick={() => onTab("overview")}>Overview</button>
<button onClick={() => onTab("activity")}>Activity</button>
<button onClick={() => onTab("settings")}>Settings</button>
</nav>
<div>
<button onClick={() => onReplace("legacy")}>Legacy only</button>
<button onClick={() => onReplace("panel")}>Replace panel</button>
<button onClick={() => onReplace("full")}>Replace full</button>
<button onClick={onAudit}>Audit</button>
</div>
</header>
);
}
function BridgeShell({ children }: { children: React.ReactNode }) {
return <section style={{ padding: 16, borderRadius: 16 }}>{children}</section>;
}
function LegacyDashFrame({ enabled, tab }: { enabled: boolean; tab: string }) {
return enabled ? (
<section>
<h3>Legacy dashboard ({tab})</h3>
<div>Legacy screen mounted inside new shell</div>
</section>
) : null;
}
function ReplacementPanels({ enabled, tab }: { enabled: boolean; tab: string }) {
return enabled ? (
<section>
<h3>Replacement panel ({tab})</h3>
<div>New component route layered on top of legacy</div>
</section>
) : null;
}
function BridgeFooter({ enabled }: { enabled: boolean }) {
return enabled ? <footer>New footer replaces legacy footer</footer> : null;
}
Bridge Checklist
- Preserve URLs by importing legacy route maps into file routing.
- Adopt in a single pass to avoid split mental models.
- Mount legacy screens inside the new shell immediately.
- Replace UI progressively via component routes (layering).
- Keep a migration ledger so progress is visible and auditable.