Routing Coordination Across Micro-Frontends #
The most reliable way to break a micro-frontend platform is to let every remote ship its own router and assume it owns the URL. Each app calls history.pushState, each one mounts a <BrowserRouter> or RouterModule.forRoot(), and the browser quietly hands all of them the same single window.history object. The result is a tug-of-war: navigations fire twice, the back button skips entries or lands on a blank shell, and deep links resolve against whichever router happened to boot first.
This guide sits under Cross-App State & Context Sharing, because the URL is shared state — arguably the most important piece of shared state you have. It is the one value that survives reloads, gets bookmarked, lands in support tickets, and drives server-side rendering. Treat it with the same discipline you would apply to an auth token or a feature flag.
We cover the failure mode in detail, then the fix: a single-writer history model where the host shell owns navigation and remotes receive route context as data. From there we go into the three child guides — Synchronizing Browser History Across Micro-Frontend Shells for the low-level history mechanics, Sharing a Single Router Instance Between Host and Remotes for the shared-instance pattern, and Handling Deep Links and Route Guards in Federated Apps for hydration and authorization.
What actually breaks #
The browser exposes exactly one history stack per tab. There is no namespacing, no per-app instance, no way to own a slice of it. When two routers both wrap pushState and both subscribe to popstate, they interfere in ways that are maddening to reproduce:
- Double navigations. The host pushes
/orders/42, a remote’s router observes the change, re-derives its own state, and pushes again. You get two history entries for one user action, so one back-press appears to do nothing. - Lost back-button state. A remote calls
history.replaceStateto stash scroll position or filter state, clobbering the entry the host wrote. The back button then restores a URL the host never expected and can’t match. - Broken deep links. A user opens
/billing/invoices/9cold. The billing remote loads asynchronously, but its router initialized against/before the remote’s chunk arrived, so the deep route silently falls back to the index view. - Listener storms. Three frameworks each patch
window.history.pushState. Now a single navigation triggers a cascade of monkey-patched callbacks, each firing the others.
The root cause is always the same: multiple writers to a single resource with no coordination protocol. The fix is to designate one writer.
Key objectives #
- Establish a single-writer history model: exactly one piece of code calls
pushState/replaceStateand listens topopstate. - Position the host shell as router owner; remotes consume route context as read-only data and request navigation through an API, never by touching
historydirectly. - Propagate route changes via an event-based channel so framework-mixed remotes stay in sync without coupling to the host’s router type.
- Support mixed routing stacks — React Router, Vue Router, and Angular Router coexisting in one shell.
- Make deep-link hydration and route guards deterministic regardless of remote load order.
The single-writer model #
There are two viable architectures. The first is to share one router instance across host and remotes (covered in depth in Sharing a Single Router Instance Between Host and Remotes) — clean when every app uses the same framework version, brittle the moment you mix React Router 6 with Angular. The second, and the one that scales across frameworks, is the single-writer history model: the host owns window.history exclusively, and remotes get a RouteContext object plus a navigate() callback.
In the single-writer model, remotes never import a router that touches the browser. Instead they mount against an in-memory or “controlled” router whose location is driven by props the host pushes down. The host is the only entity allowed to write history; everyone else reads.
Setup/Config #
Two build-time concerns make routing coordination work. First, the host’s routing primitives must be a shared singleton so remotes that do read the location consume the same module instance. Second, remotes must be configured to load lazily so deep links can wait for the right chunk.
// host/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
billing: 'billing@/remotes/billing/remoteEntry.js',
orders: 'orders@/remotes/orders/remoteEntry.js',
},
shared: {
// The route-context module is the contract every app reads from.
// Singleton guarantees one event channel and one source of truth.
'@platform/route-context': { singleton: true, requiredVersion: '^1.0.0', strictVersion: true },
// react-router is shared so React remotes don't bundle a second
// copy that would re-patch history.
'react-router-dom': { singleton: true, requiredVersion: '^6.22.0' },
},
}),
],
};
singleton: true on @platform/route-context is the load-bearing line. It guarantees there is exactly one event emitter and one current-route value across the host and every remote, the same way singleton config keeps a shared store coherent in Synchronizing Redux Across Micro-Frontends. Lazy remote loading itself is covered in Dynamically Loading Remote Modules at Runtime.
The route-context contract #
The shared module is small and framework-neutral. It holds the current location, lets the host publish updates, and lets anyone subscribe.
// @platform/route-context/index.ts
type RouteSnapshot = {
pathname: string;
search: string;
hash: string;
state: unknown;
};
type RouteListener = (route: RouteSnapshot) => void;
class RouteContext {
private current: RouteSnapshot = snapshot();
private listeners = new Set<RouteListener>();
get route() {
return this.current;
}
// Only the host calls publish — it runs after history is written.
publish(next: RouteSnapshot) {
this.current = next;
this.listeners.forEach((fn) => fn(next));
}
subscribe(fn: RouteListener) {
this.listeners.add(fn);
fn(this.current); // emit immediately so late mounts hydrate
return () => this.listeners.delete(fn);
}
}
function snapshot(): RouteSnapshot {
const { pathname, search, hash } = window.location;
return { pathname, search, hash, state: window.history.state };
}
export const routeContext = new RouteContext();
The subscribe call emits the current route synchronously on registration. That single line solves the most common deep-link race: a remote that mounts after the initial navigation still receives the correct location immediately instead of waiting for the next change.
Integration #
The host wires its own router to the route-context module, then exposes a navigate() API. Remotes mount controlled routers driven by the context. This is the same shape as other coordination patterns in Alternatives to Prop Drilling in Distributed UIs: a shared service replaces direct, brittle coupling.
The history-sync adapter #
This adapter is what makes the host the single writer. It wraps history writes, publishes to the route-context after each change, and is the only code in the system that touches popstate.
// host/historySyncAdapter.ts
import { routeContext } from '@platform/route-context';
type NavOptions = { replace?: boolean; state?: unknown };
export function createHistorySync() {
const publish = () => {
const { pathname, search, hash } = window.location;
routeContext.publish({ pathname, search, hash, state: window.history.state });
};
// Single writer: every programmatic navigation funnels through here.
function navigate(to: string, opts: NavOptions = {}) {
const fn = opts.replace ? 'replaceState' : 'pushState';
// Guard: skip no-op navigations to avoid duplicate history entries.
if (!opts.replace && to === window.location.pathname + window.location.search + window.location.hash) {
return;
}
window.history[fn](opts.state ?? null, '', to);
publish();
}
// Single popstate listener for the whole application.
const onPop = () => publish();
window.addEventListener('popstate', onPop);
// Hydrate subscribers with the boot-time location (deep-link entry).
publish();
return {
navigate,
destroy: () => window.removeEventListener('popstate', onPop),
};
}
Because programmatic navigation only happens through navigate() and popstate is handled exactly once, there is no path for a second writer to enter. The no-op guard kills the double-navigation bug at its source.
Mixed-framework remotes #
Each framework has a “controlled” or “memory” router mode that reads location from props instead of the browser. Wire each to the route-context.
// React remote — controlled router driven by route-context, not BrowserRouter.
import { unstable_HistoryRouter as HistoryRouter } from 'react-router-dom';
import { routeContext } from '@platform/route-context';
const memoryHistory = createMemoryRouterBridge(routeContext);
export function RemoteRoot() {
return (
<HistoryRouter history={memoryHistory}>
<Routes>{/* remote-local routes */}</Routes>
</HistoryRouter>
);
}
// Vue remote — createMemoryHistory, fed by route-context subscription.
import { createRouter, createMemoryHistory } from 'vue-router';
import { routeContext } from '@platform/route-context';
const router = createRouter({ history: createMemoryHistory(), routes });
routeContext.subscribe((r) => {
const target = r.pathname + r.search + r.hash;
if (router.currentRoute.value.fullPath !== target) router.replace(target);
});
For Angular, set RouterModule.forRoot(routes, { urlHandlingStrategy }) with a custom UrlHandlingStrategy that returns false for shouldProcessUrl on routes the host owns, so Angular’s router never writes the browser URL — it only renders the segment it’s responsible for. Remotes call the host’s navigate() for cross-app links; intra-remote links can stay in the memory router.
Edge cases #
- Boot-order races. A deep link to a remote’s route arrives before the remote’s chunk loads. Because
subscribereplays the current route on registration, the remote hydrates correctly whenever it mounts. Show a route-level skeleton until the remote signals ready. - Concurrent navigation requests. Two remotes call
navigate()in the same tick. Serialize through the single adapter; the no-op guard and last-write-wins ordering keep history consistent. Debounce rapid identical calls. - State serialization.
history.statemust survive structured clone. Strip class instances and functions before passing them asopts.state; store rich objects in a shared store keyed by an ID you put in the URL. - Hash vs path routing mismatch. If one remote insists on hash routing, normalize at the adapter so the canonical location always reflects the full URL.
- Trailing-slash and query drift. Decide canonical form once in
navigate()andreplaceStateto it on boot so guards and matchers see identical strings.
Testing/Validation #
- Unit: Mock
window.historyand assertnavigate()produces exactly one entry per call, honors the replace flag, and skips no-ops. Assertsubscribereplays the current route immediately. - Integration: Mount the host with two remotes in jsdom; drive a navigation and assert both remotes’ controlled routers report the new path without writing history twice.
- Back-button: In Playwright, navigate host → remote A → remote B, then press back twice and assert each step restores the exact prior URL and rendered view.
- Deep-link cold load: Open a remote-owned URL directly and assert the correct remote renders the deep route, not the index fallback.
- Listener audit: Assert there is exactly one
popstatelistener after all remotes mount — a count above one means a remote is patching history.
Deployment #
- Feature-flag the writer. Roll the single-writer adapter out behind a flag so you can revert to per-remote routing if a remote isn’t yet migrated. Progressive rollout patterns apply directly here.
- Telemetry. Emit a metric on every
navigate()andpopstatewith source app and resulting path. Alert on double-entry signatures (two pushes within one frame) and on deep-link fallback hits. - Version the contract. Treat the
RouteSnapshotshape as a published API. Add fields additively; never repurpose existing ones. Keep@platform/route-contextbackward compatible for at least two release cycles. - Rollback. Because the host owns navigation, a bad remote can’t corrupt the URL — disabling the remote restores routing immediately. Keep that property by never letting a remote write history directly, even temporarily.
Common Pitfalls #
| Issue | Root Cause & Resolution |
|---|---|
| Back button skips or no-ops | Two routers both wrote history for one user action. Fix: Route all programmatic navigation through the single-writer adapter and add a no-op guard against the current URL. |
| Deep links fall back to the index view | The remote’s router initialized against / before its chunk loaded. Fix: Replay the current route on subscribe, lazy-load the remote, and render a route-level skeleton until ready. |
| Multiple popstate handlers fire per navigation | Each framework monkey-patched window.history. Fix: Put remotes on memory/controlled routers; only the host listens to popstate. Audit listener count in tests. |
| Lost filter or scroll state on back | A remote called replaceState and clobbered the host’s entry. Fix: Forbid direct history writes from remotes; expose navigate({ replace }) and store rich state in a shared store keyed by a URL ID. |
| Angular remote rewrites the whole URL | RouterModule.forRoot defaults to processing every URL. Fix: Supply a custom UrlHandlingStrategy that declines host-owned routes so Angular renders without writing the browser URL. |
FAQ #
Should the host own routing, or should remotes each run their own router?
The host should own writes to window.history. There is only one history stack per tab, so a single writer is the only way to avoid double navigations and lost back-button state. Remotes can still run a router internally — just a controlled or memory router that reads location from the host instead of touching the browser.
Can React Router, Vue Router, and Angular Router coexist in one shell?
Yes, as long as none of them writes window.history. Run each on its memory/controlled mode and feed it the current location from the shared route-context. Cross-app links call the host’s navigate(); intra-remote links can stay local. The frameworks never see each other.
How do deep links work when the target remote loads asynchronously?
The shared route-context replays the current route the moment a subscriber registers, so a remote that mounts after the initial navigation still hydrates against the correct URL. Pair that with lazy remote loading and a route-level skeleton, and a cold deep link resolves once the remote’s chunk arrives. Route guards are covered in Handling Deep Links and Route Guards in Federated Apps.
Why use an event-based route channel instead of just sharing a router instance?
Sharing one router instance is cleaner when every app runs the same framework and version. The moment you mix frameworks or major versions, a shared instance becomes impossible, and an event-based channel decouples remotes from the host’s router type entirely. The tradeoffs of each approach are detailed in Sharing a Single Router Instance Between Host and Remotes.