DNAπŸ—οΈ System DesignMicro-Frontends Architecture
πŸ‘‘ApexSystem DesignMicro-FrontendsArchitecture

Micro-Frontends Architecture

When a monolith outgrows a single team, micro-frontends let organizations scale frontend development β€” at the cost of complexity you must deliberately manage.

Micro-Frontends Architecture

Micro-frontends apply the principles of microservices to the frontend: independent teams own independent slices of the UI, deploying independently, with independent technology choices. It's a powerful pattern β€” and a dangerous one if adopted without understanding the costs.

Why Micro-Frontends?

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Monolith Frontend                     β”‚
β”‚                                                         β”‚
β”‚  Team A       Team B       Team C       Team D          β”‚
β”‚  ─────────────────────────────────────────────          β”‚
β”‚  All teams commit to the same repo                      β”‚
β”‚  All teams deploy together                              β”‚
β”‚  Merge conflicts, coordination overhead, slow CI        β”‚
β”‚  One bad deploy = everything broken                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 
                        β–Ό Scale pain β–Ό
 
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Team A  β”‚  β”‚  Team B  β”‚  β”‚  Team C  β”‚  β”‚  Team D  β”‚
β”‚ Catalog  β”‚  β”‚  Cart    β”‚  β”‚ Checkout β”‚  β”‚ Account  β”‚
β”‚          β”‚  β”‚          β”‚  β”‚          β”‚  β”‚          β”‚
β”‚ Own repo β”‚  β”‚ Own repo β”‚  β”‚ Own repo β”‚  β”‚ Own repo β”‚
β”‚ Own CI   β”‚  β”‚ Own CI   β”‚  β”‚ Own CI   β”‚  β”‚ Own CI   β”‚
β”‚ Own deployβ”‚ β”‚ Own deployβ”‚ β”‚ Own deployβ”‚ β”‚ Own deployβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The Three Drivers

  1. Team autonomy: Teams can choose their own tools, frameworks, and release cadence
  2. Independent deployments: Ship the cart without waiting for catalog to finish their feature
  3. Technology heterogeneity: Team A uses React, Team C uses Vue β€” both coexist

Composition Patterns

Build-Time Composition

Micro-frontends are npm packages consumed by a host application at build time.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Host App    β”‚
β”‚              β”‚     npm install
β”‚  imports:    │◀──────────────── @org/catalog-mfe
β”‚  @org/cart   │◀──────────────── @org/cart-mfe
β”‚  @org/acct   │◀──────────────── @org/account-mfe
β”‚              β”‚
β”‚  Webpack     │──── Single bundle ────▢ Deploy
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Pros: Simple, good tree-shaking, type safety across boundaries Cons: All teams must deploy together (defeats the purpose), version lock-in

Runtime Composition via Module Federation

The dominant pattern for production micro-frontends:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Host Shell                      β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚  Header (shared)                            β”‚  β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€  β”‚
β”‚  β”‚         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”‚  β”‚
β”‚  β”‚  Nav    β”‚    Remote Module        β”‚        β”‚  β”‚
β”‚  β”‚  bar    β”‚    (loaded at runtime)  β”‚        β”‚  β”‚
β”‚  β”‚         β”‚                         β”‚        β”‚  β”‚
β”‚  β”‚         β”‚  /catalog β†’ catalog-mfe β”‚        β”‚  β”‚
β”‚  β”‚         β”‚  /cart    β†’ cart-mfe    β”‚        β”‚  β”‚
β”‚  β”‚         β”‚  /account β†’ account-mfe β”‚        β”‚  β”‚
β”‚  β”‚         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β”‚  β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€  β”‚
β”‚  β”‚  Footer (shared)                            β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Iframe Composition

<iframe src="https://catalog.example.com" title="Product Catalog" />

Pros: Perfect isolation (CSS, JS, security) Cons: No shared state, poor UX (no shared scroll, routing), accessibility nightmare, performance overhead

Edge-Side Includes (ESI)

Server-side composition at the CDN/edge layer:

<header>
  <esi:include src="https://header.example.com/fragment" />
</header>
<main>
  <esi:include src="https://catalog.example.com/fragment" />
</main>

Pros: Works without JavaScript, fast TTFB Cons: Limited interactivity, CDN support varies

Module Federation 2.0 Deep Dive

Module Federation is Webpack's (and now Rspack's) native solution for sharing code between independently built applications at runtime.

Host Configuration

// shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
 
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        catalog: 'catalog@https://catalog.example.com/remoteEntry.js',
        cart: 'cart@https://cart.example.com/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
        'react-router-dom': { singleton: true },
      },
    }),
  ],
};

Remote Configuration

// catalog/webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'catalog',
      filename: 'remoteEntry.js',
      exposes: {
        './CatalogPage': './src/CatalogPage',
        './ProductCard': './src/components/ProductCard',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};

Loading Remote Modules

const CatalogPage = React.lazy(() => import('catalog/CatalogPage'));
 
function App() {
  return (
    <Routes>
      <Route
        path="/catalog/*"
        element={
          <Suspense fallback={<PageSkeleton />}>
            <ErrorBoundary fallback={<RemoteLoadError />}>
              <CatalogPage />
            </ErrorBoundary>
          </Suspense>
        }
      />
    </Routes>
  );
}

Module Federation 2.0 Improvements

  • Runtime API: Dynamic remote registration without rebuild
  • Manifest protocol: Standardized remote discovery
  • Type hints: Shared TypeScript types across remotes
  • Version negotiation: Smarter shared dependency resolution

Shared Dependencies Strategy

The biggest challenge: how do you avoid loading React 5 times?

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Shared Dependency Strategy            β”‚
β”‚                                                  β”‚
β”‚  Singleton (one version loaded)                  β”‚
β”‚  β”œβ”€ react, react-dom                            β”‚
β”‚  β”œβ”€ react-router-dom                            β”‚
β”‚  └─ design-system                               β”‚
β”‚                                                  β”‚
β”‚  Scoped (each MFE has its own)                   β”‚
β”‚  β”œβ”€ form libraries                              β”‚
β”‚  β”œβ”€ date libraries                              β”‚
β”‚  └─ MFE-specific utilities                      β”‚
β”‚                                                  β”‚
β”‚  Externalized (loaded from CDN)                  β”‚
β”‚  └─ Large libraries loaded once via importmap   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
{
  "imports": {
    "react": "https://cdn.example.com/react@18/esm/index.js",
    "react-dom": "https://cdn.example.com/react-dom@18/esm/index.js"
  }
}

Routing Across Micro-Frontends

Shell-Owned Routing

Shell Router (top-level routes)
  β”‚
  β”œβ”€ /catalog/*  β†’ Catalog MFE (owns sub-routes)
  β”‚   β”œβ”€ /catalog/
  β”‚   β”œβ”€ /catalog/:id
  β”‚   └─ /catalog/category/:slug
  β”‚
  β”œβ”€ /cart/*     β†’ Cart MFE
  β”‚   β”œβ”€ /cart/
  β”‚   └─ /cart/checkout
  β”‚
  └─ /account/*  β†’ Account MFE
      β”œβ”€ /account/profile
      └─ /account/orders

The contract: The shell owns top-level routes. Each MFE owns its sub-routes. Navigation between MFEs goes through the shell's router. Navigation within an MFE uses its own router.

// Shell routes
<Routes>
  <Route path="/catalog/*" element={<CatalogMFE />} />
  <Route path="/cart/*" element={<CartMFE />} />
  <Route path="/account/*" element={<AccountMFE />} />
</Routes>
 
// Inside Catalog MFE β€” uses MemoryRouter or basename
<Routes>
  <Route index element={<CatalogList />} />
  <Route path=":id" element={<ProductDetail />} />
</Routes>

Shared State & Communication

Micro-frontends should be loosely coupled. Communication should be through well-defined interfaces, not shared state stores.

Custom Events (Recommended)

// Cart MFE dispatches
window.dispatchEvent(new CustomEvent('cart:updated', {
  detail: { itemCount: 3, total: 59.97 },
}));
 
// Header MFE listens
useEffect(() => {
  const handler = (e: CustomEvent) => setCartCount(e.detail.itemCount);
  window.addEventListener('cart:updated', handler);
  return () => window.removeEventListener('cart:updated', handler);
}, []);

URL State (Simplest)

/catalog?search=shoes&sort=price
 
Every MFE can read URL params. No shared runtime needed.

Shared Event Bus (Typed)

type EventMap = {
  'cart:updated': { itemCount: number; total: number };
  'user:logout': undefined;
  'notification:new': { message: string; type: 'info' | 'error' };
};
 
class EventBus {
  private handlers = new Map<string, Set<Function>>();
 
  emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {
    this.handlers.get(event)?.forEach(handler => handler(data));
  }
 
  on<K extends keyof EventMap>(event: K, handler: (data: EventMap[K]) => void) {
    if (!this.handlers.has(event)) this.handlers.set(event, new Set());
    this.handlers.get(event)!.add(handler);
    return () => this.handlers.get(event)?.delete(handler);
  }
}
 
export const eventBus = new EventBus();

CSS Isolation

Without isolation, one MFE's .button class clobbers another's.

StrategyIsolation LevelTrade-off
CSS ModulesFile-scopedBuild tool required
Shadow DOMFull encapsulationStyle sharing is harder
CSS-in-JSRuntime scopedRuntime cost, SSR complexity
BEM / PrefixingConvention-basedRelies on discipline
CSS LayersCascade controlModern browsers only
/* Using CSS Layers for MFE isolation */
@layer shell, catalog, cart, account;
 
