Detail chart · Frontend

Micro-Frontend Architecture

Pattern ◆◆◆◆◇

Decompose large frontend applications into independently deployable units owned by separate teams.

Summary

Micro-Frontend Architecture applies the microservices decomposition model to the UI layer. Instead of a single frontend codebase owned by everyone (and therefore controlled by no one), the product is split into vertical slices — each slice is a self-contained application that a single team builds, tests, and deploys independently. A shell application composes these slices at runtime into the experience a user sees.

Problem

Frontend codebases suffer from the same coupling problems as monolithic backends — at scale, faster than backends do. Multiple teams committing to a single repository block each other on releases, share a global dependency graph that makes version upgrades catastrophic, and produce a build pipeline that takes 20 minutes for a one-line change in a single team’s widget.

Solution

Divide the product into vertical domains. Each domain owns a remote application — a bundle that exports components or pages. A shell application loads remotes at runtime and places them into named slots in the layout.

Webpack Module Federation

Module Federation is the most mature runtime composition approach. Remotes expose components; the shell consumes them as if they were local imports.

// Remote: recommendations-app/webpack.config.js
new ModuleFederationPlugin({
  name: 'recommendations',
  filename: 'remoteEntry.js',
  exposes: {
    './RecommendationPanel': './src/RecommendationPanel',
  },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
});

// Shell: webpack.config.js
new ModuleFederationPlugin({
  name: 'shell',
  remotes: {
    recommendations: 'recommendations@https://cdn.example.com/remoteEntry.js',
  },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
});

The shell lazy-loads the remote on demand:

// Shell: lazy-loading a remote component with error isolation
const RecommendationPanel = React.lazy(
  () => import('recommendations/RecommendationPanel')
);

function ProductPage() {
  return (
    <main>
      <ProductDetails />
      <ErrorBoundary fallback={<EmptyPanel />}>
        <Suspense fallback={<PanelSkeleton />}>
          <RecommendationPanel productId={productId} />
        </Suspense>
      </ErrorBoundary>
    </main>
  );
}

Cross-Micro-Frontend Communication

Micro-frontends must communicate without tight coupling. Three mechanisms, in order of preference:

1. URL / routing — the most durable contract. If a navigation event carries all required state in the URL, remotes need no shared runtime.

2. Shared event bus — a lightweight pub/sub on window or a singleton module that both shell and remotes import:

// Thin event bus — no direct import between remotes
const bus = {
  emit: (event: string, payload: unknown) =>
    window.dispatchEvent(new CustomEvent(event, { detail: payload })),
  on: (event: string, handler: (payload: unknown) => void) =>
    window.addEventListener(event, (e) => handler((e as CustomEvent).detail)),
};

// Cart remote emits; checkout remote listens — they never import each other
bus.emit('cart:item-added', { sku, quantity });

3. Shared store module — a singleton Zustand or Redux store exposed as a shared module. Use only for high-frequency shared state (e.g., auth session, cart count in the header). Treat it as a strict API contract: breaking changes require versioning.

Deployment Contract

Each remote publishes a remoteEntry.js at a stable URL. The shell resolves remote URLs at runtime (from config or a manifest API), not at build time. This is what enables independent deployment: the shell does not need to be rebuilt when a remote changes.

┌─────────────────────────────────────────┐
│              Shell Application          │
│  ┌──────────┐  ┌──────────┐  ┌───────┐ │
│  │  Header  │  │  Sidebar │  │ Slots │ │  ← Shell owns layout
│  └──────────┘  └──────────┘  └───┬───┘ │
│                                  │     │
└──────────────────────────────────┼─────┘
                                   │ runtime load
        ┌──────────────────────────┼──────────────────┐
        │                          │                  │
   ┌────▼────┐               ┌─────▼──────┐   ┌──────▼─────┐
   │ Product │               │  Reviews   │   │   Cart     │
   │  Team   │               │   Team     │   │   Team     │
   │ Remote  │               │   Remote   │   │   Remote   │
   └─────────┘               └────────────┘   └────────────┘
   (deploys independently)   (deploys independently)

When to Use

Avoid when:

Trade-offs

BenefitCost
Teams deploy independently; no shared release trainDistributed system complexity: remote availability, version skew, CDN cache management
Failure in one remote is isolated by an error boundaryDuplicate framework bundles inflate initial load unless shared modules are carefully configured
Teams can adopt different frameworks or versions per domainTesting the integrated product requires a full composition environment
Legacy monolith can be extracted incrementallyCross-remote debugging is harder: stack traces cross bundle boundaries
Scales team autonomy without scaling coordination overheadShared state contracts between remotes must be versioned as APIs

Live Playground

Experiment with the pattern below. The “before” tab shows a shell that imports a remote panel directly at build time — a single crash anywhere in the remote takes down the whole shell, and the shell can never ship without rebuilding against the remote’s exact version. The “after” tab shows the same shell resolving the remote from a runtime registry (standing in for remoteEntry.js resolution) and wrapping it in an error boundary, so the remote can fail, change, or redeploy independently.