← Back to list

Microfrontend Routing Strategies

Why does routing become challenging in a microfrontend setup?

Simuratli in Level Up Coding · 2026-05-12 09:10 · 85 claps · 3.6 min read paywalled
#react-microfrontend #implement-microfrontend #react #seniors #software-development
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Microfrontend Routing Strategies

Why does routing become challenging in a microfrontend setup?

In a basic application, we usually have a single router. But when switching to a microfrontend architecture, if each microfrontend wants to implement its own router, who should control window.history?

The problem is this:

1 — User wants to go /orders/43

2 — Shell decides which remote must load. /orders/ (Orders fetched from MF)

3 — The Orders microfrontend is being loaded — but here’s the big question: how will the remote know about the /42 part of the URL? Should it have its own router? Who is responsible for parsing the URL?

Why window.history important?

BrowserRouter call window.history.pushState()in back. This is a native function that can change navigation history. If two BrowserRouter instances run at the same time, both of them may call these history APIs, which can lead to race conditions, corrupted navigation history, and unpredictable back button behavior.

1. Shell level routing.

In this type of routing, the shell application is aware of all routes and decides which remote should be rendered for a given URL. The remote itself doesn’t own the routing logic — it behaves more like a regular component.”

// webpack.config.js — Shell (Host)
new ModuleFederationPlugin({
  name: 'shell',
  remotes: {
    orders: 'orders@http://localhost:3001/remoteEntry.js',
    profile: 'profile@http://localhost:3002/remoteEntry.js',
  }
})
// Shell App.jsx — All routing is in here
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { lazy, Suspense } from 'react'

const OrdersApp = lazy(() => import('orders/App'))
const ProfileApp = lazy(() => import('profile/App'))

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback="Loading...">
        <Routes>
          <Route path="/"        element={<Home />} />
          <Route path="/orders"  element={<OrdersApp />} />
          <Route path="/profile" element={<ProfileApp />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  )
}
export default function OrdersApp() {
  // Bu component sadece render eder
  // URL'den haberi yok, alt route'ları yok
  return <div>Orders list is here</div>
}

When we use this type of routing:

  1. If we have small teams. Time is more important than complexity.
  2. Every remote has 1 page.

2. MemoryRouter routing.

MemoryRouter keeps navigation history entirely in its memory. It never updates the browser URL. A remote can navigate internally, but the browser is unaware of those route changes. The shell doesn’t touchwindow.history, and the remote doesn’t either. This provides full isolation.”

// Shell App.jsx
<Routes>
  <Route path="/orders/*" element={<OrdersApp />} />
</Routes>

// Shell only know "/orders" prefix
// It not need to now other parts (necessary part)
// Orders MF — App.jsx
import { MemoryRouter, Routes, Route } from 'react-router-dom'

export default function OrdersApp({ initialPath = '/list' }) {
  return (
    <MemoryRouter initialEntries={[initialPath]}>
      <Routes>
        <Route path="/list"  element={<OrderList />} />
        <Route path="/:id"   element={<OrderDetail />} />
      </Routes>
    </MemoryRouter>
  )
}

Critical Issue: Deeplink does not work.

User sends/orders/42 to his friend. His friend opened it, and what happened:

1 — The browser goes to /orders/42. Shell understands it is OrdersApp, and it renders OrdersApp.

2 — OrdersApp starts with MemoryRouter. If the initialPath prop is not provided, it starts with the default /list route.

3 — The user can not see the #42 order

Solution: pass initialPath from the shell.

// Shell - parses the part of the URL after /orders
function OrdersWrapper() {
  const location = useLocation() // /orders/42
  const subPath = location.pathname.replace('/orders', '')
  // subPath = "/42"
  return <OrdersApp initialPath={subPath} />
}

// But this only runs once — after that, even if the URL changes, the remote won’t update
// That’s why MemoryRouter is more suitable for modals/widgets

3. Synchronize routing.

Synchronize routing; Isolation with basename

The basename prop in React Router tells it to only care about the part of the URL after a given prefix. The shell handles/orders/*, while the remote is used basename="/orders" to manage its own nested routes. The browser URL still reflects the real path, so deep linking works properly.

// Shell App.jsx
<BrowserRouter>
  <Routes>
    <Route path="/"          element={<Home />} />
    <Route path="/orders/*" element={<OrdersApp basePath="/orders" />} />
    <Route path="/profile/*"element={<ProfileApp basePath="/profile" />} />
  </Routes>
</BrowserRouter>

//Shell only send prefix as prop. clean interface
// Orders MF — App.jsx
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'

export default function OrdersApp({ basePath = '/orders' }) {
  return (
    <BrowserRouter basename={basePath}>
      <nav>
        <Link to="/list">All</Link>
        {/* This link: /orders/list */}
      </nav>
      <Routes>
        <Route path="/"      element={<OrderList />} />
        <Route path="/list" element={<OrderList />} />
        <Route path="/:id"  element={<OrderDetail />} />
      </Routes>
    </BrowserRouter>
  )
}

// When the remote runs in standalone mode, the default basePath is used
// This allows it to be tested independently without being connected to the shell ← senior approach

How deeplink works now:

  1. URL: /orders/42 — the shell’s BrowserRouter detects the /orders/* route and renders the OrdersApp
  2. The OrdersApp is mounted with basename="/orders". The remote BrowserRouter reads the URL /orders/42, strips the basename, and resolves it as /42The OrdersApp is mounted with basename="/orders". The remote BrowserRouter reads the URL /orders/42, strips the basename, and resolves it as /42
  3. The route /:id matches, so id = "42". OrderDetail is rendered, and useParams() returns { id: "42" }. Deep linking works!
  4. The remote BrowserRouter navigates to/list, and the browser URL becomes /orders/list. The shell does not intervene.


메타데이터
post_id
e66112fdbf7d
slug
microfrontend-routing-strategies-e66112fdbf7d
url
https://levelup.gitconnected.com/microfrontend-routing-strategies-e66112fdbf7d
canonical_url
https://levelup.gitconnected.com/microfrontend-routing-strategies-e66112fdbf7d
author_url
https://medium.com/@simuratli
status
ok
fetched_at
2026-06-21 19:25:17