React Micro-Frontends: Module Federation Guide (2026)

Micro-frontends let large organizations split a frontend monolith into independently deployable apps, each owned by a separate team. Webpack 5's Module Federation is the most adopted approach — it enables runtime composition of separately built and deployed JavaScript modules. This guide covers host/remote setup, shared dependencies, cross-app routing, and when to use micro-frontends.

Core Concepts

  • Host (Shell) — the container app that loads and mounts remote apps at runtime. Owns the top-level layout, navigation and auth.
  • Remote — an independently deployed app that exposes components or pages. Examples: Checkout Team's checkout remote, Catalog Team's product-listing remote.
  • Shared modules — dependencies like React, React DOM, and your design system that are loaded once and shared across all remotes to avoid duplicate bundles.
  • Federated module — a JavaScript module exported from a remote and imported dynamically by the host at runtime, not at build time.

Webpack Module Federation

// Structure
apps/
├── shell/          ← Host: loads remotes, owns routing
├── checkout/       ← Remote: owned by Checkout team
├── catalog/        ← Remote: owned by Catalog team
└── shared-ui/      ← Remote: design system components

// Each app is a separate project with its own package.json,
// CI pipeline, and deployment. They share nothing at build time.

Remote App Setup

// apps/checkout/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container

module.exports = {
  entry: './src/index',
  output: {
    publicPath: 'auto',   // IMPORTANT: required for Module Federation
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'checkout',                  // Unique name — used by host
      filename: 'remoteEntry.js',        // Entry file exposed to host
      exposes: {
        // Key: import name host uses. Value: local file
        './CheckoutPage': './src/pages/CheckoutPage',
        './CartButton': './src/components/CartButton',
        './useCart': './src/hooks/useCart',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
        'react-router-dom': { singleton: true },
      },
    }),
  ],
}

// apps/checkout/src/pages/CheckoutPage.tsx
// Just a normal React component — no special MF code needed
export default function CheckoutPage() {
  return <div>Checkout flow owned by Checkout team</div>
}

// apps/checkout/src/bootstrap.tsx
// MF remotes need an async bootstrap to avoid eager consumption errors
import('./App')   // Dynamic import of actual app entry

// apps/checkout/src/index.ts
import('./bootstrap')  // Thin sync entry that triggers async load

Host App Setup

// apps/shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        // Name maps to the remote's `name` field in its config
        // URL is the deployed location of remoteEntry.js
        checkout: 'checkout@https://checkout.myapp.com/remoteEntry.js',
        catalog:  'catalog@https://catalog.myapp.com/remoteEntry.js',
        sharedUi: 'sharedUi@https://design.myapp.com/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
        'react-router-dom': { singleton: true },
      },
    }),
  ],
}

// apps/shell/src/App.tsx
import React, { Suspense, lazy } from 'react'

// TypeScript: declare remote modules
declare module 'checkout/CheckoutPage' {
  const CheckoutPage: React.ComponentType
  export default CheckoutPage
}

// Lazy load remote components — just like React.lazy but from a remote
const CheckoutPage = lazy(() => import('checkout/CheckoutPage'))
const ProductListing = lazy(() => import('catalog/ProductListing'))

function App() {
  return (
    <Router>
      <Shell>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route
            path="/checkout/*"
            element={
              <Suspense fallback={<PageSkeleton />}>
                <CheckoutPage />
              </Suspense>
            }
          />
          <Route
            path="/products"
            element={
              <Suspense fallback={<PageSkeleton />}>
                <ProductListing />
              </Suspense>
            }
          />
        </Routes>
      </Shell>
    </Router>
  )
}

Shared Dependencies

// Shared config — same version across all apps
const sharedConfig = {
  react: {
    singleton: true,         // Only one instance loaded
    requiredVersion: '^18.3.0',
    eager: false,            // Load asynchronously (default)
  },
  'react-dom': { singleton: true, requiredVersion: '^18.3.0' },
  'react-router-dom': { singleton: true, requiredVersion: '^6.0.0' },
  // Share design system — avoids duplicate CSS-in-JS rules
  '@myapp/design-system': { singleton: true, requiredVersion: '^2.0.0' },
}

// Version mismatch handling
// If checkout requires react@18.2 and host has 18.3, webpack logs a warning
// but uses the higher version. Set strictVersion: true to throw instead.
shared: {
  react: { singleton: true, strictVersion: true, requiredVersion: '18.3.0' }
}

// eager: true — include in initial bundle (required for shell's own React)
// Use only in the shell's entry bootstrap file, not remotes
shared: {
  react: { singleton: true, eager: true }
}

Cross-App Routing

// Pattern: shell owns the router, remotes receive basename
// Remote uses useNavigate — navigates within shell's router

// Shell passes routing props to remote via React context
const RouterContext = createContext<{ navigate: NavigateFunction }>({
  navigate: () => {},
})

// Shell wraps remote with its router context
<RouterContext.Provider value={{ navigate }}>
  <Suspense fallback={<Spinner />}>
    <CheckoutPage />
  </Suspense>
</RouterContext.Provider>

// Remote uses the provided navigate (not its own router)
function CheckoutPage() {
  const { navigate } = useContext(RouterContext)
  function handleComplete() {
    navigate('/order-confirmation')   // Shell handles the route
  }
  return <CheckoutForm onComplete={handleComplete} />
}

// Alternative: route events via CustomEvent
function navigateInShell(path: string) {
  window.dispatchEvent(new CustomEvent('mf:navigate', { detail: { path } }))
}
// Shell listens: window.addEventListener('mf:navigate', e => navigate(e.detail.path))

Vite Federation

npm install @originjs/vite-plugin-federation --save-dev

// Remote vite.config.ts
import federation from '@originjs/vite-plugin-federation'

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: 'checkout',
      filename: 'remoteEntry.js',
      exposes: {
        './CheckoutPage': './src/pages/CheckoutPage',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
  build: { target: 'esnext' },  // Required for top-level await
})

// Host vite.config.ts
export default defineConfig({
  plugins: [
    react(),
    federation({
      name: 'shell',
      remotes: {
        checkout: 'https://checkout.myapp.com/assets/remoteEntry.js',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
  build: { target: 'esnext' },
})

When to Use Micro-Frontends

Use micro-frontends when: You have 5+ teams working on the same frontend. Independent deployment cadence matters — Checkout team must ship without waiting for the Catalog team. Different parts of the app have radically different tech stacks or dependencies.
Avoid micro-frontends when: You have a single small team. The overhead (separate CI pipelines, versioning contracts, shared dependency management, debugging cross-app issues) exceeds the benefit. For most apps, a monorepo with a shared component library is the better choice — it gives team ownership without runtime complexity. Start with a monorepo; add Module Federation only when deployment independence becomes a real need.