Front-end Engineering Lab
PatternsMicrofrontends

Routing Orchestration

Managing navigation and routing across multiple microfrontends

Coordinating navigation across microfrontends is critical for seamless user experience. This guide covers proven routing patterns.

🎯 The Challenge

User navigates: /products/123 → /checkout

Questions:
- Who owns the router?
- How do MFEs know the current route?
- How to handle deep linking?
- How to prevent full page reloads?

Shell application owns the router and mounts MFEs based on routes.

Implementation

// Shell App
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';

const HeaderMFE = lazy(() => import('header/App'));
const ProductsMFE = lazy(() => import('products/App'));
const CheckoutMFE = lazy(() => import('checkout/App'));

export function Shell() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading...</div>}>
        <HeaderMFE />
        
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/products/*" element={<ProductsMFE />} />
          <Route path="/checkout/*" element={<CheckoutMFE />} />
          <Route path="/account/*" element={<AccountMFE />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

MFE Internal Routing

// Products MFE
import { Routes, Route, useParams } from 'react-router-dom';

export function ProductsMFE() {
  return (
    <Routes>
      <Route path="/" element={<ProductList />} />
      <Route path="/:id" element={<ProductDetail />} />
      <Route path="/category/:category" element={<CategoryPage />} />
    </Routes>
  );
}

// URL: /products/123
// Products MFE receives: /:id with params.id = "123"

Pros:

  • ✅ Single source of truth
  • ✅ Clean URL structure
  • ✅ Easy to debug
  • ✅ Type-safe routing

Cons:

  • ⚠️ Shell knows all routes
  • ⚠️ Tight coupling to shell

🔀 Pattern 2: Distributed Routing

Each MFE manages its own routes independently.

Implementation

// Shell App
export function Shell() {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);
  
  useEffect(() => {
    // Listen to navigation events
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };
    
    window.addEventListener('popstate', handlePopState);
    
    // Listen to custom navigation events
    window.addEventListener('mfe-navigate', (e: CustomEvent) => {
      setCurrentPath(e.detail.path);
    });
    
    return () => {
      window.removeEventListener('popstate', handlePopState);
      window.removeEventListener('mfe-navigate', handlePopState);
    };
  }, []);
  
  return (
    <div>
      <HeaderMFE currentPath={currentPath} />
      
      {currentPath.startsWith('/products') && <ProductsMFE />}
      {currentPath.startsWith('/checkout') && <CheckoutMFE />}
      {currentPath.startsWith('/account') && <AccountMFE />}
    </div>
  );
}
// shared/navigation.ts
export function navigate(path: string) {
  // Update browser history
  window.history.pushState({}, '', path);
  
  // Dispatch event to notify all MFEs
  window.dispatchEvent(new CustomEvent('mfe-navigate', { 
    detail: { path } 
  }));
}

export function useCurrentPath() {
  const [path, setPath] = useState(window.location.pathname);
  
  useEffect(() => {
    const handler = (e: CustomEvent) => {
      setPath(e.detail.path);
    };
    
    window.addEventListener('mfe-navigate', handler as EventListener);
    window.addEventListener('popstate', () => setPath(window.location.pathname));
    
    return () => {
      window.removeEventListener('mfe-navigate', handler as EventListener);
      window.removeEventListener('popstate', () => setPath(window.location.pathname));
    };
  }, []);
  
  return path;
}

Usage in MFEs

// Products MFE
import { navigate, useCurrentPath } from '@company/shared';

export function ProductCard({ product }) {
  const handleClick = () => {
    navigate(`/products/${product.id}`);
  };
  
  return (
    <div onClick={handleClick}>
      {product.name}
    </div>
  );
}

export function ProductsMFE() {
  const currentPath = useCurrentPath();
  
  // Parse route manually
  const productId = currentPath.match(/\/products\/(\d+)/)?.[1];
  
  if (productId) {
    return <ProductDetail id={productId} />;
  }
  
  return <ProductList />;
}

Pros:

  • ✅ Complete independence
  • ✅ No shell coordination needed
  • ✅ Easy to add new routes

Cons:

  • ⚠️ Duplicate routing logic
  • ⚠️ Hard to debug
  • ⚠️ Route conflicts possible

🎯 Pattern 3: Hybrid Routing

Shell owns top-level routes, MFEs own internal routes.

// Shell - Top level only
<Routes>
  <Route path="/products/*" element={<ProductsMFE />} />
  <Route path="/checkout/*" element={<CheckoutMFE />} />
</Routes>

// Products MFE - Internal routes
<Routes>
  <Route path="/" element={<ProductList />} />
  <Route path="/:id" element={<ProductDetail />} />
  <Route path="/category/:cat" element={<Category />} />
</Routes>

// URL Structure:
// /products          → ProductList
// /products/123      → ProductDetail
// /products/category/electronics → Category

🔗 Deep Linking

Handle direct navigation to nested routes.

// Shell App
export function Shell() {
  // Parse initial URL on load
  const initialPath = window.location.pathname;
  
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/products/*" element={
          <ProductsMFE initialPath={initialPath} />
        } />
      </Routes>
    </BrowserRouter>
  );
}

// Products MFE
export function ProductsMFE({ initialPath }: { initialPath: string }) {
  // Extract product ID from initial path
  const productId = initialPath.match(/\/products\/(\d+)/)?.[1];
  
  // Fetch product data immediately
  useEffect(() => {
    if (productId) {
      fetchProduct(productId);
    }
  }, [productId]);
  
  return (
    <Routes>
      <Route path="/:id" element={<ProductDetail />} />
    </Routes>
  );
}

🔄 Cross-MFE Navigation

Navigate between MFEs smoothly.

// shared/router.ts
class MFERouter {
  navigate(path: string, options?: { replace?: boolean }) {
    if (options?.replace) {
      window.history.replaceState({}, '', path);
    } else {
      window.history.pushState({}, '', path);
    }
    
    // Notify all MFEs
    window.dispatchEvent(new CustomEvent('route-change', {
      detail: { path, action: options?.replace ? 'replace' : 'push' }
    }));
  }
  
  back() {
    window.history.back();
  }
  
  forward() {
    window.history.forward();
  }
  
  getCurrentPath(): string {
    return window.location.pathname;
  }
  
  subscribe(callback: (path: string) => void): () => void {
    const handler = (e: CustomEvent) => {
      callback(e.detail.path);
    };
    
    window.addEventListener('route-change', handler as EventListener);
    window.addEventListener('popstate', () => callback(window.location.pathname));
    
    return () => {
      window.removeEventListener('route-change', handler as EventListener);
      window.removeEventListener('popstate', () => callback(window.location.pathname));
    };
  }
}

export const mfeRouter = new MFERouter();

Usage

// Header MFE - Navigation
import { mfeRouter } from '@company/shared';

export function Navigation() {
  return (
    <nav>
      <a onClick={() => mfeRouter.navigate('/products')}>Products</a>
      <a onClick={() => mfeRouter.navigate('/checkout')}>Checkout</a>
      <a onClick={() => mfeRouter.navigate('/account')}>Account</a>
    </nav>
  );
}

// Products MFE - Link to checkout
export function ProductDetail() {
  const handleCheckout = () => {
    mfeRouter.navigate('/checkout');
  };
  
  return <button onClick={handleCheckout}>Checkout</button>;
}

📍 Route Matching

Determine which MFE to render.

// Shell App
interface RouteConfig {
  path: string;
  mfe: string;
  component: React.ComponentType;
}

const routes: RouteConfig[] = [
  { path: '/products', mfe: 'products', component: ProductsMFE },
  { path: '/checkout', mfe: 'checkout', component: CheckoutMFE },
  { path: '/account', mfe: 'account', component: AccountMFE },
];

export function Shell() {
  const currentPath = useCurrentPath();
  
  // Find matching route
  const matchedRoute = routes.find(route => 
    currentPath.startsWith(route.path)
  );
  
  if (!matchedRoute) {
    return <NotFound />;
  }
  
  const Component = matchedRoute.component;
  return <Component />;
}

🎨 Active Route Highlighting

Highlight active navigation items.

// Header MFE
import { useCurrentPath } from '@company/shared';

export function Navigation() {
  const currentPath = useCurrentPath();
  
  const isActive = (path: string) => {
    return currentPath.startsWith(path);
  };
  
  return (
    <nav>
      <a 
        className={isActive('/products') ? 'active' : ''}
        onClick={() => navigate('/products')}
      >
        Products
      </a>
      <a 
        className={isActive('/checkout') ? 'active' : ''}
        onClick={() => navigate('/checkout')}
      >
        Checkout
      </a>
    </nav>
  );
}

🔐 Protected Routes

Handle authentication across MFEs.

// Shell App
export function Shell() {
  const { user, loading } = useAuth();
  
  if (loading) return <LoadingSpinner />;
  
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/products/*" element={<ProductsMFE />} />
        
        {/* Protected routes */}
        <Route path="/account/*" element={
          user ? <AccountMFE /> : <Navigate to="/login" />
        } />
        
        <Route path="/admin/*" element={
          user?.role === 'admin' ? <AdminMFE /> : <Navigate to="/" />
        } />
      </Routes>
    </BrowserRouter>
  );
}

