Mounting a Micro-Frontend Router in a Host App
After reading this you will be able to mount a separately built application inside a host, give it ownership of a URL prefix, and let both routers coexist over one history stack without duplicated entries or a not-found view flashing over working content.
← Back to Router Migration & Interop
Prerequisites
Core Concept
A browsing context has exactly one session history, and every router assumes it owns it. Mounting two means deciding, explicitly, which one writes and how the other is told about changes.
The arrangement that works has three parts.
The host owns history. Only the host calls pushState and replaceState. The child is configured with a history adapter that forwards navigation requests to the host instead of performing them.
The child owns a prefix. Configured with a basename, the child’s route table stays written in its own terms — /items/:id rather than /apps/inventory/items/:id. The host strips the prefix on the way in and the child’s adapter re-adds it on the way out. Every mainstream router supports this: React Router’s basename, Vue Router’s createWebHistory(base), Angular’s APP_BASE_HREF, SvelteKit’s paths.base.
Each router hears only about its own paths. This is the part that eliminates the flashing not-found view. If the child is notified of a host URL it cannot match, it renders its 404 over the host’s content. Filtering notifications by ownership makes that impossible rather than merely unlikely.
Cross-boundary navigation then has one rule: the child never navigates outside its prefix directly. It asks the host, which performs the navigation and unmounts the child. Without that rule the child pushes a URL the host does not know it owns, and neither renders anything.
Implementation
1. Define ownership once
// TypeScript 5.x — the host's single source of truth about who renders what
export const CHILD_PREFIX = "/apps/inventory";
export type Owner = "host" | "child";
export function ownerOf(pathname: string): Owner {
return pathname === CHILD_PREFIX || pathname.startsWith(`${CHILD_PREFIX}/`)
? "child"
: "host";
}
/** Strip the prefix so the child sees its own route space. */
export function toChildPath(pathname: string): string {
return pathname.slice(CHILD_PREFIX.length) || "/";
}
2. Build the navigation bus
// TypeScript 5.x — the only code in the page that touches the History API
type Listener = (pathname: string, owner: Owner) => void;
const listeners = new Set<Listener>();
export const bus = {
navigate(to: string, options: { replace?: boolean; state?: unknown } = {}): void {
const url = new URL(to, location.origin);
if (url.href === location.href) return; // no-op guard
if (options.replace) history.replaceState(options.state ?? history.state, "", url);
else history.pushState(options.state ?? null, "", url);
emit(url.pathname);
},
subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
function emit(pathname: string): void {
const owner = ownerOf(pathname);
for (const listener of listeners) listener(pathname, owner);
}
// Back and forward go through the same path as programmatic navigation.
window.addEventListener("popstate", () => emit(location.pathname));
3. Give the child a history adapter
The child’s router is handed something that looks like a history object but delegates every write. Crucially, its listener is filtered by ownership.
// TypeScript 5.x — the adapter handed to the child at mount time
export function createChildHistory() {
return {
get pathname(): string {
return toChildPath(location.pathname);
},
push(childPath: string, state?: unknown): void {
bus.navigate(`${CHILD_PREFIX}${childPath}`, { state });
},
replace(childPath: string, state?: unknown): void {
bus.navigate(`${CHILD_PREFIX}${childPath}`, { replace: true, state });
},
/** The child hears about ITS paths only — this is what stops the 404 flash. */
listen(onChange: (childPath: string) => void): () => void {
return bus.subscribe((pathname, owner) => {
if (owner === "child") onChange(toChildPath(pathname));
});
},
/** Leaving the child's territory must go through the host. */
navigateOut(hostPath: string): void {
bus.navigate(hostPath);
},
};
}
4. Mount and unmount on ownership changes
Mount the child when it owns the path and unmount it — genuinely, not with display: none — when it does not.
// TypeScript 5.x — the host's mount controller
let unmountChild: (() => void) | null = null;
bus.subscribe(async (pathname, owner) => {
if (owner === "child") {
if (!unmountChild) {
// Lazily loaded: the child's bundle is not shipped to users who never visit it.
const { mount } = await import("inventory-app/mount");
unmountChild = mount(document.getElementById("child-root")!, createChildHistory());
}
return;
}
// Left the child's territory: tear it down so its timers and effects stop.
unmountChild?.();
unmountChild = null;
renderHostRoute(pathname);
});
With a framework router, the adapter usually plugs in even more directly:
// React Router 6.22 inside the child — basename does the prefix work for you
import { createBrowserRouter, RouterProvider } from "react-router-dom";
export function mount(el: HTMLElement, host: ReturnType<typeof createChildHistory>) {
const router = createBrowserRouter(
[
{ path: "/", element: <ItemList /> },
{ path: "/items/:id", element: <ItemDetail /> },
{ path: "*", element: <ChildNotFound /> },
],
{ basename: "/apps/inventory" }, // routes stay written in child terms
);
const root = createRoot(el);
root.render(<RouterProvider router={router} />);
return () => root.unmount();
}
Verification
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("one click produces exactly one history entry across the boundary", async ({ page }) => {
await page.goto("/reports");
const depth = await page.evaluate(() => history.length);
await page.getByRole("link", { name: "Inventory" }).click();
await expect(page.locator("[data-app='inventory']")).toBeVisible();
expect(await page.evaluate(() => history.length)).toBe(depth + 1);
// One Back press must return to the host route, not to an intermediate state.
await page.goBack();
await expect(page.locator("[data-route='reports']")).toBeVisible();
});
test("the child's not-found view never appears over host content", async ({ page }) => {
await page.goto("/apps/inventory/items/7");
await page.getByRole("link", { name: "Account" }).click();
await expect(page.locator("[data-route='account']")).toBeVisible();
await expect(page.locator("[data-child-notfound]")).toHaveCount(0);
});
test("the child is unmounted, not hidden, when the host owns the path", async ({ page }) => {
await page.goto("/apps/inventory/items/7");
await page.getByRole("link", { name: "Account" }).click();
expect(await page.locator("[data-app='inventory']").count()).toBe(0);
});
Gotchas
- Hidden is not unmounted. A child kept in the DOM with
display: nonestill runs timers, still fetches, and can still steal focus. - A doubled basename produces
/apps/inventory/apps/inventory/items/7. It happens when the child is given absolute paths as well as a basename; pick one convention and enforce it in the adapter. - Two
popstatelisteners cannot be made safe by ordering. Filter by ownership; do not try to sequence them. - The child must not link outside its prefix with a plain anchor. Route outbound navigation through the host, or provide a host-aware link component the child uses instead.
- Shared dependencies can duplicate. Two React copies in one page break hooks; align versions or use module federation’s shared scope.
- Scroll and focus behaviour differ between the two routers and users notice the inconsistency most at the boundary. Align them before shipping.
- The child’s bundle should load lazily. Shipping it to every visitor removes the main operational reason for splitting the applications in the first place.
FAQ
Should the child use an iframe instead? An iframe gives real isolation and its own history, at the cost of shared styling, shared state, and a URL that no longer reflects the child’s route. For anything that must be deep-linkable, in-page mounting is the better trade.
How does the child navigate to a host route? Through an explicit method on the adapter — navigateOut above. Giving the child a plain pushState call is exactly what the single-writer rule forbids.
Can the child own more than one prefix? Yes, if the ownership predicate says so. Keep it one function; two similar predicates in two places will drift apart within a release or two.
What about query parameters at the boundary? They belong to whoever owns the path. Strip only the path prefix in the adapter and pass location.search through untouched.
Does this work with server-side rendering? The prefix and ownership predicate work identically on the server; the adapter needs a memory-history equivalent so the child can render without a real window.
Related
- Router Migration & Interop — the single-writer rule and cutover strategies this applies.
- Framework-Specific Routing Patterns — how each router expresses the basename and history adapter the bus needs.
- Fallback Routing Strategies — what should happen to paths that neither the host nor the child claims.