Setting up Module Federation (Webpack) for Microfrontends
Module Federation is a built-in Webpack 5 mechanism for sharing code between independent builds at runtime. Each application (remote) publishes part of its modules, another (host) loads them dynamically without rebuilding. Code deploys independently — changes in remote are immediately available in host.
This is not iframe and not web components — it's actual JS code sharing dependencies (React, ReactDOM, etc.) through a shared mechanism.
What's Included
Architectural design of splitting into remotes, configuring ModuleFederationPlugin in each application, typing through @module-federation/typescript, shared dependencies, dynamic loading, handling load errors, CI/CD with independent deploy.
Architecture
host (shell) — main application, entry point
├── remote: catalog — product catalog
├── remote: checkout — order checkout
├── remote: profile — user cabinet
└── remote: auth — auth widget (shared UI)
Each remote is a separate repository with a separate CI/CD pipeline.
Installation
# in each application
npm install webpack@5 webpack-cli webpack-dev-server
npm install @module-federation/typescript
npm install html-webpack-plugin babel-loader @babel/core @babel/preset-react @babel/preset-typescript
webpack.config.js — Host (shell)
// apps/shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container
const HtmlWebpackPlugin = require('html-webpack-plugin')
const deps = require('./package.json').dependencies
module.exports = (env, argv) => ({
mode: argv.mode ?? 'development',
entry: './src/index.ts',
output: {
publicPath: 'auto',
filename: '[name].[contenthash].js',
clean: true,
},
resolve: {
extensions: ['.ts', '.tsx', '.js'],
},
module: {
rules: [
{
test: /\.(ts|tsx)$/,
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react', '@babel/preset-typescript'],
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
catalog: `catalog@${
argv.mode === 'production'
? 'https://catalog.example.com'
: 'http://localhost:3001'
}/remoteEntry.js`,
checkout: `checkout@${
argv.mode === 'production'
? 'https://checkout.example.com'
: 'http://localhost:3002'
}/remoteEntry.js`,
auth: `auth@${
argv.mode === 'production'
? 'https://auth.example.com'
: 'http://localhost:3003'
}/remoteEntry.js`,
},
shared: {
react: {
singleton: true,
requiredVersion: deps.react,
eager: false,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
eager: false,
},
'react-router-dom': {
singleton: true,
requiredVersion: deps['react-router-dom'],
},
},
}),
new HtmlWebpackPlugin({ template: './public/index.html' }),
],
devServer: {
port: 3000,
historyApiFallback: true,
},
})
webpack.config.js — Remote (catalog)
// apps/catalog/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container
const deps = require('./package.json').dependencies
module.exports = (env, argv) => ({
mode: argv.mode ?? 'development',
entry: './src/index.ts',
output: {
publicPath: 'auto',
filename: '[name].[contenthash].js',
clean: true,
},
plugins: [
new ModuleFederationPlugin({
name: 'catalog',
filename: 'remoteEntry.js', // entry point for host
exposes: {
'./ProductList': './src/components/ProductList',
'./ProductDetail': './src/components/ProductDetail',
'./useCart': './src/hooks/useCart',
},
shared: {
react: {
singleton: true,
requiredVersion: deps.react,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
'react-router-dom': {
singleton: true,
},
},
}),
],
devServer: {
port: 3001,
// CORS — host on different port
headers: { 'Access-Control-Allow-Origin': '*' },
historyApiFallback: true,
},
})
Bootstrap Pattern
Module Federation requires async loading. The entry point must be async:
// src/index.ts (in EVERY application)
import('./bootstrap')
// src/bootstrap.tsx
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
const root = createRoot(document.getElementById('root')!)
root.render(<App />)
Without this you'll get Shared module is not available for eager consumption.
Using Remote in Host
// apps/shell/src/App.tsx
import React, { Suspense, lazy } from 'react'
import { Routes, Route } from 'react-router-dom'
// TypeScript doesn't know about remote modules without declarations
const ProductList = lazy(() => import('catalog/ProductList'))
const ProductDetail = lazy(() => import('catalog/ProductDetail'))
const Checkout = lazy(() => import('checkout/CheckoutFlow'))
function App() {
return (
<Routes>
<Route
path="/products"
element={
<Suspense fallback={<PageSkeleton />}>
<ProductList />
</Suspense>
}
/>
<Route
path="/products/:id"
element={
<Suspense fallback={<PageSkeleton />}>
<ProductDetail />
</Suspense>
}
/>
<Route
path="/checkout"
element={
<Suspense fallback={<PageSkeleton />}>
<Checkout />
</Suspense>
}
/>
</Routes>
)
}
TypeScript Declarations for Remote Modules
npm install @module-federation/typescript
// webpack.config.js (remote)
const { FederatedTypesPlugin } = require('@module-federation/typescript')
plugins: [
new ModuleFederationPlugin({ ... }),
new FederatedTypesPlugin({
federationConfig: {
name: 'catalog',
exposes: { './ProductList': './src/components/ProductList' },
},
}),
]
// webpack.config.js (host)
plugins: [
new ModuleFederationPlugin({ ... }),
new FederatedTypesPlugin({
federationConfig: {
name: 'shell',
remotes: { catalog: 'catalog@...' },
},
}),
]
Types are generated automatically and available as @mf-types/catalog/ProductList.
Handling Remote Load Errors
// components/RemoteComponent.tsx
import React, { Suspense, Component, ReactNode } from 'react'
interface ErrorBoundaryState { hasError: boolean; error?: Error }
class RemoteErrorBoundary extends Component<
{ fallback: ReactNode; children: ReactNode },
ErrorBoundaryState
> {
state: ErrorBoundaryState = { hasError: false }
static getDerivedStateFromError(error: Error) {
return { hasError: true, error }
}
render() {
if (this.state.hasError) return this.props.fallback
return this.props.children
}
}
export function RemoteComponent({
component: Component,
fallback,
errorFallback,
...props
}: {
component: React.ComponentType<unknown>
fallback: ReactNode
errorFallback: ReactNode
[key: string]: unknown
}) {
return (
<RemoteErrorBoundary fallback={errorFallback}>
<Suspense fallback={fallback}>
<Component {...props} />
</Suspense>
</RemoteErrorBoundary>
)
}
Communication Between Microfrontends
Remote modules are isolated — no shared Redux/Zustand. Patterns for communication:
Custom Events:
// catalog remote — publishes event
window.dispatchEvent(new CustomEvent('catalog:add-to-cart', {
detail: { productId, quantity }
}))
// checkout remote — listens
window.addEventListener('catalog:add-to-cart', (e: CustomEvent) => {
checkoutStore.addItem(e.detail)
})
Shared state through shared module:
// expose store from auth remote
exposes: {
'./store': './src/store/authStore',
}
// shared: singleton so all remotes get one instance
shared: {
'./src/store/authStore': { singleton: true }
}
CI/CD — Independent Deploy
# .github/workflows/catalog.yml
name: Deploy Catalog
on:
push:
branches: [main]
paths: ['apps/catalog/**']
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd apps/catalog && npm ci && npm run build
- name: Deploy to CDN
run: aws s3 sync apps/catalog/dist s3://catalog.example.com --delete
- name: Invalidate CloudFront
run: aws cloudfront create-invalidation --distribution-id $CF_ID --paths "/*"
Host receives updated remote without its own deploy — on next page load.
What We Do
Design boundaries between microfrontends, configure webpack with ModuleFederationPlugin in each application, solve shared dependencies (React singleton, design system), set up TypeScript declarations, implement remote load error handling, establish CI/CD for independent deploy of each remote.
Timeline: 5–10 days depending on number of remotes and monorepo availability.