📊 Route Analytics

Track navigation events.

// shared/analytics.ts
class RouteAnalytics {
  private previousPath: string = '';
  
  init() {
    mfeRouter.subscribe((path) => {
      this.trackPageView(path);
    });
  }
  
  trackPageView(path: string) {
    // Send to analytics service
    analytics.page({
      path,
      previousPath: this.previousPath,
      timestamp: Date.now(),
      referrer: document.referrer,
    });
    
    this.previousPath = path;
  }
  
  trackNavigation(from: string, to: string) {
    analytics.track('navigation', {
      from,
      to,
      duration: performance.now(),
    });
  }
}

export const routeAnalytics = new RouteAnalytics();

⚡ Prefetching

Load MFEs before user navigates.

// Shell App
import { lazy, Suspense } from 'react';

const ProductsMFE = lazy(() => import('products/App'));
const CheckoutMFE = lazy(() => import('checkout/App'));

export function Navigation() {
  // Prefetch on hover
  const prefetchProducts = () => {
    import('products/App');
  };
  
  const prefetchCheckout = () => {
    import('checkout/App');
  };
  
  return (
    <nav>
      <a 
        onMouseEnter={prefetchProducts}
        onClick={() => navigate('/products')}
      >
        Products
      </a>
      <a 
        onMouseEnter={prefetchCheckout}
        onClick={() => navigate('/checkout')}
      >
        Checkout
      </a>
    </nav>
  );
}

🏢 Real-World Examples

Spotify

// Shell owns routes
/           → Home MFE
/search     → Search MFE
/playlist/* → Playlist MFE
/artist/*   → Artist MFE

// Prefetch on hover
// Analytics on every navigation

Amazon

// Distributed routing
// Each widget (MFE) independent
// Deep linking fully supported
// Analytics per MFE

IKEA

// Hybrid approach
// Shell: /products, /cart, /checkout
// MFEs: Internal routes
// Shared router utility

📚 Key Takeaways

  1. Shell-based routing for most cases (simple, debuggable)
  2. Hybrid routing for independence (top-level + internal)
  3. Use history API not window.location (no page reload)
  4. Shared navigation utility prevents inconsistency
  5. Prefetch MFEs on hover for instant navigation
  6. Track all navigations for analytics
  7. Support deep linking from day one

Start simple with shell-based routing. Add complexity only when needed.

On this page