Skip to content

Routing and Navigation

Routing is critical in micro frontends. Unlike a monolithic SPA with a single router, micro frontend systems must coordinate routing across multiple independently developed applications. Poor routing design leads to full-page reloads, broken back-button behavior, and confusing user experiences.

This guide covers the application shell pattern, URL-based routing strategies, client-side routing coordination, cross-application navigation, and deep linking.

In a micro frontend architecture:

  • Each micro frontend may have its own router — React Router, Vue Router, Angular Router, etc.
  • The shell must own top-level routing — Deciding which micro frontend to load for which URL
  • Users expect seamless navigation — No jarring full-page reloads when moving between features
  • Deep links and bookmarks must work/checkout/123 should load the right micro frontend directly

Routing decisions are intertwined with your composition strategy. Client-side composition typically requires more sophisticated routing orchestration than server-side composition, where the server can route by URL and compose the appropriate fragments.


The application shell is the minimal host that owns the top-level layout and routing. It loads micro frontends into designated slots based on the current URL.

The shell parses the URL and decides which micro frontend(s) to load. Micro frontends handle their own sub-routes within their slot.

URL: /products/electronics/123/reviews
Shell routing:
/products/* → Load Product micro frontend
/checkout/* → Load Checkout micro frontend
/account/* → Load Account micro frontend
Product micro frontend (sub-routing):
/products/electronics/123/reviews → Show review tab
/products/electronics/123/specs → Show specs tab

A well-designed URL structure maps cleanly to ownership:

URL PathOwnerMicro Frontend
/ShellHome/landing
/products/*Product TeamProduct catalog, PDP
/checkout/*Checkout TeamCart, checkout flow
/account/*Account TeamProfile, orders, settings
/admin/*Admin TeamAdmin dashboard

Path prefixes create clear ownership boundaries. See team autonomy for how teams own their route namespaces.


The most common approach: each micro frontend owns a path prefix.

https://example.com/team-a/dashboard
https://example.com/team-a/settings
https://example.com/team-b/catalog
https://example.com/team-b/checkout

Implementation: The shell’s router matches the path prefix and loads the corresponding micro frontend. Sub-routes are passed to the micro frontend.

const routeConfig = [
{ path: '/team-a/*', microFrontend: 'team-a' },
{ path: '/team-b/*', microFrontend: 'team-b' },
{ path: '/', microFrontend: 'home' },
];

Each micro frontend (or team) gets its own subdomain.

https://team-a.example.com/dashboard
https://team-b.example.com/catalog
https://checkout.example.com/cart

Pros: Strong isolation, independent deployments per subdomain, simple load balancing
Cons: Cross-subdomain navigation feels like leaving the site, cookie/shared state complexity, DNS and SSL management


Modern SPAs use the History API. When the user clicks a link or uses the back/forward buttons, the shell must:

  1. React to popstate (browser back/forward)
  2. Update the URL without full reload (history.pushState / history.replaceState)
  3. Notify the active micro frontend of route changes
  4. Mount/unmount micro frontends as the user navigates
// Shell listens for popstate (browser back/forward)
window.addEventListener('popstate', () => {
const path = window.location.pathname;
loadMicroFrontendForPath(path);
});

When each micro frontend has its own router (e.g., React Router), conflicts arise:

  • Duplicate history entries — Each router may push its own state
  • Scroll restoration — Multiple routers can fight over scroll position
  • Route change events — The shell needs to know when a micro frontend navigates internally

Approach: The shell owns the primary History API interaction. Micro frontends receive the current path as a prop and render accordingly, or use hash-based sub-routing to avoid conflicting with the shell.

Single-SPA is a meta-framework that orchestrates multiple frameworks. It uses an “application” registry and “activity functions” to determine when each micro frontend is active:

import { registerApplication, start } from 'single-spa';
registerApplication({
name: 'product-catalog',
app: () => import('@org/product-catalog'),
activeWhen: ['/products'],
});
registerApplication({
name: 'checkout',
app: () => import('@org/checkout'),
activeWhen: ['/checkout'],
});
start();

When the URL matches activeWhen, Single-SPA loads and mounts the application. It handles the routing lifecycle centrally.


Section titled “Navigating Between Micro Frontends Without Full Reloads”

Users should move from /products/123 to /checkout without a full page reload. The shell must:

  1. Parse the navigation target
  2. Unmount the current micro frontend (or keep it in DOM if same route tree)
  3. Load and mount the target micro frontend
  4. Update the URL via history.pushState

Links between micro frontends should use normal <a href="..."> or router.push() that the shell intercepts, rather than window.location.href assignments that cause reloads.

A shared header or sidebar may contain links to different micro frontends. Two patterns:

1. Shell-owned navigation: The shell renders the nav and uses its router for links. Micro frontends do not render nav links.

2. Event-based navigation: Micro frontends emit navigation events; the shell listens and performs the route change.

// Micro frontend emits navigation intent
window.dispatchEvent(new CustomEvent('app:navigate', {
detail: { path: '/checkout' }
}));
// Shell listens and handles
window.addEventListener('app:navigate', (e) => {
history.pushState({}, '', e.detail.path);
loadMicroFrontendForPath(e.detail.path);
});

Event-Based Communication for Route Changes

Section titled “Event-Based Communication for Route Changes”

Publish-subscribe keeps the shell and micro frontends loosely coupled:

// Simple event bus for route changes
const routeBus = {
listeners: new Set(),
navigate(path) {
history.pushState({}, '', path);
this.listeners.forEach(fn => fn(path));
},
subscribe(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
},
};

Users expect to bookmark or share URLs like https://example.com/products/electronics/123/reviews and land directly on that view.

Requirements:

  1. Server-side routing — If the server serves the shell for all routes (e.g., SPA fallback), the initial HTML load is the same. The shell parses the URL on load and mounts the correct micro frontend with the correct sub-route.

  2. No client-only routes — Avoid routes that only exist after client-side JavaScript runs. The server (or CDN) must serve the shell for all valid URLs.

  3. Sub-route passing — When loading /products/electronics/123/reviews, the shell loads the Product micro frontend and passes electronics/123/reviews (or the full path) so it can render the reviews tab.


Below is a minimal application shell router pattern:

// Simple application shell router (pseudocode)
const ROUTES = [
{ path: /^\/products(\/.*)?$/, mf: 'product-catalog', basePath: '/products' },
{ path: /^\/checkout(\/.*)?$/, mf: 'checkout', basePath: '/checkout' },
{ path: /^\/account(\/.*)?$/, mf: 'account', basePath: '/account' },
{ path: /^\/$/, mf: 'home', basePath: '/' },
];
function getRouteForPath(path) {
return ROUTES.find(r => r.path.test(path));
}
async function loadMicroFrontendForPath(path) {
const route = getRouteForPath(path);
if (!route) return render404();
const subPath = path.replace(route.basePath, '') || '/';
// Unmount previous micro frontend
const container = document.getElementById('app-shell');
container.innerHTML = '';
// Load and mount
const { mount } = await import(
/* @vite-ignore */ `${getMfUrl(route.mf)}/remoteEntry.js`
);
mount(container, { path: subPath, basePath: route.basePath });
}
// Intercept link clicks for in-app navigation
document.addEventListener('click', (e) => {
const a = e.target.closest('a[href^="/"]');
if (a && !a.target && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
loadMicroFrontendForPath(a.getAttribute('href'));
history.pushState({}, '', a.getAttribute('href'));
}
});
window.addEventListener('popstate', () => loadMicroFrontendForPath(location.pathname));
// Initial load
loadMicroFrontendForPath(location.pathname);

  • The application shell owns top-level routing and loads micro frontends based on URL.
  • Path-based and subdomain-based routing define ownership boundaries.
  • Coordinate client-side routing via History API, popstate, and event-based communication.
  • Ensure deep linking works by serving the shell for all routes and passing sub-routes to micro frontends.
  • Cross-application navigation should avoid full reloads; use the shell’s router or a shared event bus.

For how composition affects routing, see composition strategies. For organizational ownership of route namespaces, see team autonomy.

Go Deeper in the Book

This topic is covered in depth in Chapters 3-4 of Micro Frontends Architecture for Scalable Applications, with detailed examples, diagrams, and production-ready patterns.