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.
Table of Contents
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.