Three years ago, our monolithic React app had 15 teams stepping on each other's toes. Deploy conflicts, merge nightmares, and a 45-minute build time that made everyone dread pushing code. We knew we needed micro-frontends, but every solution felt like duct tape—iframes, script tags, custom bundler hacks. Then Webpack 5 shipped Module Federation, and everything changed. We decomposed our monolith into 12 independently deployable applications. Build time: 3 minutes. Deploy conflicts: zero. Team velocity: through the roof.
Micro-frontends promise independent deployability, team autonomy, and technology flexibility. Module Federation delivers on that promise without the typical compromises. Let's explore how to architect, build, and deploy production-grade micro-frontend systems.
What is Module Federation?
Module Federation is Webpack 5's solution for sharing code between separately built applications at runtime. Think of it as dynamic imports on steroids—applications can load modules from other applications over the network, with automatic dependency resolution and deduplication.
Key Concepts:
- Host: The main application that loads remote modules
- Remote: An application that exposes modules for others to consume
- Shared Dependencies: Libraries (React, lodash) shared between applications
- Dynamic Remote Containers: Load remotes discovered at runtime
// Traditional approach: Everything bundled together
import { UserProfile } from '@monolith/user-profile';
// Module Federation: Loaded at runtime from separate deployment
import { UserProfile } from 'userApp/UserProfile';
// ↑ This comes from a different app, possibly on a different server!
Architecture Patterns
Pattern 1: Shell Application + Feature Apps
The most common pattern: a lightweight shell orchestrates multiple feature applications.
┌─────────────────── Shell App (Host) ───────────────────┐
│ - Routing │
│ - Authentication │
│ - Shared UI (header, sidebar) │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Products │ │ Orders │ │ Settings │ │
│ │ Remote │ │ Remote │ │ Remote │ │
│ └────────────┘ └────────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────┘
When to use:
- Multiple teams with clear feature boundaries
- Shared navigation and authentication
- Central routing coordination
Pattern 2: Micro-Frontends as Components
Apps expose reusable components that can be composed anywhere.
┌─────── Marketing Site ───────┐ ┌─────── Admin Portal ─────┐
│ import Header from 'uiLib' │ │ import Header from 'uiLib'│
│ import Footer from 'uiLib' │ │ import Table from 'uiLib' │
└──────────────────────────────┘ └───────────────────────────┘
↓ ↓
┌───────────────────────────────────────────┐
│ UI Library (Remote) │
│ - Header, Footer, Button, Table │
└───────────────────────────────────────────┘
When to use:
- Design system shared across multiple apps
- Common components with frequent updates
- Avoiding duplication across teams
Pattern 3: Micro-Frontends as Pages
Each route is owned by a different application.
/products/* → Products App
/checkout/* → Checkout App
/dashboard/* → Dashboard App
/settings/* → Settings App
When to use:
- Strong route boundaries
- Teams own entire user flows
- Minimal cross-team dependencies
Setting Up Module Federation
Step 1: Project Structure
my-micro-frontends/
├── packages/
│ ├── shell/ # Host application
│ │ ├── webpack.config.js
│ │ ├── src/
│ │ │ ├── App.tsx
│ │ │ ├── bootstrap.tsx
│ │ │ └── index.ts
│ │ └── package.json
│ ├── products/ # Remote application
│ │ ├── webpack.config.js
│ │ ├── src/
│ │ │ ├── App.tsx
│ │ │ ├── ProductList.tsx
│ │ │ └── index.ts
│ │ └── package.json
│ └── orders/ # Another remote
│ ├── webpack.config.js
│ └── src/
├── package.json # Workspace root
└── pnpm-workspace.yaml
Step 2: Configure Remote (Products App)
// packages/products/webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const path = require('path');
const packageJson = require('./package.json');
module.exports = {
entry: './src/index.ts',
mode: 'production',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
publicPath: 'https://products.example.com/', // CDN URL
clean: true,
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'products',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/ProductList',
'./ProductDetail': './src/ProductDetail',
'./ProductSearch': './src/ProductSearch',
},
shared: {
react: {
singleton: true,
requiredVersion: packageJson.dependencies.react,
},
'react-dom': {
singleton: true,
requiredVersion: packageJson.dependencies['react-dom'],
},
'react-router-dom': {
singleton: true,
},
},
}),
],
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
};
Key Configuration:
name: Unique identifier for this remotefilename: Entry point file (default: remoteEntry.js)exposes: Modules available to other appsshared: Dependencies shared with host (deduplication)
Step 3: Configure Host (Shell App)
// packages/shell/webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = {
entry: './src/index.ts',
mode: 'production',
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: 'https://shell.example.com/',
},
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
products: 'products@https://products.example.com/remoteEntry.js',
orders: 'orders@https://orders.example.com/remoteEntry.js',
},
shared: {
react: { singleton: true, eager: true },
'react-dom': { singleton: true, eager: true },
'react-router-dom': { singleton: true },
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
// ... rest of config
};
Key Configuration:
remotes: Map of remote applications and their URLseager: true: Load immediately (no lazy loading)singleton: true: Only one instance of React across all apps
Step 4: Bootstrap Pattern
Critical: Use dynamic imports to ensure shared dependencies load first.
// packages/shell/src/index.ts
// ⚠️ Don't import React here! Dynamic import only.
import('./bootstrap');
// packages/shell/src/bootstrap.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(<App />);
Why? Module Federation needs to resolve shared dependencies before React loads. The bootstrap pattern ensures correct loading order.
Pattern 4: Dynamic Remote Loading
Discover and load remotes at runtime (not compile time):
// packages/shell/src/utils/loadRemote.ts
export function loadRemote<T = any>(
url: string,
scope: string,
module: string
): Promise<T> {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.type = 'text/javascript';
script.async = true;
script.onload = () => {
// @ts-ignore
const container = window[scope];
// Initialize the remote
container
.init(__webpack_share_scopes__.default)
.then(() => {
// Get the module
container.get(module).then((factory: any) => {
const Module = factory();
resolve(Module);
});
})
.catch(reject);
};
script.onerror = reject;
document.head.appendChild(script);
});
}
// Usage: Load remotes from a config API
interface RemoteConfig {
name: string;
url: string;
scope: string;
}
async function loadRemotesFromAPI() {
const response = await fetch('/api/remotes');
const remotes: RemoteConfig[] = await response.json();
const loadedRemotes = await Promise.all(
remotes.map(remote =>
loadRemote(remote.url, remote.scope, './App')
)
);
return loadedRemotes;
}
Use Case: Multi-tenant platforms where each tenant has custom modules, or plugin systems where remotes are discovered dynamically.
Pattern 5: React Integration with Lazy Loading
// packages/shell/src/App.tsx
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Lazy load remote components
const ProductList = lazy(() => import('products/ProductList'));
const OrderList = lazy(() => import('orders/OrderList'));
const Settings = lazy(() => import('settings/Settings'));
export default function App() {
return (
<BrowserRouter>
<div className="app">
<nav>
<Link to="/products">Products</Link>
<Link to="/orders">Orders</Link>
<Link to="/settings">Settings</Link>
</nav>
<main>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/products/*" element={<ProductList />} />
<Route path="/orders/*" element={<OrderList />} />
<Route path="/settings/*" element={<Settings />} />
</Routes>
</Suspense>
</main>
</div>
</BrowserRouter>
);
}
Key Points:
- Suspense handles loading states
- Routes with
/*allow remote apps to handle sub-routes - Each remote loads only when navigated to
Pattern 6: Shared State Management
Option 1: Context from Host
// packages/shell/src/context/AuthContext.tsx
import { createContext, useContext, useState } from 'react';
interface AuthContextType {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
export const AuthContext = createContext<AuthContextType>(null!);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = async (credentials: Credentials) => {
const user = await api.login(credentials);
setUser(user);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Export for remotes
export const useAuth = () => useContext(AuthContext);
// packages/shell/webpack.config.js
new ModuleFederationPlugin({
name: 'shell',
exposes: {
'./AuthContext': './src/context/AuthContext',
},
// ...
});
// packages/products/src/ProductList.tsx
import { useAuth } from 'shell/AuthContext';
export default function ProductList() {
const { user } = useAuth();
if (!user) {
return <div>Please log in</div>;
}
return <div>Products for {user.name}</div>;
}
Option 2: Event Bus for Communication
// packages/shared/src/eventBus.ts
type EventCallback = (data: any) => void;
class EventBus {
private events: Map<string, EventCallback[]> = new Map();
emit(event: string, data?: any) {
const callbacks = this.events.get(event) || [];
callbacks.forEach(callback => callback(data));
}
on(event: string, callback: EventCallback) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event)!.push(callback);
}
off(event: string, callback: EventCallback) {
const callbacks = this.events.get(event) || [];
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
export const eventBus = new EventBus();
// products/src/ProductDetail.tsx
import { eventBus } from 'shared/eventBus';
function ProductDetail() {
const addToCart = (product: Product) => {
// Emit event for other apps
eventBus.emit('cart:add', product);
};
return <button onClick={() => addToCart(product)}>Add to Cart</button>;
}
// cart/src/CartWidget.tsx
import { useEffect, useState } from 'react';
import { eventBus } from 'shared/eventBus';
function CartWidget() {
const [items, setItems] = useState([]);
useEffect(() => {
const handler = (product: Product) => {
setItems(prev => [...prev, product]);
};
eventBus.on('cart:add', handler);
return () => eventBus.off('cart:add', handler);
}, []);
return <div>Cart ({items.length})</div>;
}
Versioning Strategy
Semantic Versioning for Remotes
// products/webpack.config.js
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'products',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/ProductList',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0', // Flexible versioning
strictVersion: false, // Allow minor version mismatches
},
},
}),
],
};
Version Manifest
// packages/shell/src/utils/remoteVersions.ts
interface RemoteManifest {
name: string;
version: string;
url: string;
exposes: string[];
}
export async function getRemoteManifest(
remoteName: string
): Promise<RemoteManifest> {
const response = await fetch(`https://cdn.example.com/manifests/${remoteName}.json`);
return response.json();
}
// Usage
const manifest = await getRemoteManifest('products');
console.log(`Loading products v${manifest.version}`);
Error Handling & Fallbacks
// packages/shell/src/components/RemoteComponent.tsx
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback: ReactNode;
}
interface State {
hasError: boolean;
}
export class RemoteErrorBoundary extends Component<Props, State> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: any) {
console.error('Remote component error:', error, errorInfo);
// Report to monitoring service
reportError({
type: 'remote_load_error',
error: error.message,
componentStack: errorInfo.componentStack,
});
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// Usage
<RemoteErrorBoundary fallback={<div>Failed to load products</div>}>
<Suspense fallback={<Spinner />}>
<ProductList />
</Suspense>
</RemoteErrorBoundary>
Deployment Strategy
Multi-Environment Setup
// packages/products/webpack.config.js
const getPublicPath = () => {
switch (process.env.NODE_ENV) {
case 'production':
return 'https://cdn.example.com/products/';
case 'staging':
return 'https://staging-cdn.example.com/products/';
default:
return 'http://localhost:3001/';
}
};
module.exports = {
output: {
publicPath: getPublicPath(),
},
// ...
};
Blue-Green Deployment
// Remote registry with versioning
interface RemoteRegistry {
products: {
stable: 'https://cdn.example.com/products/v1.2.3/remoteEntry.js',
canary: 'https://cdn.example.com/products/v1.3.0-beta/remoteEntry.js',
};
orders: {
stable: 'https://cdn.example.com/orders/v2.1.0/remoteEntry.js',
canary: 'https://cdn.example.com/orders/v2.2.0-alpha/remoteEntry.js',
};
}
// Load based on feature flag
const remoteUrl = featureFlags.useCanary
? remoteRegistry.products.canary
: remoteRegistry.products.stable;
CI/CD Pipeline
# .github/workflows/deploy-remote.yml
name: Deploy Remote
on:
push:
branches: [main]
paths:
- 'packages/products/**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build
run: |
cd packages/products
npm install
npm run build
- name: Deploy to CDN
run: |
aws s3 sync packages/products/dist s3://cdn.example.com/products/v${{ github.sha }}/
aws cloudfront create-invalidation --distribution-id ${{ secrets.CF_DIST_ID }}
- name: Update Remote Registry
run: |
curl -X PUT https://api.example.com/remotes/products \
-H "Authorization: Bearer ${{ secrets.API_TOKEN }}" \
-d '{
"version": "${{ github.sha }}",
"url": "https://cdn.example.com/products/v${{ github.sha }}/remoteEntry.js"
}'
Performance Optimization
Preloading Remotes
<!-- index.html -->
<link rel="preload" href="https://cdn.example.com/products/remoteEntry.js" as="script" />
<link rel="preload" href="https://cdn.example.com/orders/remoteEntry.js" as="script" />
Code Splitting Within Remotes
// products/src/ProductList.tsx
import { lazy } from 'react';
// Split heavy components
const ProductFilters = lazy(() => import('./ProductFilters'));
const ProductGrid = lazy(() => import('./ProductGrid'));
export default function ProductList() {
return (
<div>
<Suspense fallback={<div>Loading filters...</div>}>
<ProductFilters />
</Suspense>
<Suspense fallback={<div>Loading products...</div>}>
<ProductGrid />
</Suspense>
</div>
);
}
Caching Strategy
// Webpack config
module.exports = {
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].chunk.js',
},
optimization: {
moduleIds: 'deterministic',
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
},
};
Testing Strategy
Testing Remotes in Isolation
// products/src/ProductList.test.tsx
import { render } from '@testing-library/react';
import ProductList from './ProductList';
describe('ProductList', () => {
it('renders products', () => {
const { getByText } = render(<ProductList />);
expect(getByText('Products')).toBeInTheDocument();
});
});
Integration Testing
// shell/src/App.integration.test.tsx
import { render, waitFor } from '@testing-library/react';
import App from './App';
// Mock remote modules
jest.mock('products/ProductList', () => ({
default: () => <div>Mocked Products</div>,
}));
describe('App Integration', () => {
it('loads remote components', async () => {
const { getByText } = render(<App />);
await waitFor(() => {
expect(getByText('Mocked Products')).toBeInTheDocument();
});
});
});
Common Pitfalls
❌ Version Conflicts
// DON'T: Different React versions
// Host: React 18.0.0
// Remote: React 17.0.2
// Result: Two React instances loaded! 💥
Solution: Use singleton: true and align versions across all apps.
❌ Circular Dependencies
Shell imports from Products
Products imports from Shell
Result: Infinite loading loop 💥
Solution: Extract shared code to a separate package.
❌ Missing Error Boundaries
// DON'T: No error handling
<Suspense fallback={<Loading />}>
<RemoteComponent />
</Suspense>
// DO: Add error boundary
<ErrorBoundary>
<Suspense fallback={<Loading />}>
<RemoteComponent />
</Suspense>
</ErrorBoundary>
Production Checklist
Before launching:
- ✅ All remotes use semantic versioning
- ✅ Error boundaries wrap all remote components
- ✅ Monitoring for remote load failures
- ✅ Fallback UIs for offline/failed remotes
- ✅ Shared dependencies use singleton mode
- ✅ CDN caching configured (long TTL)
- ✅ Deployment pipeline tests remote compatibility
- ✅ Feature flags for gradual rollouts
- ✅ Performance budgets for each remote
- ✅ Documentation for adding new remotes
Summary
Module Federation enables true micro-frontend architecture:
Key Benefits:
- Independent deployment - Ship without coordinating with other teams
- Code sharing - Share components and dependencies
- Technology flexibility - Mix React, Vue, Angular (with adapters)
- Team autonomy - Own your domain end-to-end
- Incremental adoption - Migrate piece by piece
Architecture Principles:
- Start with clear boundaries (by feature, team, or domain)
- Minimize inter-remote communication (loose coupling)
- Version remotes independently
- Plan for failures (error boundaries, fallbacks)
- Monitor remote load performance
When to Use:
- ✅ Multiple teams working on one product
- ✅ Need independent deployment cadence
- ✅ Large application with clear feature boundaries
- ✅ Shared component libraries across apps
When to Skip:
- ❌ Small team (<5 developers)
- ❌ Simple application (< 10 routes)
- ❌ Tight coupling between features
- ❌ Limited infrastructure for multiple deployments
Frequently Asked Questions
Can I use Module Federation with Vite instead of Webpack?
Yes! Vite has experimental support for Module Federation via the vite-plugin-federation plugin. However, it's less mature than Webpack's implementation. For production applications, Webpack 5 remains the safest choice. If you prefer Vite, thoroughly test cross-remote compatibility and be prepared for occasional plugin updates.
How do I handle versioning conflicts between remotes?
Use semantic versioning and the requiredVersion configuration in Module Federation. Set strictVersion: false to allow minor version mismatches for libraries like React. For breaking changes, create a new remote name (e.g., productsV2) rather than breaking existing consumers. Maintain a central version registry (JSON file or API) to track compatible versions across remotes.
Can I use different React versions across remotes?
No—this is a common mistake that causes runtime errors. All remotes must use the same major React version. Set singleton: true in the shared configuration to enforce this. Webpack will warn if versions mismatch. For major upgrades, coordinate all teams to upgrade simultaneously or use feature flags to gradually roll out the new version.
How do I test Module Federation locally?
Run each remote on a different port (e.g., shell on :3000, products on :3001). Update webpack configs to point to localhost URLs during development. Use tools like concurrently to start all apps at once. For integration tests, mock remotes or use webpack-dev-server with hot reload. Always test the production build before deploying—local and production builds can behave differently.
What's the performance impact of Module Federation?
Initial load adds 10-50KB per remote (the remoteEntry.js file). Each remote loaded dynamically adds network latency (typically 50-200ms depending on CDN). However, you gain: better caching (users only re-download changed remotes), code splitting (don't load products app on the checkout page), and parallel development (faster builds per team). For most applications, the tradeoffs favor Module Federation.
How do I handle authentication across remotes?
Centralize authentication in the shell application. Expose authentication context via Module Federation (shell/AuthContext) and import it in remotes. Store tokens in cookies (not localStorage) for server-side access. All remotes should treat the shell as the source of truth for user state. Use event buses for cross-remote communication if needed.
Can I use Module Federation with monorepos?
Absolutely—this is the recommended setup. Tools like pnpm workspaces, Nx, or Turborepo make managing multiple remotes easier. Share common code via workspace packages, use consistent build tooling across remotes, and centralize configuration. Monorepos with Module Federation give you independent deployment with shared code, the best of both worlds.
How do I migrate an existing monolith to Module Federation?
Start with a single remote for one feature (e.g., settings page). Keep the monolith as the shell application. Once stable, extract another feature. Gradually decompose over 3-6 months—don't rewrite everything at once. Use feature flags to toggle between old monolith pages and new remotes. Measure performance and stability at each step. Plan for 20-30% longer initial timeline due to infrastructure setup.
Module Federation is production-ready and battle-tested at scale (used by companies like ByteDance, Microsoft, and others). Start with a pilot remote, validate the architecture, then gradually decompose your monolith. Your future self (and your team) will thank you.