@layer catalog {
  .product-card { /* only within catalog layer */ }
}
 
@layer cart {
  .product-card { /* different styles, no conflict */ }
}

Testing Strategies

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Testing Pyramid for MFEs           β”‚
β”‚                                                β”‚
β”‚           β–²                                    β”‚
β”‚          β•± β•²    E2E (full integration)         β”‚
β”‚         ╱───╲   All MFEs composed in shell     β”‚
β”‚        β•± Ctr β•²  Contract tests                 β”‚
β”‚       ╱───────╲ Verify MFE ↔ Shell interface   β”‚
β”‚      β•±  Int    β•² Integration within each MFE   β”‚
β”‚     ╱───────────╲                              β”‚
β”‚    β•±    Unit     β•² Components, hooks, utils    β”‚
β”‚   ╱───────────────╲                            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Contract tests are critical: they verify that the shell and remotes agree on the interface (exported components, props, events) without requiring full integration.

When NOT to Use Micro-Frontends

SignalReality
"It's what Netflix does"Netflix has 1000+ frontend engineers. You have 12.
One team, one appYou're adding complexity without the organizational benefit
Premature optimizationStart monolithic, extract when pain is real
Tech diversity for funConsistency > novelty for maintainability
< 3 teamsThe coordination cost exceeds the independence benefit

The Complexity Cost

Monolith:  1 repo, 1 build, 1 deploy, 1 test suite
MFE:       N repos, N builds, N deploys, N+1 test suites,
           shared deps management, cross-MFE routing,
           CSS isolation, communication protocol,
           deployment orchestration, monitoring per MFE

Monorepo vs Polyrepo

FactorMonorepoPolyrepo
Code sharingEasy (workspace imports)Hard (publish packages)
ConsistencyEnforced (shared config)Varies per repo
CI/CDComplex (affected-only builds)Simple per repo
ToolingTurborepo, Nx, LernaStandard per-repo tooling
AutonomyLower (shared conventions)Higher (full independence)
OnboardingOne cloneN clones

Recommended: Monorepo with Module Federation

monorepo/
  apps/
    shell/          ← Host application
    catalog/        ← Remote MFE
    cart/           ← Remote MFE
    account/        ← Remote MFE
  packages/
    design-system/  ← Shared UI components
    shared-types/   ← TypeScript interfaces
    event-bus/      ← Communication layer
    eslint-config/  ← Shared linting rules
  turbo.json        ← Build orchestration

This gives you the organizational benefits of micro-frontends (independent builds, independent deploys) with the developer experience of a monorepo (shared types, easy refactoring, consistent tooling).

Deployment Pipeline

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Commit  │───▢│  Build   │───▢│  Test    │───▢│  Deploy  β”‚
β”‚          β”‚    β”‚  MFE     β”‚    β”‚  Unit +  β”‚    β”‚  to CDN  β”‚
β”‚  (cart)  β”‚    β”‚  only    β”‚    β”‚ Contract β”‚    β”‚  (cart)  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                     β”‚
                                                     β–Ό
                                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                              β”‚ Smoke    β”‚
                                              β”‚ Test in  β”‚
                                              β”‚ Staging  β”‚
                                              β”‚ (full    β”‚
                                              β”‚  shell)  β”‚
                                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Each MFE deploys independently. The shell loads the latest version of each remote at runtime. No coordinated releases needed.

Micro-frontends are an organizational scaling pattern, not a technical optimization. Use them when you have the team structure that demands them. Don't use them because they're architecturally interesting. That's the architect's judgment call.