Sharing a Single Router Instance Between Host and Remotes #

When a host and a remote each mount their own BrowserRouter, React Router throws “You cannot render a <Router> inside another <Router>” — the fix is to render exactly one router in the host and let remotes contribute route subtrees into that single context.

This guide shows how to mark react-router-dom as a shared singleton in Module Federation, render the router once in the host, expose route subtrees from remotes, and keep a standalone dev fallback so each remote still runs on its own. It is part of the broader work on Routing Coordination Across Micro-Frontends.

One router context shared from host to remotes A single BrowserRouter in the host wraps Routes that mount remote-exported route subtrees, all reading one history and URL. Host: <BrowserRouter> (one history + URL) react-router-dom shared as singleton Host <Routes> — composes child route trees Remote A <Route> subtree no own Router Remote B <Route> subtree no own Router Remote C <Route> subtree no own Router
The host owns the only router; remotes export route subtrees that read the same history and URL.

Prerequisites #

The non-negotiable rule: the router and its history object are stateful singletons. If two copies exist, two contexts exist, and link clicks update one while the other never re-renders. Treat the router exactly like React itself — see Managing Shared Dependencies at Runtime for the runtime negotiation that makes this work.

Step 1 — Mark react-router-dom as a shared singleton #

In both the host and every remote, declare react-router-dom as a singleton with a strict required version. The host and remotes must agree, or the federation runtime will load two copies.

// shared.js — imported by host and every remote webpack config
const deps = require('./package.json').dependencies;

module.exports = {
  react: { singleton: true, requiredVersion: deps.react, eager: false },
  'react-dom': { singleton: true, requiredVersion: deps['react-dom'], eager: false },
  'react-router-dom': {
    singleton: true,
    requiredVersion: deps['react-router-dom'],
    // The router holds the only history instance — never tolerate two copies.
    strictVersion: true,
    eager: false,
  },
};

Wire it into each ModuleFederationPlugin:

// webpack.config.js (host AND remotes use the same `shared` block)
const { ModuleFederationPlugin } = require('webpack').container;
const shared = require('./shared');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        orders: 'orders@https://cdn.example.com/orders/remoteEntry.js',
      },
      exposes: {}, // host exposes nothing here
      shared,
    }),
  ],
};

singleton: true ensures only one react-router-dom module evaluates at runtime, so there is exactly one router context. This is the same deduplication discipline described in Configuring Shared Singletons to Deduplicate React.

Step 2 — Render the Router once, at the top of the host #

The host is the only place a <BrowserRouter> (or createBrowserRouter) lives. Everything below it — including federated remotes — inherits that context.

// host/src/App.tsx
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { lazy, Suspense } from 'react';

const OrdersRoutes = lazy(() => import('orders/Routes')); // remote route subtree

export default function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/orders">Orders</Link>
      </nav>

      <Routes>
        <Route path="/" element={<Home />} />
        {/* The trailing /* hands the rest of the path to the remote */}
        <Route
          path="/orders/*"
          element={
            <Suspense fallback={<p>Loading orders…</p>}>
              <OrdersRoutes />
            </Suspense>
          }
        />
      </Routes>
    </BrowserRouter>
  );
}

function Home() {
  return <h1>Dashboard</h1>;
}

The path="/orders/*" splat is what lets a remote own everything under /orders. The remote’s own <Route path> values are then relative to /orders.

Step 3 — Export a route subtree from the remote (no Router inside) #

The remote exposes a component that renders <Routes>/<Route> only. It must never render <BrowserRouter>, <HashRouter>, or createBrowserRouter when running inside the host — that is precisely what triggers the nested-router error.

// orders/src/Routes.tsx — exposed via Module Federation
import { Routes, Route } from 'react-router-dom';
import OrderList from './OrderList';
import OrderDetail from './OrderDetail';

// Paths are relative to wherever the host mounts this subtree (/orders/*)
export default function OrdersRoutes() {
  return (
    <Routes>
      <Route index element={<OrderList />} />
      <Route path=":orderId" element={<OrderDetail />} />
    </Routes>
  );
}

Expose it in the remote’s webpack config:

