Loading…
Loading…
How to break down monolithic frontend applications into scalable, independently deployable micro-frontends using Webpack's Module Federation.
Tags
Webpack 5's Module Federation lets you split a monolithic frontend into independently deployable applications that load code from each other at runtime. Each team owns a vertical slice of the product -- their own repo, build pipeline, and deployment schedule. The host application dynamically loads remote modules without requiring a rebuild. This guide covers the full configuration, shared dependency management, runtime integration patterns, and the communication strategies that make micro-frontends work in production.
Frontend monoliths hit a wall. When 15 developers push to the same repository, merge conflicts become daily friction. A CSS change in the checkout flow breaks the product page. The CI pipeline takes 20 minutes because it rebuilds everything. Nobody wants to deploy on Friday because one team's feature is not ready, and it blocks everyone else's.
Micro-frontends solve this the same way microservices solved backend monoliths: by drawing boundaries around business domains and letting teams move independently. The checkout team deploys three times a day. The product catalog team deploys weekly. Neither blocks the other.
Module Federation is the technology that makes this practical. Before it existed, micro-frontend solutions relied on iframes (terrible for UX), build-time package composition (no independent deployability), or custom runtime loaders (complex and brittle). Module Federation is a first-class Webpack feature that handles code sharing, version negotiation, and lazy loading out of the box.
Module Federation has three roles:
An application can be both a host and a remote simultaneously -- this is called a bidirectional setup.
The remote application exposes specific modules through its Webpack configuration:
// checkout-app/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
output: {
publicPath: 'https://checkout.example.com/',
},
plugins: [
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
'./CheckoutFlow': './src/components/CheckoutFlow',
'./CartSummary': './src/components/CartSummary',
'./useCart': './src/hooks/useCart',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};The filename specifies the manifest file that the host will fetch at runtime. The exposes map defines which modules are available to consumers. The shared configuration prevents loading React twice.
The host application declares where to find remote modules:
// shell-app/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
checkout: 'checkout@https://checkout.example.com/remoteEntry.js',
catalog: 'catalog@https://catalog.example.com/remoteEntry.js',
account: 'account@https://account.example.com/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};Loading remote components requires dynamic imports wrapped in React's Suspense and error boundaries:
// shell-app/src/App.tsx
import React, { Suspense, lazy } from 'react';
import ErrorBoundary from './components/ErrorBoundary';
const CheckoutFlow = lazy(() => import('checkout/CheckoutFlow'));
const ProductCatalog = lazy(() => import('catalog/ProductCatalog'));
const AccountDashboard = lazy(() => import('account/Dashboard'));
function App() {
return (
<div>
<Navigation />
<ErrorBoundary fallback={<FallbackUI />}>
<Suspense fallback={<LoadingSkeleton />}>
<Routes>
<Route path="/checkout/*" element={<CheckoutFlow />} />
<Route path="/products/*" element={<ProductCatalog />} />
<Route path="/account/*" element={<AccountDashboard />} />
</Routes>
</Suspense>
</ErrorBoundary>
</div>
);
}The error boundary is critical. If the checkout service is down, the rest of the application should still work. Without it, a single failed remote takes down the entire page.
For scenarios where remote URLs are not known at build time, you can load remotes dynamically:
// shell-app/src/utils/loadRemote.ts
async function loadRemoteModule(scope: string, module: string, url: string) {
await loadScript(url);
const container = window[scope];
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 ${url}`));
document.head.appendChild(script);
});
}This pattern enables configuration-driven architectures where a config service tells the shell which remotes to load based on feature flags, user roles, or A/B test assignments.
Shared dependencies are the trickiest part of Module Federation. Get them wrong and you either load React twice (doubling bundle size and breaking hooks) or get version conflicts at runtime.
shared: {
react: {
singleton: true, // Only one copy loaded, ever
requiredVersion: '^18.0.0',
eager: false, // Load lazily (recommended for remotes)
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
},
'@tanstack/react-query': {
singleton: true,
requiredVersion: '^5.0.0',
},
// Non-singleton: each remote can use its own version
'lodash': {
requiredVersion: '^4.17.0',
},
}The rule of thumb: anything that maintains global state (React, state management libraries, routing) must be a singleton. Utility libraries like lodash or date-fns can safely have multiple versions.
Micro-frontends need to communicate without creating tight coupling. There are three proven patterns:
Custom Events for loose coupling:
// In the checkout micro-frontend
function publishCartUpdate(cart: Cart) {
window.dispatchEvent(
new CustomEvent('cart:updated', { detail: cart })
);
}
// In the header micro-frontend
useEffect(() => {
const handler = (e: CustomEvent<Cart>) => {
setCartCount(e.detail.items.length);
};
window.addEventListener('cart:updated', handler);
return () => window.removeEventListener('cart:updated', handler);
}, []);Shared State via Props for parent-child relationships:
// Shell passes shared state down to remotes
<CheckoutFlow
user={currentUser}
onOrderComplete={(order) => navigate(`/orders/${order.id}`)}
/>Shared State Store for complex state that spans multiple remotes:
// shared-state/src/store.ts (deployed as its own federated module)
import { create } from 'zustand';
export const useGlobalStore = create((set) => ({
user: null,
theme: 'light',
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
}));Each micro-frontend gets its own CI/CD pipeline:
# checkout-app/.github/workflows/deploy.yml
name: Deploy Checkout
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
- run: npm run build
- run: aws s3 sync dist/ s3://mfe-checkout/ --delete
- run: aws cloudfront create-invalidation --distribution-id $CDN_ID --paths "/remoteEntry.js"Invalidating the remoteEntry.js file on the CDN is essential -- the host application fetches this file at runtime to discover the remote's module map.
Shared dependency version drift. If the checkout team upgrades to React 19 but the shell is still on React 18, the singleton constraint will force one version, potentially breaking the other. Establish a shared dependency contract and enforce it in CI.
CSS conflicts. Without isolation, styles from one micro-frontend leak into another. Use CSS Modules, CSS-in-JS with unique prefixes, or Shadow DOM for strict isolation.
No error boundaries. A single failing remote should not crash the entire application. Every remote import must be wrapped in an error boundary with a meaningful fallback.
Over-decomposition. Not every component needs to be a micro-frontend. If two "micro-frontends" are always deployed together and owned by the same team, they should be one application. The overhead of Module Federation is only justified when you need independent deployability.
Ignoring performance. Each remote adds a network request for its remoteEntry.js manifest and its chunks. Prefetch critical remotes and lazy-load non-critical ones. Monitor the waterfall in your browser DevTools.
Micro-frontends are the right choice when:
Stick with a monolith when:
Consider alternatives when:
Module Federation is a Webpack 5 feature that allows multiple independent builds to share code at runtime, enabling one application to dynamically load components or modules from another deployed application.
Micro-frontends are best suited for large organizations with multiple teams working on different business domains of the same application, where independent deployability and team autonomy are more important than simplicity.
Each team can deploy their domain (e.g., checkout, product catalog) independently without requiring other teams to rebuild or redeploy, dramatically reducing coordination overhead and deployment risk.
Collaboration
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI: let's bring your vision to life.
Learn how to design stable API contracts between Micro-Frontends and Backend-for-Frontend layers with versioning, ownership boundaries, error handling, and schema governance.
An architectural deep dive into using Next.js as a Backend-for-Frontend, including route handlers, server components, auth boundaries, caching, and service orchestration.
A practical guide to using Next.js Cache Components and Partial Prerendering in real applications, with tradeoffs, cache strategy, and freshness considerations.