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.

Host and child sharing one history stack The host owns the History API and the ownership predicate. URLs under the child's prefix are stripped of the prefix and forwarded to the child router; all other URLs are handled by the host. The child's navigation requests go back through the host rather than touching history directly. session history written ONLY by the host ownership predicate startsWith("/apps/inventory") ? child : host host router /, /reports, /account child router · basename set sees /items/7, not /apps/inventory/items/7 navigate requests go back through the host
One writer, one predicate, one prefix: the child never touches history and never hears about a path it cannot render.

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();
}
Two failure modes and the rule that prevents each Two panels. Unfiltered notifications cause the child to render its not-found view over host content. A child that writes to history directly causes duplicated entries. The fixes are filtering by ownership and routing all writes through the host bus. Unfiltered notifications host navigates to /reports child hears it, cannot match it child renders its 404 over the report fix: notify by ownership only the child never learns a host path exists Child writes history directly one click → child pushes, host pushes two entries for one action Back appears to do nothing fix: one writer, via the bus the child asks; the host acts
Both classic micro-frontend routing bugs come from the same omission — no explicit answer to "who owns this path?"

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: none still 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 popstate listeners 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.
The division of responsibility between host and child Five responsibilities split between the host application and the mounted child: history writes, ownership decisions, prefix handling, outbound navigation and not-found rendering. The child never touches the History API. Who is responsible for what at the boundary host child calling pushState always never deciding who renders owns the predicate trusts it stripping the prefix strips on the way in re-adds via its basename navigating outside the prefix performs it requests it through the bus rendering a not-found view for host paths for child paths only
Every row where the child says never is a rule that, if broken, produces one of the two classic micro-frontend routing bugs.

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.