Routing
Cross-application routing strategies for micro-frontends including shell-controlled routing, distributed routing, and URL contracts.
You are an expert in cross-application routing strategies for micro-frontend architectures. You help teams design URL structures, navigation flows, and router coordination patterns that allow independently deployed micro-frontends to participate in a seamless single-page navigation experience. ## Key Points 1. **Shell-controlled routing** — the shell owns all route definitions and delegates rendering to micro-frontends. 2. **Distributed routing** — each micro-frontend registers its own routes; the shell provides only the mounting mechanism. 3. **Server-side routing** — an edge layer (CDN, reverse proxy) routes requests to different micro-frontend servers by path prefix. 1. **Level 1 (Shell)** — matches the path prefix and mounts the correct micro-frontend. 2. **Level 2 (Micro-frontend)** — matches sub-routes within the prefix and renders internal views. 1. **Assign path prefixes per team** — every micro-frontend owns a unique URL prefix. This prevents route collisions and makes ownership unambiguous. 2. **Use `basename` in child routers** — React Router's `basename`, Vue Router's `base`, and Angular's `APP_BASE_HREF` scope the micro-frontend's routes under its prefix automatically. 3. **Centralize cross-app navigation** — micro-frontends should emit navigation requests; the shell should be the only code calling `history.pushState`. This prevents history stack corruption. 4. **Document the URL contract** — maintain a registry of path prefixes, required query parameters, and their owners. This is the API surface of your routing layer. 5. **Lazy-load micro-frontends by route** — do not load all micro-frontend bundles on initial page load. Load only the one matching the current route. 6. **Handle transitions gracefully** — show a loading indicator while the target micro-frontend's bundle is being fetched. Users should never see a blank screen. 7. **Test deep links** — every URL in the system should work when loaded directly (bookmarked or shared). This means server-side routing must fall back to the shell for client-side routes. ## Quick Example ``` /catalog/** → Catalog team /checkout/** → Checkout team /account/** → Account team / → Shell (renders default micro-frontend) ```
skilldb get micro-frontend-skills/RoutingFull skill: 292 linesRouting — Micro-Frontends
You are an expert in cross-application routing strategies for micro-frontend architectures. You help teams design URL structures, navigation flows, and router coordination patterns that allow independently deployed micro-frontends to participate in a seamless single-page navigation experience.
Overview
Routing in a micro-frontend architecture is fundamentally a coordination problem. The shell (or root application) must decide which micro-frontend to mount based on the URL, and each micro-frontend may have its own internal routes. The URL is the shared contract between teams — it must be well-defined, stable, and unambiguous.
There are three primary models:
- Shell-controlled routing — the shell owns all route definitions and delegates rendering to micro-frontends.
- Distributed routing — each micro-frontend registers its own routes; the shell provides only the mounting mechanism.
- Server-side routing — an edge layer (CDN, reverse proxy) routes requests to different micro-frontend servers by path prefix.
Core Concepts
URL as the Integration Contract
The URL is the most important shared interface in a micro-frontend system. Teams must agree on path prefix ownership:
/catalog/** → Catalog team
/checkout/** → Checkout team
/account/** → Account team
/ → Shell (renders default micro-frontend)
This contract is the minimal coupling required. Everything behind the prefix is owned by the respective team.
Two-Level Routing
In most architectures, routing happens at two levels:
- Level 1 (Shell) — matches the path prefix and mounts the correct micro-frontend.
- Level 2 (Micro-frontend) — matches sub-routes within the prefix and renders internal views.
The shell must not interfere with Level 2 routing, and micro-frontends must not navigate outside their prefix without going through the shell.
History API Coordination
All micro-frontends share the same window.history stack. If two apps both call pushState, they will step on each other. The solution is to either use a single router (shell-controlled) or establish clear boundaries with the basename / prefix pattern.
Implementation Patterns
Shell-Controlled Routing with React Router
// shell/App.tsx — the shell owns the top-level router
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { lazy, Suspense } from "react";
const CatalogApp = lazy(() => import("catalog/App")); // Module Federation
const CheckoutApp = lazy(() => import("checkout/App"));
const AccountApp = lazy(() => import("account/App"));
export default function ShellApp() {
return (
<BrowserRouter>
<ShellNavbar />
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/catalog/*" element={<CatalogApp />} />
<Route path="/checkout/*" element={<CheckoutApp />} />
<Route path="/account/*" element={<AccountApp />} />
<Route path="/" element={<HomePage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
// catalog/App.tsx — the micro-frontend uses relative routes
import { Routes, Route } from "react-router-dom";
export default function CatalogApp() {
return (
<Routes>
<Route index element={<ProductList />} />
<Route path="category/:id" element={<CategoryPage />} />
<Route path="product/:id" element={<ProductDetail />} />
</Routes>
);
}
Distributed Routing with single-spa
// root-config.js — route ownership is defined by activity functions
import { registerApplication, start } from "single-spa";
registerApplication({
name: "@myorg/catalog",
app: () => System.import("@myorg/catalog"),
activeWhen: (location) => location.pathname.startsWith("/catalog"),
});
registerApplication({
name: "@myorg/checkout",
app: () => System.import("@myorg/checkout"),
activeWhen: (location) => location.pathname.startsWith("/checkout"),
});
start();
// catalog micro-frontend — uses its own Vue Router internally
import { createRouter, createWebHistory } from "vue-router";
const router = createRouter({
history: createWebHistory("/catalog"), // basename scopes all routes
routes: [
{ path: "/", component: ProductList },
{ path: "/category/:id", component: CategoryPage },
{ path: "/product/:id", component: ProductDetail },
],
});
Cross-App Navigation via Events
// Navigation contract — micro-frontends request navigation; the shell executes it
// @myorg/event-contracts
export interface NavigateEvent {
path: string;
replace?: boolean;
}
// Micro-frontend requests navigation (does NOT call history.pushState directly)
function requestNavigation(path: string, replace = false) {
window.dispatchEvent(
new CustomEvent("mf:navigate", {
detail: { path, replace },
})
);
}
// Shell listens and performs the actual navigation
window.addEventListener("mf:navigate", (event: CustomEvent<NavigateEvent>) => {
const { path, replace } = event.detail;
if (replace) {
router.replace(path);
} else {
router.push(path);
}
});
Server-Side Routing with Nginx
# nginx.conf — route by path prefix to different micro-frontend servers
upstream catalog_server {
server catalog-service:3001;
}
upstream checkout_server {
server checkout-service:3002;
}
upstream shell_server {
server shell-service:3000;
}
server {
listen 80;
location /catalog/ {
proxy_pass http://catalog_server/;
}
location /checkout/ {
proxy_pass http://checkout_server/;
}
location / {
proxy_pass http://shell_server/;
}
}
URL State Preservation Across Micro-Frontends
// When navigating from /catalog?filter=shoes to /checkout,
// preserve the return URL so the user can go back.
function navigateToCheckout(returnUrl: string) {
const encoded = encodeURIComponent(returnUrl);
requestNavigation(`/checkout?returnUrl=${encoded}`);
}
// In checkout micro-frontend, after order completion:
function handleOrderComplete() {
const params = new URLSearchParams(window.location.search);
const returnUrl = params.get("returnUrl") || "/";
requestNavigation(decodeURIComponent(returnUrl));
}
Route Guards and Authentication
// Shell-level route guard — redirect unauthenticated users
import { getState } from "@myorg/shared-state";
const PROTECTED_PREFIXES = ["/checkout", "/account"];
function guardNavigation(path: string): string {
const { user } = getState();
if (!user && PROTECTED_PREFIXES.some((p) => path.startsWith(p))) {
const returnUrl = encodeURIComponent(path);
return `/login?returnUrl=${returnUrl}`;
}
return path;
}
// Apply guard before executing navigation
window.addEventListener("mf:navigate", (event) => {
const guarded = guardNavigation(event.detail.path);
if (guarded !== event.detail.path) {
router.replace(guarded);
} else {
router.push(guarded);
}
});
Handling 404s in a Distributed System
// Shell must handle the case where no micro-frontend matches
// shell/App.tsx
<Routes>
<Route path="/catalog/*" element={<CatalogApp />} />
<Route path="/checkout/*" element={<CheckoutApp />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
// Each micro-frontend must also handle unknown sub-routes
// catalog/App.tsx
<Routes>
<Route index element={<ProductList />} />
<Route path="product/:id" element={<ProductDetail />} />
<Route path="*" element={<CatalogNotFound />} />
</Routes>
Best Practices
- Assign path prefixes per team — every micro-frontend owns a unique URL prefix. This prevents route collisions and makes ownership unambiguous.
- Use
basenamein child routers — React Router'sbasename, Vue Router'sbase, and Angular'sAPP_BASE_HREFscope the micro-frontend's routes under its prefix automatically. - Centralize cross-app navigation — micro-frontends should emit navigation requests; the shell should be the only code calling
history.pushState. This prevents history stack corruption. - Document the URL contract — maintain a registry of path prefixes, required query parameters, and their owners. This is the API surface of your routing layer.
- Lazy-load micro-frontends by route — do not load all micro-frontend bundles on initial page load. Load only the one matching the current route.
- Handle transitions gracefully — show a loading indicator while the target micro-frontend's bundle is being fetched. Users should never see a blank screen.
- Test deep links — every URL in the system should work when loaded directly (bookmarked or shared). This means server-side routing must fall back to the shell for client-side routes.
Common Pitfalls
- History stack pollution — if both the shell and a micro-frontend push history entries for the same navigation, the back button requires two presses. Ensure only one layer pushes per navigation.
- Missing server-side fallback — if the server does not serve the shell's
index.htmlfor all client-side routes, deep links return 404. Configure the server with a catch-all rule. - Prefix conflicts — if
/catalogand/catalog-adminare owned by different teams, a naivestartsWith("/catalog")check matches both. Use exact prefix matching with a trailing slash or explicit route registration. - Stale route registrations — if a micro-frontend is decommissioned but its routes remain in the shell config, users hit dead ends. Keep the route registry in sync with deployed applications.
- Scroll position issues — navigating between micro-frontends may not reset scroll position. Explicitly scroll to top on route changes, or use
scrollRestoration: "manual". - Query parameter conflicts — if two micro-frontends both use
?page=in the URL, loading one after the other produces confusing behavior. Namespace query parameters by app (e.g.,?catalog_page=2).
Core Philosophy
The URL is the most important shared interface in a micro-frontend system. It is the contract between teams, the entry point for users, the target for search engines, and the state that survives page refreshes. Every URL must have a clear owner, a stable structure, and unambiguous semantics. Assign path prefixes per team (/catalog/**, /checkout/**) and treat these assignments as immutable API contracts.
Two-level routing keeps concerns separated. The shell matches the path prefix and mounts the correct micro-frontend (Level 1). The micro-frontend matches sub-routes within its prefix and renders internal views (Level 2). The shell must not interfere with Level 2 routing, and micro-frontends must not navigate outside their prefix without going through the shell. This clean separation prevents history stack corruption and route ownership conflicts.
Centralize cross-application navigation. Micro-frontends should emit navigation requests via custom events; the shell should be the only code that calls history.pushState. When multiple applications can independently manipulate the browser history, the back button requires multiple presses, navigation state becomes inconsistent, and debugging route issues requires understanding the interaction of multiple routers.
Anti-Patterns
-
Allowing micro-frontends to call
history.pushStatedirectly — bypassing the shell's router for cross-app navigation corrupts the history stack and causes double-push issues where the back button requires two presses. -
Not handling 404s at both levels — the shell must catch unmatched top-level paths, and each micro-frontend must catch unmatched sub-routes; missing either level leaves users on blank pages.
-
Using overlapping path prefixes — if
/catalogand/catalog-adminare owned by different teams, a naivestartsWith("/catalog")check matches both; use exact prefix matching with trailing delimiters. -
Not testing deep links — every URL in the system must work when loaded directly from a bookmark or shared link; this requires server-side fallback to the shell's
index.htmlfor all client-side routes. -
Forgetting to reset scroll position on route changes — navigating between micro-frontends may leave the scroll position at the bottom of the previous page; explicitly scroll to top on route transitions.
Install this skill directly: skilldb add micro-frontend-skills
Related Skills
Deployment
Independent deployment patterns for micro-frontends including CI/CD pipelines, versioning, rollback, and environment strategies.
Design System Sharing
Strategies for sharing design systems, component libraries, and visual consistency across independently deployed micro-frontends.
Iframe Composition
iFrame-based micro-frontend composition for maximum isolation between independently deployed frontend applications.
Module Federation
Webpack Module Federation for sharing code and dependencies across independently deployed micro-frontends at runtime.
Shared State
Cross-application state sharing patterns for micro-frontends including event buses, shared stores, and URL-based state.
Single Spa
Single-SPA framework for orchestrating multiple JavaScript micro-frontends within a single page application shell.