Module Federation in 2026: Micro-Frontends That Actually Work
Module Federation in 2026: Micro-Frontends That Actually Work
Micro-frontends promise organizational scalability—independent teams shipping independent features. The reality has been different: shared dependency hell, version conflicts, runtime explosions, and integration nightmares that make the monolith look appealing.
Module Federation changed this. But it's also been misused, misunderstood, and over-engineered into oblivion. This is a pragmatic guide to building micro-frontends that actually work: the architectural decisions that matter, the pitfalls that kill teams, and how to structure production systems that scale with your organization.
The Micro-Frontend Problem Space
┌─────────────────────────────────────────────────────────────────────────────┐
│ WHY MICRO-FRONTENDS EXIST │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Organizational Scaling (the real reason) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Monolith Frontend │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Single Repo │ │ │
│ │ │ Team A ──┐ │ │ │
│ │ │ Team B ──┼── Single Build ── Single Deploy ── Coupling │ │ │
│ │ │ Team C ──┘ │ │ │
│ │ │ │ │ │
│ │ │ Problems: │ │ │
│ │ │ - Merge conflicts across teams │ │ │
│ │ │ - One team's bug blocks everyone's deploy │ │ │
│ │ │ - Shared code becomes no one's responsibility │ │ │
│ │ │ - Build times grow linearly with codebase │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Micro-Frontend Architecture │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ Team A │ │ Team B │ │ Team C │ │ │
│ │ │ Repo │ │ Repo │ │ Repo │ │ │
│ │ │ Build │ │ Build │ │ Build │ │ │
│ │ │ Deploy │ │ Deploy │ │ Deploy │ │ │
│ │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │
│ │ │ │ │ │ │
│ │ └──────────────┼──────────────┘ │ │
│ │ ▼ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ Shell App │ │ │
│ │ │ (Composition) │ │ │
│ │ └─────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ When to use micro-frontends: │
│ ✓ Multiple teams (3+) working on same product │
│ ✓ Teams need independent release cycles │
│ ✓ Different parts of app have different scaling needs │
│ ✓ Organizational boundaries align with feature boundaries │
│ │
│ When NOT to use: │
│ ✗ Small team (< 10 engineers) │
│ ✗ Shared state is core to the product │
│ ✗ Performance is critical (adds overhead) │
│ ✗ "Because Netflix does it" │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Module Federation: The Mental Model
Webpack 5's Module Federation allows JavaScript applications to dynamically load code from other applications at runtime, while sharing dependencies.
┌─────────────────────────────────────────────────────────────────────────────┐
│ MODULE FEDERATION ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Traditional Bundling │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Build Time Runtime │ │
│ │ ┌─────────┐ ┌─────────────────────┐ │ │
│ │ │ App A │──┐ │ bundle.js │ │ │
│ │ │ + React │ │ │ │ │ │
│ │ └─────────┘ │ Bundle │ App A code │ │ │
│ │ ┌─────────┐ ├─────────────────────────►│ + React (bundled) │ │ │
│ │ │ App B │ │ │ + App B code │ │ │
│ │ │ + React │──┘ │ + React (again!) │ │ │
│ │ └─────────┘ │ │ │ │
│ │ └─────────────────────┘ │ │
│ │ │ │
│ │ Problem: Each app bundles its own copy of shared deps │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Module Federation │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Build Time Runtime │ │
│ │ ┌─────────┐ │ │
│ │ │ App A │──► remoteEntry.js ┌───────────────────┐ │ │
│ │ │ exposes │ (manifest) │ Shell │ │ │
│ │ └─────────┘ │ │ │ │
│ │ ┌─────────┐ │ Load remotes │ │ │
│ │ │ App B │──► remoteEntry.js │ at runtime ─────┼──┐ │ │
│ │ │ exposes │ (manifest) │ │ │ │ │
│ │ └─────────┘ │ Share React ◄────┼──┼──│ │
│ │ ┌─────────┐ │ (single copy) │ │ │ │
│ │ │ React │──► Shared singleton │ │ │ │ │
│ │ └─────────┘ └───────────────────┘ │ │ │
│ │ │ │ │ │
│ │ ▼ │ │ │
│ │ ┌─────────────────┐ │ │ │
│ │ │ Remote Chunks │◄───┘ │ │
│ │ │ (loaded on │ │ │
│ │ │ demand) │ │ │
│ │ └─────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Key Concepts
// Host (Shell) - consumes remote modules
// webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
// Remotes this app can consume
remotes: {
// Format: "internalName@URL/remoteEntry.js"
checkout: 'checkout@https://checkout.example.com/remoteEntry.js',
catalog: 'catalog@https://catalog.example.com/remoteEntry.js',
account: 'account@https://account.example.com/remoteEntry.js',
},
// Dependencies to share (critical!)
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};
// Remote (Micro-frontend) - exposes modules
// checkout/webpack.config.js
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'checkout',
// What this app exposes to others
exposes: {
'./CheckoutFlow': './src/components/CheckoutFlow',
'./CartSummary': './src/components/CartSummary',
'./useCart': './src/hooks/useCart',
},
// Also shares dependencies
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};
Webpack Module Federation vs Native ESM Federation
Webpack Module Federation (Current Standard)
┌─────────────────────────────────────────────────────────────────────────────┐
│ WEBPACK MODULE FEDERATION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Pros: │
│ ✓ Mature and battle-tested (since Webpack 5, 2020) │
│ ✓ Rich dependency sharing (version negotiation, singletons) │
│ ✓ Works with existing Webpack ecosystem │
│ ✓ Chunk splitting and optimization │
│ ✓ Development experience (HMR, source maps) │
│ │
│ Cons: │
│ ✗ Webpack-only (sort of - see Vite plugins) │
│ ✗ Complex configuration │
│ ✗ Runtime overhead (container initialization) │
│ ✗ Version mismatches can be silent failures │
│ │
│ Architecture: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ remoteEntry.js (manifest) │ │
│ │ ├── Declares exposed modules │ │
│ │ ├── Declares shared dependencies │ │
│ │ ├── Contains initialization logic │ │
│ │ └── Points to actual chunks │ │
│ │ │ │
│ │ Chunk loading: │ │
│ │ 1. Shell loads remoteEntry.js │ │
│ │ 2. Container initializes, negotiates shared deps │ │
│ │ 3. Shell imports exposed module │ │
│ │ 4. Container loads required chunks │ │
│ │ 5. Module executes with shared context │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Native ESM Federation (Import Maps + Dynamic Import)
┌─────────────────────────────────────────────────────────────────────────────┐
│ NATIVE ESM FEDERATION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Using Import Maps (browser-native) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ <!-- index.html --> │ │
│ │ <script type="importmap"> │ │
│ │ { │ │
│ │ "imports": { │ │
│ │ "react": "https://esm.sh/react@18.2.0", │ │
│ │ "react-dom": "https://esm.sh/react-dom@18.2.0", │ │
│ │ "@checkout/": "https://checkout.example.com/modules/", │ │
│ │ "@catalog/": "https://catalog.example.com/modules/" │ │
│ │ } │ │
│ │ } │ │
│ │ </script> │ │
│ │ │ │
│ │ <!-- Application code uses bare specifiers --> │ │
│ │ <script type="module"> │ │
│ │ import React from 'react'; │ │
│ │ import { CheckoutFlow } from '@checkout/CheckoutFlow.js'; │ │
│ │ </script> │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Pros: │
│ ✓ Browser-native (no bundler runtime) │
│ ✓ Simpler mental model │
│ ✓ Works with any bundler or no bundler │
│ ✓ Better caching (standard HTTP semantics) │
│ │
│ Cons: │
│ ✗ No version negotiation (you pick the version) │
│ ✗ No automatic chunk splitting for shared deps │
│ ✗ Import maps don't support dynamic URLs │
│ ✗ Limited browser support (no Safari until 2023) │
│ ✗ No HMR or dev tooling out of the box │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Comparison Matrix
┌─────────────────────────────────────────────────────────────────────────────┐
│ FEDERATION APPROACH COMPARISON │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Feature │ Webpack MF │ Native ESM │ Hybrid │
│ ─────────────────────────┼─────────────────┼─────────────────┼───────────│
│ Browser support │ All (bundled) │ Modern only │ All │
│ ─────────────────────────┼─────────────────┼─────────────────┼───────────│
│ Dependency sharing │ Automatic │ Manual │ Automatic │
│ ─────────────────────────┼─────────────────┼─────────────────┼───────────│
│ Version negotiation │ Yes │ No │ Yes │
│ ─────────────────────────┼─────────────────┼─────────────────┼───────────│
│ Chunk optimization │ Yes │ Manual │ Yes │
│ ─────────────────────────┼─────────────────┼─────────────────┼───────────│
│ Dev experience │ Excellent │ Basic │ Good │
│ ─────────────────────────┼─────────────────┼─────────────────┼───────────│
│ Bundle size overhead │ ~20KB runtime │ None │ ~10KB │
│ ─────────────────────────┼─────────────────┼─────────────────┼───────────│
│ Configuration complexity │ High │ Low │ Medium │
│ ─────────────────────────┼─────────────────┼─────────────────┼───────────│
│ Framework agnostic │ Partial │ Yes │ Yes │
│ ─────────────────────────┼─────────────────┼─────────────────┼───────────│
│ │
│ Recommendation: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Use Webpack Module Federation when: │ │
│ │ - You need sophisticated dependency sharing │ │
│ │ - Teams use different versions of shared libs │ │
│ │ - You want the mature tooling and ecosystem │ │
│ │ │ │
│ │ Use Native ESM when: │ │
│ │ - All remotes are owned by same team │ │
│ │ - You control dependency versions centrally │ │
│ │ - Bundle size is critical │ │
│ │ - You want simplicity over features │ │
│ │ │ │
│ │ Use Hybrid (Module Federation 2.0 / Rspack) when: │ │
│ │ - You want best of both worlds │ │
│ │ - You're starting a new project in 2025 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Runtime Dependency Sharing: Where Things Break
The most dangerous part of Module Federation is shared dependencies. Get it wrong, and you get runtime explosions, subtle bugs, or duplicate React instances.
The Singleton Problem
// The danger: React requires a single instance
// Multiple React instances = hooks don't work, context breaks
// ❌ BAD: Each remote bundles its own React
// checkout/webpack.config.js
{
shared: {
react: {}, // No singleton, no version constraint
}
}
// Result: checkout loads its own React copy
// Hooks called in checkout fail when rendered in shell
// ✅ GOOD: Force singleton with strict version
{
shared: {
react: {
singleton: true, // Only one instance globally
strictVersion: true, // Fail if versions incompatible
requiredVersion: '^18.2.0',
},
'react-dom': {
singleton: true,
strictVersion: true,
requiredVersion: '^18.2.0',
},
}
}
The Version Negotiation Dance
┌─────────────────────────────────────────────────────────────────────────────┐
│ DEPENDENCY VERSION NEGOTIATION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Scenario: Three apps with different React versions │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Shell │ │ Checkout │ │ Catalog │ │
│ │ React 18.2 │ │ React 18.1 │ │ React 18.3 │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └───────────────────┼───────────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Version │ │
│ │ Negotiation │ │
│ └────────┬────────┘ │
│ │ │
│ ┌─────────────────────────┼─────────────────────────────────────────┐ │
│ │ │ │ │
│ │ With strictVersion: false (default) │ │
│ │ ───────────────────────────────────────── │ │
│ │ 1. Shell loads first, provides React 18.2 │ │
│ │ 2. Checkout loads, sees 18.2 ≥ 18.1, uses shared │ │
│ │ 3. Catalog loads, sees 18.2 < 18.3, loads OWN copy │ │
│ │ │ │
│ │ Result: TWO React instances! Catalog is broken. │ │
│ │ │ │
│ │ With strictVersion: true │ │
│ │ ───────────────────────────────────────── │ │
│ │ 1. Shell loads first, provides React 18.2 │ │
│ │ 2. Checkout loads, 18.1 compatible with 18.2, uses shared │ │
│ │ 3. Catalog loads, 18.3 > 18.2, ERROR thrown │ │
│ │ │ │
│ │ Result: Build/runtime fails fast, you fix it. │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Shared Dependency Configuration Patterns
// webpack.config.js - Production-grade shared config
const packageJson = require('./package.json');
const sharedDependencies = {
// Strict singletons - must be exactly one instance
react: {
singleton: true,
strictVersion: true,
requiredVersion: packageJson.dependencies.react,
},
'react-dom': {
singleton: true,
strictVersion: true,
requiredVersion: packageJson.dependencies['react-dom'],
},
'react-router-dom': {
singleton: true,
strictVersion: true,
requiredVersion: packageJson.dependencies['react-router-dom'],
},
// State management - singleton to share state
zustand: {
singleton: true,
requiredVersion: packageJson.dependencies.zustand,
},
'@tanstack/react-query': {
singleton: true,
requiredVersion: packageJson.dependencies['@tanstack/react-query'],
},
// UI libraries - can be shared but not strictly singleton
'@radix-ui/react-dialog': {
singleton: false, // Multiple versions OK
requiredVersion: packageJson.dependencies['@radix-ui/react-dialog'],
},
// Utilities - eager load to avoid async chunk issues
lodash: {
singleton: false,
eager: true, // Load immediately, not on demand
},
};
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'myApp',
shared: sharedDependencies,
}),
],
};
Debugging Shared Dependency Issues
// Debug utility to inspect shared scope
// Add this to your shell's entry point in development
if (process.env.NODE_ENV === 'development') {
// @ts-ignore
window.__FEDERATION_DEBUG__ = true;
// Log shared scope after init
setTimeout(() => {
// @ts-ignore
const sharedScope = __webpack_share_scopes__.default;
console.group('Module Federation Shared Scope');
Object.entries(sharedScope).forEach(([name, versions]) => {
console.group(name);
Object.entries(versions as Record<string, any>).forEach(
([version, info]) => {
console.log(`${version}:`, {
loaded: info.loaded,
eager: info.eager,
from: info.from,
});
}
);
console.groupEnd();
});
console.groupEnd();
}, 2000);
}
// Common issues and their symptoms:
//
// "Invalid hook call" error:
// → Multiple React instances loaded
// → Check: Are all remotes using singleton: true for react?
//
// "Cannot read property 'Provider' of undefined":
// → Context not shared properly
// → Check: Is the context library marked as singleton?
//
// Styles not applying / CSS conflicts:
// → CSS modules not scoped properly
// → Solution: Use unique prefixes per remote
//
// "Shared module is not available for eager consumption":
// → Async boundary issue
// → Solution: Make bootstrap async (see below)
The Async Bootstrap Pattern
// The problem: Webpack tries to load shared deps synchronously
// but Module Federation requires async initialization
// ❌ BAD: Direct entry point
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
// Error: Shared module is not available for eager consumption
// ✅ GOOD: Async bootstrap
// index.ts (entry point)
import('./bootstrap');
// 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 this works:
// 1. index.ts is synchronous, just imports bootstrap
// 2. Dynamic import() allows async module resolution
// 3. Module Federation initializes shared scope before bootstrap runs
// 4. bootstrap.tsx can now use shared React safely
Versioning Contracts Between Teams
The hardest part of micro-frontends isn't technical—it's coordination. How do teams agree on shared dependency versions?
The Contract Model
┌─────────────────────────────────────────────────────────────────────────────┐
│ DEPENDENCY CONTRACT ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Central Contract Package │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ @company/federation-contracts │ │
│ │ ├── package.json (peer deps = the contract) │ │
│ │ ├── shared-config.js (webpack shared config) │ │
│ │ ├── types/ (shared TypeScript interfaces) │ │
│ │ └── CHANGELOG.md (version history) │ │
│ │ │ │
│ │ // package.json │ │
│ │ { │ │
│ │ "name": "@company/federation-contracts", │ │
│ │ "version": "2.0.0", │ │
│ │ "peerDependencies": { │ │
│ │ "react": "^18.2.0", │ │
│ │ "react-dom": "^18.2.0", │ │
│ │ "react-router-dom": "^6.20.0", │ │
│ │ "@tanstack/react-query": "^5.0.0", │ │
│ │ "zustand": "^4.4.0" │ │
│ │ } │ │
│ │ } │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Each micro-frontend │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ // package.json │ │
│ │ { │ │
│ │ "dependencies": { │ │
│ │ "@company/federation-contracts": "^2.0.0" │ │
│ │ } │ │
│ │ } │ │
│ │ │ │
│ │ // webpack.config.js │ │
│ │ const { sharedConfig } = require('@company/federation-contracts');│ │
│ │ │ │
│ │ module.exports = { │ │
│ │ plugins: [ │ │
│ │ new ModuleFederationPlugin({ │ │
│ │ shared: sharedConfig, // Use central config │ │
│ │ }), │ │
│ │ ], │ │
│ │ }; │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Implementing the Contract Package
// packages/federation-contracts/src/shared-config.ts
import type { SharedConfig } from 'webpack/lib/container/ModuleFederationPlugin';
// Version source of truth
export const SHARED_VERSIONS = {
react: '^18.2.0',
'react-dom': '^18.2.0',
'react-router-dom': '^6.20.0',
'@tanstack/react-query': '^5.0.0',
zustand: '^4.4.0',
'date-fns': '^3.0.0',
} as const;
// Webpack shared config
export const sharedConfig: SharedConfig = {
react: {
singleton: true,
strictVersion: true,
requiredVersion: SHARED_VERSIONS.react,
},
'react-dom': {
singleton: true,
strictVersion: true,
requiredVersion: SHARED_VERSIONS['react-dom'],
},
'react-router-dom': {
singleton: true,
strictVersion: true,
requiredVersion: SHARED_VERSIONS['react-router-dom'],
},
'@tanstack/react-query': {
singleton: true,
requiredVersion: SHARED_VERSIONS['@tanstack/react-query'],
},
zustand: {
singleton: true,
requiredVersion: SHARED_VERSIONS.zustand,
},
'date-fns': {
singleton: false,
requiredVersion: SHARED_VERSIONS['date-fns'],
},
};
// Validation function for CI
export function validateDependencies(
packageJson: { dependencies?: Record<string, string> }
): { valid: boolean; errors: string[] } {
const errors: string[] = [];
for (const [dep, requiredVersion] of Object.entries(SHARED_VERSIONS)) {
const installedVersion = packageJson.dependencies?.[dep];
if (!installedVersion) {
errors.push(`Missing required dependency: ${dep}`);
continue;
}
// Check semver compatibility
if (!semverSatisfies(installedVersion, requiredVersion)) {
errors.push(
`${dep}: installed ${installedVersion}, required ${requiredVersion}`
);
}
}
return { valid: errors.length === 0, errors };
}
CI Validation
# .github/workflows/validate-contracts.yml
name: Validate Federation Contracts
on:
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Validate federation contracts
run: |
node -e "
const { validateDependencies } = require('@company/federation-contracts');
const packageJson = require('./package.json');
const result = validateDependencies(packageJson);
if (!result.valid) {
console.error('Federation contract violations:');
result.errors.forEach(e => console.error(' - ' + e));
process.exit(1);
}
console.log('All federation contracts satisfied!');
"
Version Upgrade Strategy
┌─────────────────────────────────────────────────────────────────────────────┐
│ CONTRACT UPGRADE PROCESS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Phase 1: Announce (Week 1) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ - Create RFC for version bump │ │
│ │ - Document breaking changes │ │
│ │ - Set deadline for migration │ │
│ │ - Publish beta version of contracts package │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Phase 2: Parallel Support (Week 2-3) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ - Shell supports BOTH versions │ │
│ │ - Remotes migrate at their own pace │ │
│ │ - CI allows both contract versions │ │
│ │ │ │
│ │ // shell/webpack.config.js │ │
│ │ shared: { │ │
│ │ react: { │ │
│ │ singleton: true, │ │
│ │ // Accept both 18.2.x and 18.3.x during migration │ │
│ │ requiredVersion: '^18.2.0 || ^18.3.0', │ │
│ │ }, │ │
│ │ } │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Phase 3: Deprecate (Week 4) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ - CI warns on old version │ │
│ │ - Document stragglers │ │
│ │ - Offer migration support │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Phase 4: Enforce (Week 5) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ - CI fails on old version │ │
│ │ - Remove parallel support from shell │ │
│ │ - Publish final contracts package │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Production Architecture
The Complete Stack
┌─────────────────────────────────────────────────────────────────────────────┐
│ PRODUCTION MICRO-FRONTEND ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CDN Layer │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ CloudFront / Fastly / Cloudflare │ │
│ │ ├── shell.example.com → Shell static assets │ │
│ │ ├── checkout.example.com → Checkout remoteEntry + chunks │ │
│ │ ├── catalog.example.com → Catalog remoteEntry + chunks │ │
│ │ └── account.example.com → Account remoteEntry + chunks │ │
│ │ │ │
│ │ Cache strategy: │ │
│ │ - remoteEntry.js: short TTL (5 min) or no-cache │ │
│ │ - chunks.[hash].js: immutable, long TTL (1 year) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Remote Configuration │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Option A: Static remotes (simple) │ │
│ │ ┌───────────────────────────────────────────────────────────┐ │ │
│ │ │ // webpack.config.js │ │ │
│ │ │ remotes: { │ │ │
│ │ │ checkout: `checkout@${CHECKOUT_URL}/remoteEntry.js`, │ │ │
│ │ │ } │ │ │
│ │ │ // URLs from environment variables │ │ │
│ │ └───────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Option B: Dynamic remotes (flexible) │ │
│ │ ┌───────────────────────────────────────────────────────────┐ │ │
│ │ │ // Load remotes at runtime from config service │ │ │
│ │ │ const config = await fetch('/api/federation-config'); │ │ │
│ │ │ // { checkout: 'https://v2.checkout.example.com/...' } │ │ │
│ │ └───────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Shell Application │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Responsibilities: │ │
│ │ ├── Global navigation │ │
│ │ ├── Authentication state │ │
│ │ ├── Routing between micro-frontends │ │
│ │ ├── Error boundaries for remote failures │ │
│ │ ├── Shared state (user, cart, etc.) │ │
│ │ └── Feature flag distribution │ │
│ │ │ │
│ │ Does NOT: │ │
│ │ ├── Implement business features │ │
│ │ ├── Own domain-specific data │ │
│ │ └── Couple to remote implementation details │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Micro-Frontend (Remote) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Exposes: │ │
│ │ ├── Page components (full pages) │ │
│ │ ├── Widget components (embeddable) │ │
│ │ └── Shared hooks (optional) │ │
│ │ │ │
│ │ Standalone capable: │ │
│ │ ├── Can run independently for development │ │
│ │ ├── Has own routing for its pages │ │
│ │ └── Provides mock shell context for testing │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Shell Implementation
// shell/src/App.tsx
import { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ShellProvider } from './context/ShellContext';
import { Navigation } from './components/Navigation';
import { RemoteErrorFallback } from './components/RemoteErrorFallback';
import { PageSkeleton } from './components/PageSkeleton';
// Dynamic remote loading
const CheckoutFlow = lazy(() => import('checkout/CheckoutFlow'));
const ProductCatalog = lazy(() => import('catalog/ProductCatalog'));
const ProductDetail = lazy(() => import('catalog/ProductDetail'));
const AccountDashboard = lazy(() => import('account/Dashboard'));
const queryClient = new QueryClient();
export function App() {
return (
<QueryClientProvider client={queryClient}>
<ShellProvider>
<BrowserRouter>
<div className="app-layout">
<Navigation />
<main className="app-content">
<Routes>
{/* Shell-owned routes */}
<Route path="/" element={<HomePage />} />
{/* Catalog remote routes */}
<Route
path="/products"
element={
<RemoteWrapper name="catalog">
<ProductCatalog />
</RemoteWrapper>
}
/>
<Route
path="/products/:id"
element={
<RemoteWrapper name="catalog">
<ProductDetail />
</RemoteWrapper>
}
/>
{/* Checkout remote routes */}
<Route
path="/checkout/*"
element={
<RemoteWrapper name="checkout">
<CheckoutFlow />
</RemoteWrapper>
}
/>
{/* Account remote routes */}
<Route
path="/account/*"
element={
<RemoteWrapper name="account">
<AccountDashboard />
</RemoteWrapper>
}
/>
</Routes>
</main>
</div>
</BrowserRouter>
</ShellProvider>
</QueryClientProvider>
);
}
// Wrapper for remote components with error handling
function RemoteWrapper({
children,
name,
}: {
children: React.ReactNode;
name: string;
}) {
return (
<ErrorBoundary
FallbackComponent={({ error, resetErrorBoundary }) => (
<RemoteErrorFallback
remoteName={name}
error={error}
onRetry={resetErrorBoundary}
/>
)}
onError={(error) => {
// Log to monitoring
console.error(`Remote ${name} failed:`, error);
trackRemoteError(name, error);
}}
>
<Suspense fallback={<PageSkeleton />}>{children}</Suspense>
</ErrorBoundary>
);
}
Shell Context for Remotes
// shell/src/context/ShellContext.tsx
import { createContext, useContext, useMemo } from 'react';
import { useAuth } from '../hooks/useAuth';
import { useCart } from '../hooks/useCart';
import { useFeatureFlags } from '../hooks/useFeatureFlags';
// Contract: What shell provides to remotes
export interface ShellContextValue {
user: {
id: string;
email: string;
name: string;
roles: string[];
} | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
cart: {
items: CartItem[];
total: number;
itemCount: number;
};
addToCart: (product: Product, quantity: number) => void;
removeFromCart: (itemId: string) => void;
featureFlags: Record<string, boolean>;
hasFeature: (flag: string) => boolean;
// Navigation helpers
navigateTo: (path: string) => void;
showToast: (message: string, type: 'success' | 'error') => void;
}
const ShellContext = createContext<ShellContextValue | null>(null);
export function ShellProvider({ children }: { children: React.ReactNode }) {
const auth = useAuth();
const cart = useCart();
const flags = useFeatureFlags();
const navigate = useNavigate();
const { showToast } = useToast();
const value = useMemo<ShellContextValue>(
() => ({
user: auth.user,
isAuthenticated: auth.isAuthenticated,
login: auth.login,
logout: auth.logout,
cart: {
items: cart.items,
total: cart.total,
itemCount: cart.items.reduce((sum, i) => sum + i.quantity, 0),
},
addToCart: cart.addItem,
removeFromCart: cart.removeItem,
featureFlags: flags.all,
hasFeature: flags.has,
navigateTo: navigate,
showToast,
}),
[auth, cart, flags, navigate, showToast]
);
return (
<ShellContext.Provider value={value}>{children}</ShellContext.Provider>
);
}
// Export hook for remotes to use
export function useShell(): ShellContextValue {
const context = useContext(ShellContext);
if (!context) {
throw new Error(
'useShell must be used within ShellProvider. ' +
'If running standalone, wrap your app with MockShellProvider.'
);
}
return context;
}
Remote Implementation
// checkout/src/bootstrap.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { MockShellProvider } from './test/MockShellProvider';
import { CheckoutFlow } from './components/CheckoutFlow';
// Standalone mode for development
const isStandalone = !window.__SHELL_CONTEXT__;
function StandaloneApp() {
return (
<MockShellProvider>
<BrowserRouter basename="/checkout">
<Routes>
<Route path="/*" element={<CheckoutFlow />} />
</Routes>
</BrowserRouter>
</MockShellProvider>
);
}
if (isStandalone) {
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(<StandaloneApp />);
}
// checkout/src/components/CheckoutFlow.tsx
import { useShell } from '@company/shell-context'; // Shared package
export function CheckoutFlow() {
const { cart, user, isAuthenticated, navigateTo, showToast } = useShell();
if (!isAuthenticated) {
return <LoginPrompt onLogin={() => navigateTo('/login?redirect=/checkout')} />;
}
if (cart.items.length === 0) {
return <EmptyCart onContinueShopping={() => navigateTo('/products')} />;
}
return (
<CheckoutWizard
items={cart.items}
user={user}
onComplete={(orderId) => {
showToast('Order placed successfully!', 'success');
navigateTo(`/account/orders/${orderId}`);
}}
/>
);
}
Dynamic Remote Loading
// shell/src/utils/dynamicRemote.ts
import { init, loadRemote } from '@module-federation/runtime';
interface RemoteConfig {
name: string;
entry: string;
}
let initialized = false;
export async function initFederation(remotes: RemoteConfig[]) {
if (initialized) return;
init({
name: 'shell',
remotes: remotes.map((r) => ({
name: r.name,
entry: r.entry,
})),
shared: {
react: {
version: '18.2.0',
scope: 'default',
lib: () => require('react'),
shareConfig: { singleton: true, requiredVersion: '^18.2.0' },
},
'react-dom': {
version: '18.2.0',
scope: 'default',
lib: () => require('react-dom'),
shareConfig: { singleton: true, requiredVersion: '^18.2.0' },
},
},
});
initialized = true;
}
export async function loadRemoteComponent<T>(
remoteName: string,
moduleName: string
): Promise<T> {
const module = await loadRemote<{ default: T }>(
`${remoteName}/${moduleName}`
);
if (!module) {
throw new Error(`Failed to load ${remoteName}/${moduleName}`);
}
return module.default;
}
// Usage with React
import { lazy, Suspense } from 'react';
function createRemoteComponent<P>(remoteName: string, moduleName: string) {
return lazy(async () => {
const Component = await loadRemoteComponent<React.ComponentType<P>>(
remoteName,
moduleName
);
return { default: Component };
});
}
// Dynamic remote based on config
const CheckoutFlow = createRemoteComponent('checkout', 'CheckoutFlow');
Handling Remote Failures
// shell/src/components/RemoteErrorFallback.tsx
interface RemoteErrorFallbackProps {
remoteName: string;
error: Error;
onRetry: () => void;
}
export function RemoteErrorFallback({
remoteName,
error,
onRetry,
}: RemoteErrorFallbackProps) {
const isNetworkError =
error.message.includes('Failed to fetch') ||
error.message.includes('Load failed');
const isVersionError = error.message.includes('version');
return (
<div className="remote-error">
<h2>Something went wrong</h2>
{isNetworkError && (
<>
<p>Unable to load the {remoteName} module. This might be due to:</p>
<ul>
<li>Network connectivity issues</li>
<li>The service being temporarily unavailable</li>
</ul>
<button onClick={onRetry}>Try again</button>
</>
)}
{isVersionError && (
<>
<p>
There's a version mismatch. Please refresh the page to get the
latest version.
</p>
<button onClick={() => window.location.reload()}>
Refresh page
</button>
</>
)}
{!isNetworkError && !isVersionError && (
<>
<p>An unexpected error occurred loading {remoteName}.</p>
<button onClick={onRetry}>Try again</button>
<button onClick={() => (window.location.href = '/')}>
Go to homepage
</button>
</>
)}
{process.env.NODE_ENV === 'development' && (
<details>
<summary>Error details</summary>
<pre>{error.stack}</pre>
</details>
)}
</div>
);
}
Testing Strategies
// Testing remotes in isolation
// checkout/src/test/MockShellProvider.tsx
import { ShellContextValue } from '@company/shell-context';
const mockShellValue: ShellContextValue = {
user: {
id: 'test-user',
email: 'test@example.com',
name: 'Test User',
roles: ['customer'],
},
isAuthenticated: true,
login: jest.fn(),
logout: jest.fn(),
cart: {
items: [
{ id: '1', productId: 'prod-1', name: 'Test Product', quantity: 2, price: 29.99 },
],
total: 59.98,
itemCount: 2,
},
addToCart: jest.fn(),
removeFromCart: jest.fn(),
featureFlags: { newCheckout: true },
hasFeature: (flag) => mockShellValue.featureFlags[flag] ?? false,
navigateTo: jest.fn(),
showToast: jest.fn(),
};
export function MockShellProvider({
children,
overrides = {},
}: {
children: React.ReactNode;
overrides?: Partial<ShellContextValue>;
}) {
const value = { ...mockShellValue, ...overrides };
return (
<ShellContext.Provider value={value}>
{children}
</ShellContext.Provider>
);
}
// checkout/src/components/CheckoutFlow.test.tsx
import { render, screen } from '@testing-library/react';
import { MockShellProvider } from '../test/MockShellProvider';
import { CheckoutFlow } from './CheckoutFlow';
describe('CheckoutFlow', () => {
it('shows login prompt when not authenticated', () => {
render(
<MockShellProvider overrides={{ isAuthenticated: false, user: null }}>
<CheckoutFlow />
</MockShellProvider>
);
expect(screen.getByText(/sign in to continue/i)).toBeInTheDocument();
});
it('shows empty cart message when cart is empty', () => {
render(
<MockShellProvider
overrides={{
cart: { items: [], total: 0, itemCount: 0 },
}}
>
<CheckoutFlow />
</MockShellProvider>
);
expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
});
it('renders checkout wizard with cart items', () => {
render(
<MockShellProvider>
<CheckoutFlow />
</MockShellProvider>
);
expect(screen.getByText('Test Product')).toBeInTheDocument();
expect(screen.getByText('$59.98')).toBeInTheDocument();
});
});
Integration Testing
// e2e/checkout.spec.ts (Playwright)
import { test, expect } from '@playwright/test';
test.describe('Checkout Integration', () => {
test.beforeEach(async ({ page }) => {
// Start from shell
await page.goto('/');
// Login
await page.click('[data-testid="login-button"]');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password');
await page.click('[type="submit"]');
});
test('completes checkout flow', async ({ page }) => {
// Add product to cart (catalog remote)
await page.goto('/products/test-product');
await page.click('[data-testid="add-to-cart"]');
// Navigate to checkout (checkout remote)
await page.click('[data-testid="cart-icon"]');
await page.click('[data-testid="checkout-button"]');
// Verify checkout loaded
await expect(page.locator('[data-testid="checkout-flow"]')).toBeVisible();
// Complete checkout
await page.fill('[name="address"]', '123 Test St');
await page.fill('[name="card"]', '4242424242424242');
await page.click('[data-testid="place-order"]');
// Verify redirect to order confirmation (account remote)
await expect(page).toHaveURL(/\/account\/orders\/\w+/);
await expect(page.locator('[data-testid="order-success"]')).toBeVisible();
});
test('handles remote failure gracefully', async ({ page }) => {
// Block checkout remote
await page.route('**/checkout.example.com/**', (route) => route.abort());
await page.goto('/checkout');
// Should show error fallback
await expect(page.locator('[data-testid="remote-error"]')).toBeVisible();
await expect(page.locator('text=Unable to load')).toBeVisible();
});
});
Monitoring and Observability
// shell/src/utils/monitoring.ts
import * as Sentry from '@sentry/react';
// Track remote loading performance
export function trackRemoteLoad(
remoteName: string,
startTime: number,
success: boolean,
error?: Error
) {
const duration = performance.now() - startTime;
// Custom metric
performance.measure(`remote-load-${remoteName}`, {
start: startTime,
duration,
});
// Send to analytics
analytics.track('remote_loaded', {
remote: remoteName,
duration,
success,
error: error?.message,
});
// Sentry transaction
const transaction = Sentry.startTransaction({
name: `remote.load.${remoteName}`,
op: 'resource.script',
});
transaction.setData('duration', duration);
transaction.setData('success', success);
if (error) {
transaction.setStatus('internal_error');
Sentry.captureException(error, {
tags: { remote: remoteName },
});
}
transaction.finish();
}
// Wrapper for remote loading with monitoring
export function withRemoteMonitoring<T>(
remoteName: string,
loader: () => Promise<T>
): () => Promise<T> {
return async () => {
const startTime = performance.now();
try {
const result = await loader();
trackRemoteLoad(remoteName, startTime, true);
return result;
} catch (error) {
trackRemoteLoad(remoteName, startTime, false, error as Error);
throw error;
}
};
}
// Usage
const CheckoutFlow = lazy(
withRemoteMonitoring('checkout', () => import('checkout/CheckoutFlow'))
);
Production Checklist
┌─────────────────────────────────────────────────────────────────────────────┐
│ MICRO-FRONTEND PRODUCTION CHECKLIST │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Architecture │
│ □ Shell owns navigation, auth, and global state │
│ □ Remotes are independently deployable │
│ □ Contract package defines shared dependencies │
│ □ CI validates contract compliance │
│ □ Remotes can run standalone for development │
│ │
│ Dependency Management │
│ □ All singletons (React, router) marked as singleton: true │
│ □ strictVersion: true for critical deps │
│ □ Async bootstrap pattern implemented │
│ □ Version negotiation tested across remotes │
│ │
│ Resilience │
│ □ Error boundaries around all remote components │
│ □ Loading states for async remote loading │
│ □ Fallback UI for remote failures │
│ □ Retry mechanism for transient failures │
│ □ Graceful degradation strategy │
│ │
│ Performance │
│ □ remoteEntry.js has short cache TTL │
│ □ Chunks have content hashes and long cache TTL │
│ □ Critical remotes preloaded │
│ □ Non-critical remotes lazy loaded │
│ □ Bundle size monitoring per remote │
│ │
│ Testing │
│ □ Remotes tested in isolation with mock shell │
│ □ Integration tests cover cross-remote flows │
│ □ Contract tests validate shared type compatibility │
│ □ E2E tests run against production-like federation │
│ │
│ Deployment │
│ □ Each remote has independent CI/CD │
│ □ Blue-green or canary deployment per remote │
│ □ Rollback strategy for each remote │
│ □ Feature flags for gradual rollout │
│ │
│ Monitoring │
│ □ Remote load times tracked │
│ □ Remote failure rates tracked │
│ □ Dependency version mismatches alerted │
│ □ Bundle size regressions alerted │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Common Pitfalls and Solutions
┌─────────────────────────────────────────────────────────────────────────────┐
│ COMMON PITFALLS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. "Invalid hook call" error │
│ ───────────────────────────────────────── │
│ Cause: Multiple React instances │
│ Fix: Ensure singleton: true and matching versions │
│ │
│ 2. Styles bleeding between remotes │
│ ───────────────────────────────────────── │
│ Cause: Global CSS conflicts │
│ Fix: CSS Modules, unique prefixes, or CSS-in-JS │
│ │
│ 3. Shared state not updating │
│ ───────────────────────────────────────── │
│ Cause: Different state library instances │
│ Fix: Mark state libs as singletons, use shell context │
│ │
│ 4. "Shared module not available for eager consumption" │
│ ───────────────────────────────────────── │
│ Cause: Synchronous import of shared deps │
│ Fix: Async bootstrap pattern │
│ │
│ 5. Stale remote after deploy │
│ ───────────────────────────────────────── │
│ Cause: Cached remoteEntry.js │
│ Fix: Short TTL or no-cache for remoteEntry.js │
│ │
│ 6. Development is slow │
│ ───────────────────────────────────────── │
│ Cause: Running full federation locally │
│ Fix: Standalone mode, mock shell provider │
│ │
│ 7. TypeScript errors for remote imports │
│ ───────────────────────────────────────── │
│ Cause: No type definitions for dynamic imports │
│ Fix: Shared types package, module declarations │
│ │
│ // types/remotes.d.ts │
│ declare module 'checkout/CheckoutFlow' { │
│ const CheckoutFlow: React.ComponentType<CheckoutFlowProps>; │
│ export default CheckoutFlow; │
│ } │
│ │
│ 8. Can't debug remote code │
│ ───────────────────────────────────────── │
│ Cause: Source maps not configured │
│ Fix: devtool: 'source-map' for remotes │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Bottom Line
Module Federation enables micro-frontends that actually work, but only if you:
-
Use it for the right reasons: Organizational scaling, not technical novelty. If you don't have multiple teams needing independent deployment, you don't need micro-frontends.
-
Get shared dependencies right: Singletons for React/router, strict versions, async bootstrap. Most runtime failures trace back to dependency sharing issues.
-
Define contracts, not implementations: Central contract package for versions, CI validation, coordinated upgrades. Teams can move independently within the contract.
-
Design for failure: Every remote load can fail. Error boundaries, fallbacks, retries, and graceful degradation are mandatory.
-
Keep the shell thin: Navigation, auth, and context—nothing more. The shell is infrastructure, not a feature owner.
The goal isn't micro-frontends. The goal is teams that can ship independently. Module Federation is one tool to get there. Use it when the organizational structure demands it, implement it with discipline, and monitor relentlessly.
What did you think?