Module Federation in 2026: What Has Changed
Module Federation, introduced with Webpack 5 in 2020, has matured significantly. In 2026, it is no longer just a Webpack feature — the ecosystem includes native Vite plugins, framework-agnostic implementations, and production battle-testing across hundreds of enterprise deployments.
This guide covers Module Federation from first principles through advanced production patterns, including the Vite Federation plugin that has made it accessible to Vite users.
What Is Module Federation?
Module Federation allows JavaScript applications to dynamically load code from other applications at runtime. Unlike traditional code splitting (which splits your own code), Federation allows you to share code across entirely separate deployments.
The core model:
- Host: The shell application that loads remote modules
- Remote: An independent application that exposes modules
- Shared modules: Dependencies (like React) shared to avoid duplicate loading
Browser loads shell app (host)
|
|-- At runtime, discovers remote URLs
|
|-- Loads product-catalog.example.com/remoteEntry.js
| └── Provides: ProductCatalog, ProductCard components
|
|-- Loads cart.example.com/remoteEntry.js
└── Provides: CartWidget, useCart hook
Each remote can be deployed independently. A change to the cart service does not require redeploying the shell or any other remote.
Webpack 5 Module Federation Setup
// webpack.config.js — Host application
const { ModuleFederationPlugin } = require('webpack').container
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
// Format: name@url/remoteEntry.js
productCatalog: 'productCatalog@https://catalog.example.com/remoteEntry.js',
cart: 'cart@https://cart.example.com/remoteEntry.js',
userProfile: 'userProfile@https://profile.example.com/remoteEntry.js',
},
shared: {
react: {
singleton: true, // Only one instance of React across all remotes
requiredVersion: '^18.0.0',
eager: true, // Load with host, not lazily
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
eager: true,
},
// Shared design system
'@company/design-system': {
singleton: true,
requiredVersion: '^3.0.0',
},
},
}),
],
}
// webpack.config.js — Remote application (product catalog)
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'productCatalog',
filename: 'remoteEntry.js',
exposes: {
// What this remote makes available to hosts
'./ProductCatalog': './src/components/ProductCatalog',
'./ProductCard': './src/components/ProductCard',
'./useProductSearch': './src/hooks/useProductSearch',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
'@company/design-system': { singleton: true, requiredVersion: '^3.0.0' },
},
}),
],
}
// In the host — using remote components
// These are dynamically loaded at runtime from catalog.example.com
const ProductCatalog = React.lazy(() => import('productCatalog/ProductCatalog'))
const ProductCard = React.lazy(() => import('productCatalog/ProductCard'))
function App() {
return (
<Suspense fallback={<Skeleton />}>
<ProductCatalog />
</Suspense>
)
}
Vite Federation Plugin
The @originjs/vite-plugin-federation plugin brings Module Federation to Vite projects:
npm install -D @originjs/vite-plugin-federation
// vite.config.ts — Host
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
react(),
federation({
name: 'shell',
remotes: {
productCatalog: 'https://catalog.example.com/assets/remoteEntry.js',
cart: 'https://cart.example.com/assets/remoteEntry.js',
},
shared: ['react', 'react-dom'],
}),
],
build: {
modulePreload: false,
target: 'esnext', // Required for module federation
minify: false, // Optional: easier debugging
cssCodeSplit: false,
},
})
// vite.config.ts — Remote (product catalog)
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
react(),
federation({
name: 'productCatalog',
filename: 'remoteEntry.js',
exposes: {
'./ProductCatalog': './src/components/ProductCatalog.tsx',
'./ProductCard': './src/components/ProductCard.tsx',
},
shared: ['react', 'react-dom'],
}),
],
build: {
target: 'esnext',
},
})
Dynamic Remote Discovery
Hardcoding remote URLs in configuration is inflexible. Dynamic discovery enables runtime flexibility:
// remoteRegistry.ts
interface RemoteConfig {
name: string
url: string
version: string
}
class RemoteRegistry {
private registry: Map<string, RemoteConfig> = new Map()
async initialize() {
// Fetch remote configurations from a registry service
const response = await fetch('/api/micro-frontend-registry')
const configs: RemoteConfig[] = await response.json()
configs.forEach(config => {
this.registry.set(config.name, config)
})
}
async loadRemote(name: string, module: string) {
const config = this.registry.get(name)
if (!config) throw new Error(`Remote '${name}' not found in registry`)
// Dynamic remote loading
// @ts-ignore — __webpack_init_sharing__ and __webpack_share_scopes__ are injected by MF
await __webpack_init_sharing__('default')
const container = window[name as keyof Window] as any
if (!container) {
// Load the remote entry script dynamically
await loadScript(config.url)
}
await container.init(__webpack_share_scopes__.default)
const factory = await container.get(module)
return factory()
}
}
function loadScript(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = url
script.onload = () => resolve()
script.onerror = () => reject(new Error(`Failed to load remote: ${url}`))
document.head.appendChild(script)
})
}
export const remoteRegistry = new RemoteRegistry()
Shared State Across Micro-Frontends
Micro-frontends need to communicate without tight coupling:
// shared-state/eventBus.ts — published as a shared module
type EventMap = {
'cart:item-added': { productId: string; quantity: number }
'user:authenticated': { userId: string; token: string }
'navigation:route-change': { path: string }
}
class EventBus {
private listeners: Map<keyof EventMap, Set<Function>> = new Map()
on<K extends keyof EventMap>(event: K, handler: (data: EventMap[K]) => void) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set())
this.listeners.get(event)!.add(handler)
return () => this.listeners.get(event)!.delete(handler)
}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {
this.listeners.get(event)?.forEach(handler => handler(data))
}
}
// Ensure singleton across all micro-frontends
declare global {
interface Window { __EVENT_BUS__?: EventBus }
}
export const eventBus = window.__EVENT_BUS__ ?? (window.__EVENT_BUS__ = new EventBus())
Version Management and Compatibility
The trickiest part of micro-frontends: what happens when Host expects React 18.2 but Remote ships React 18.3?
// Strict version pinning — safest but requires coordinated deployments
shared: {
react: {
singleton: true,
strictVersion: true,
requiredVersion: '18.2.0', // Exact version
}
}
// Flexible version range — more independent deployments
shared: {
react: {
singleton: true,
strictVersion: false, // Warn instead of error on mismatch
requiredVersion: '^18.0.0', // Any 18.x version is acceptable
}
}
Shared dependency strategy:
- React, ReactDOM: Always singleton, strict version pinning
- Design system: Singleton, semver minor flexibility
- Utility libraries (lodash, date-fns): Non-singleton, each remote manages its own version
Independent Deployment Pipeline
# .github/workflows/deploy-catalog.yml
name: Deploy Product Catalog
on:
push:
branches: [main]
paths:
- 'apps/product-catalog/**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: |
cd apps/product-catalog
npm ci
npm run build
- name: Integration test with host
run: |
# Start host app pointing to new catalog build
HOST_URL=http://localhost:3000 CATALOG_REMOTE_URL=http://localhost:3001/assets/remoteEntry.js npx playwright test tests/integration/catalog.spec.ts
- name: Deploy to CDN
run: |
aws s3 sync apps/product-catalog/dist s3://company-mfe-catalog/latest/ --cache-control "public, max-age=31536000"
# Update registry to point to new version
curl -X PUT https://registry.example.com/api/remotes/productCatalog -d '{"url": "https://cdn.example.com/catalog/latest/assets/remoteEntry.js"}'
When to Use Module Federation
Use micro-frontends with Module Federation when:
- Multiple teams own different parts of the same application
- Independent deployment of features is a business requirement
- Different parts of the app have genuinely different tech stacks or upgrade cadences
- Application is large enough that a monorepo with shared packages is insufficient
Do not use when:
- Single team owns the entire frontend
- Application is small to medium sized
- Network latency for loading remotes is unacceptable in your use case
- Team does not have the operational maturity to run multiple deployment pipelines
The honest truth: Most applications do not need micro-frontends. A well-organized monorepo with clear ownership and a shared component library solves the same organizational problems with much less complexity. Reach for Module Federation when the team and deployment requirements genuinely demand it.
Production Checklist
Before going live with Module Federation:
- Remote entry URLs are served from a CDN with proper cache headers
- Fallback behavior is implemented for when a remote fails to load
- Error boundaries wrap every remote component
- Integration tests verify cross-remote communication
- Performance budget accounts for remote entry overhead (~5-15KB each)
- Version registry has rollback capability
- Monitoring alerts on remote load failures