Building Scalable Micro-Frontends with Module Federation
Learn how to build and scale micro-frontend architectures using Webpack Module Federation, sharing code efficiently across independent applications.
Introduction
Micro-frontends have revolutionized how we build large-scale web applications. By breaking down monolithic frontends into smaller, manageable pieces, teams can work independently, deploy faster, and scale more effectively.
What are Micro-Frontends?
Micro-frontends extend the concepts of microservices to the frontend world. Each team can develop, test, and deploy their features independently while maintaining a cohesive user experience.
Why Module Federation?
Webpack's Module Federation is a game-changer for micro-frontend architectures. It allows JavaScript applications to dynamically load code from another application at runtime.
Key Benefits
- Runtime Integration - Load remote modules on demand
- Code Sharing - Share dependencies between applications
- Independent Deployments - Deploy micro-frontends independently
- Version Management - Handle multiple versions gracefully
- Performance - Optimize bundle sizes automatically
Consider Your Team Size
Micro-frontends add complexity. They're best suited for large teams working on complex applications. For smaller projects, a monolithic approach might be more efficient.
Architecture Overview
Let's explore a typical micro-frontend architecture using Module Federation.
Host Application
The host (or shell) application orchestrates the micro-frontends. It provides:
- Shared layout and navigation
- Authentication and authorization
- Shared state management
- Common UI components
Remote Applications
Remote applications are independent micro-frontends that:
- Own specific feature domains
- Have their own build pipeline
- Can be deployed separately
- Expose components/modules to the host
Shared Dependencies
Configure shared libraries to avoid duplication:
- React/Vue/Angular
- UI component libraries
- Utility libraries
- State management tools
Implementation Guide
Project Structure
Configuration
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
productApp: 'productApp@http://localhost:3001/remoteEntry.js',
cartApp: 'cartApp@http://localhost:3002/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
'react-router-dom': { singleton: true },
},
}),
],
};const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'productApp',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/ProductList',
'./ProductDetail': './src/ProductDetail',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};declare module 'productApp/ProductList' {
const ProductList: React.ComponentType;
export default ProductList;
}
declare module 'productApp/ProductDetail' {
const ProductDetail: React.ComponentType<{ id: string }>;
export default ProductDetail;
}
declare module 'cartApp/Cart' {
const Cart: React.ComponentType;
export default Cart;
}Loading Remote Components
Dynamic Imports
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Dynamic imports for remote components
const ProductList = lazy(() => import('productApp/ProductList'));
const ProductDetail = lazy(() => import('productApp/ProductDetail'));
const Cart = lazy(() => import('cartApp/Cart'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/products" element={<ProductList />} />
<Route path="/products/:id" element={<ProductDetail />} />
<Route path="/cart" element={<Cart />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}import React, { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
}
class MicroFrontendErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Micro-frontend failed to load:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback || <div>Failed to load module</div>;
}
return this.props.children;
}
}
export default MicroFrontendErrorBoundary;export const LoadingSpinner = () => (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
export const ModuleLoader = () => (
<div className="p-8 text-center">
<div className="mb-4">
<LoadingSpinner />
</div>
<p className="text-gray-600">Loading module...</p>
</div>
);Error Handling Best Practice
Always wrap remote components in error boundaries. Network issues or deployment mismatches can cause runtime errors when loading remote modules.
State Management Strategies
Managing shared state across micro-frontends requires careful planning:
Browser Events
Use custom events for loose coupling:
// Publishing events
const event = new CustomEvent('cart:updated', {
detail: { itemCount: 5 }
});
window.dispatchEvent(event);
// Subscribing to events
window.addEventListener('cart:updated', (e) => {
console.log('Cart updated:', e.detail);
});Shared Store
For tightly coupled state, use a shared store:
// shared/store.ts
import create from 'zustand';
export const useGlobalStore = create((set) => ({
user: null,
setUser: (user) => set({ user }),
cart: [],
addToCart: (item) => set((state) => ({
cart: [...state.cart, item]
})),
}));Props Drilling
For simple parent-child communication:
<RemoteComponent
user={currentUser}
onUpdate={handleUpdate}
/>Deployment Strategies
Independent Deployment Pipeline
Build and Test
Each micro-frontend has its own CI/CD pipeline:
- Run unit tests
- Run integration tests
- Build production bundle
- Run E2E tests
Version and Deploy
Deploy with versioning strategy:
- Semantic versioning for remotes
- Update remote URLs in host
- Canary deployments supported
- Easy rollbacks
Monitor and Observe
Track performance and errors:
- Module loading times
- Failed chunk loading
- Error rates per micro-frontend
- User experience metrics
Version Compatibility
Test version compatibility carefully. A breaking change in a shared dependency can affect all micro-frontends. Consider using dependency version ranges wisely.
Performance Optimization
Code Splitting Strategies
// Split by routes - load only what's needed
const routes = [
{
path: '/products',
component: lazy(() => import('productApp/ProductPage')),
},
{
path: '/cart',
component: lazy(() => import('cartApp/CartPage')),
},
];// Split heavy components separately
const HeavyChart = lazy(() => import('./HeavyChart'));
const DataTable = lazy(() => import('./DataTable'));
function Dashboard() {
return (
<div>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={chartData} />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable data={tableData} />
</Suspense>
</div>
);
}// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
},
};Shared Dependencies
Optimize what you share to reduce bundle duplication:
shared: {
react: {
singleton: true,
eager: false,
requiredVersion: '^18.0.0'
},
'react-dom': {
singleton: true,
eager: false,
requiredVersion: '^18.0.0'
},
// Share only what's necessary
'lodash': {
singleton: false, // Allow multiple versions if needed
requiredVersion: false,
},
}Eager vs Lazy Loading
Use eager: true for critical shared modules that are always needed. Use eager: false (default) for modules loaded on demand to optimize initial bundle size.
Common Challenges & Solutions
Challenge 1: Styling Conflicts
Problem: CSS from different micro-frontends can conflict.
Solutions:
- Use CSS Modules or CSS-in-JS
- Adopt BEM naming convention
- Implement Shadow DOM
- Use scoped styles with frameworks
Challenge 2: Shared Dependencies Version Mismatch
Problem: Different micro-frontends need different library versions.
Solutions:
- Establish version governance
- Use singleton: false when necessary
- Implement graceful fallbacks
- Regular dependency updates
Challenge 3: Authentication and Authorization
Problem: Managing auth state across micro-frontends.
Solutions:
- Centralized auth in host app
- Share auth context via Module Federation
- Use JWT tokens in localStorage
- Implement refresh token rotation
Testing Strategies
Integration Testing
Test micro-frontend integration:
// Test remote loading
test('loads product micro-frontend', async () => {
render(<App />);
await waitFor(() => {
expect(screen.getByText('Products')).toBeInTheDocument();
});
});End-to-End Testing
Test complete user flows:
// Cypress E2E test
describe('Purchase Flow', () => {
it('completes purchase across micro-frontends', () => {
cy.visit('/products');
cy.get('[data-testid="product-1"]').click();
cy.get('[data-testid="add-to-cart"]').click();
cy.visit('/cart');
cy.get('[data-testid="checkout"]').click();
});
});Best Practices
- Keep Micro-Frontends Small - Each should have a clear, focused responsibility
- Minimize Coupling - Reduce dependencies between micro-frontends
- Share Strategically - Only share what's truly common
- Version Everything - Use semantic versioning for all remotes
- Monitor Performance - Track loading times and bundle sizes
- Document APIs - Clear contracts between micro-frontends
- Automate Testing - Comprehensive test coverage across boundaries
- Plan for Failure - Error boundaries and fallbacks everywhere
Start Small
Begin with a pilot project. Convert one feature to a micro-frontend, learn from the experience, then scale the approach across your application.
Conclusion
Micro-frontends with Module Federation offer a powerful architecture for scaling large applications. While they introduce complexity, the benefits of independent deployments, team autonomy, and improved scalability make them worthwhile for the right use cases.
Remember: micro-frontends are a tool, not a goal. Evaluate whether your team size and application complexity justify the additional overhead.
Resources
- Webpack Module Federation Documentation
- Module Federation Examples
- Micro-Frontends.org
- Single-SPA Framework
Have questions about implementing micro-frontends? Let's discuss in the comments below!