// orders/webpack.config.js
new ModuleFederationPlugin({
  name: 'orders',
  filename: 'remoteEntry.js',
  exposes: {
    './Routes': './src/Routes', // route subtree, not a whole app
  },
  shared, // same singleton block from Step 1
});

Inside OrderDetail, useParams(), useNavigate(), and <Link> all resolve against the host’s single history. A <Link to=".."> climbs back to the list; an absolute <Link to="/checkout"> jumps to a route the host or another remote owns.

Step 4 — Provide a memory-router fallback for standalone dev #

Each remote still needs to run on its own (npm run dev in the remote folder) for fast iteration. The trick is to keep the router out of the exposed module and add it only in the standalone entry point.

// orders/src/standalone.tsx — used only when the remote runs by itself
import { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import OrdersRoutes from './Routes';

createRoot(document.getElementById('root')!).render(
  // MemoryRouter gives a self-contained history with no host present.
  <MemoryRouter initialEntries={['/']}>
    <OrdersRoutes />
  </MemoryRouter>,
);

Point the remote’s dev HtmlWebpackPlugin/entry at standalone.tsx, while the exposed ./Routes stays router-free. Use BrowserRouter here instead of MemoryRouter if you want the URL bar to work in isolation; MemoryRouter avoids publicPath/deep-link friction during quick component work.

Step 5 — Pass router context across the federation boundary (when needed) #

With react-router-dom shared as a true singleton, the context flows automatically — the remote imports the same module instance as the host, so useNavigate and friends just work. No manual context passing is required.

If you cannot guarantee a singleton (mixed bundlers, or a remote on a different router major), pass the imperative pieces down explicitly instead of relying on shared context:

// host: hand the remote a stable navigate function
import { useNavigate } from 'react-router-dom';

function OrdersHost() {
  const navigate = useNavigate();
  return <OrdersRoutes onNavigate={navigate} />;
}
// remote: use the injected navigator instead of importing its own
type Props = { onNavigate?: (to: string) => void };

export default function OrdersRoutes({ onNavigate }: Props) {
  const go = onNavigate ?? ((to: string) => { window.location.assign(to); });
  // call go('/orders/42') from buttons; <Route> still reads host context
  return <Routes>{/* … */}</Routes>;
}

Prefer the singleton path. The injection pattern is a bridge for graphs you don’t fully control — see Synchronizing Browser History Across Micro-Frontend Shells when remotes use different router libraries entirely.

Verification #

Confirm there is genuinely one router before shipping:

import { useLocation } from 'react-router-dom';
// Render this in a host component and a remote component, compare output:
function RouterProbe() {
  const loc = useLocation();
  console.log('[router-probe]', loc.key, loc.pathname);
  return null;
}

If host and remote print the same loc.key for the same navigation, they share one history.

Troubleshooting #

Symptom: “You cannot render a <Router> inside another <Router>.”

Diagnosis: a remote is rendering its own BrowserRouter/MemoryRouter while mounted inside the host. Fix: remove the router from the exposed module (Step 3) and move it into the standalone entry only (Step 4). The exposed component must render <Routes> at most.

Symptom: links update the URL but the remote never re-renders (two routers, no error).

Diagnosis: react-router-dom is not actually a singleton — each side bundled its own copy, so there are two contexts and two histories. Fix: confirm singleton: true and matching requiredVersion in every shared block, then check the build output for duplicate react-router-dom chunks. The runtime resolution rules in Managing Shared Dependencies at Runtime cover how to inspect which copy won.

Symptom: the remote works standalone but blanks out (or 404s assets) inside the host.

Diagnosis: the standalone router/entry leaked into the federated build, or publicPath is wrong so the remote’s chunks load from the host origin. Fix: keep standalone.tsx out of the exposes map, and set output.publicPath: 'auto' on the remote so chunks resolve against the remote’s own origin.

Symptom: version mismatch warning, or useNavigate returns undefined.

Diagnosis: host and remote are on different react-router-dom majors (e.g. v6 vs v7); strictVersion rejected the singleton and each loaded its own incompatible copy. Fix: align both on one major, then re-run with strictVersion: true so a mismatch fails loudly at build time instead of silently splitting context. If alignment is impossible short-term, fall back to the injected-navigator bridge in Step 5.