Module Federation
The Problem Module Federation Solves
You've got a micro frontend architecture where each application is built and deployed independently. Great. But they all share common dependencies — React, design system components, utility libraries. Without a sharing mechanism, every micro frontend ships its own copy. Five micro frontends means five copies of React in the user's browser. That's absurd.
Build-time solutions (npm packages, monorepo shared libraries) force you to rebuild and redeploy the consumer when the shared code changes. That defeats the whole purpose of independent deployment.
Module Federation solves this elegantly: applications share JavaScript modules at runtime, without rebuilding the consumers. App A exposes a component. App B loads it at runtime, using the live version, with no npm install or build step required.
Think of Module Federation like a library system between office buildings. Each building (application) has its own bookshelf (bundled code). But they also have access to a shared catalog (the federation manifest). When building A needs a book (module) from building B, it checks the catalog, requests it over the intercom (runtime HTTP request), and gets the exact edition it needs. If building B updates the book, building A gets the new version on the next request — no need to restock its own shelves.
Core Concepts: Host and Remote
Two roles to understand:
Remote: An application that exposes modules for other applications to consume.
Host: An application that consumes modules from remotes.
And here's the cool part — an application can be both. Your shell app might consume navigation from a remote while exposing an authentication module to other remotes.
// Remote: Product Catalog app (webpack.config.js)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'catalog',
filename: 'remoteEntry.js',
exposes: {
'./ProductCard': './src/components/ProductCard',
'./ProductGrid': './src/components/ProductGrid',
},
shared: {
react: { singleton: true, requiredVersion: '^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
},
}),
],
};
// Host: Shell application (webpack.config.js)
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
catalog: 'catalog@https://catalog.acme.com/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
},
}),
],
};
// Host: consuming the remote module
const ProductCard = React.lazy(() => import('catalog/ProductCard'));
function HomePage() {
return (
<Suspense fallback={<ProductCardSkeleton />}>
<ProductCard id="p-123" />
</Suspense>
);
}
Shared Dependencies: The Version Negotiation
This is where Module Federation gets really interesting — and where most bugs come from. The shared configuration tells Webpack which dependencies should be shared between host and remotes instead of duplicated.
shared: {
react: {
singleton: true, // Only one instance in the page
requiredVersion: '^19.0.0', // Must satisfy this range
eager: false, // Load lazily (default)
strictVersion: false, // Warn on mismatch, don't crash
},
'lodash': {
// No singleton — multiple versions can coexist
requiredVersion: '^4.17.0',
},
'@acme/design-system': {
singleton: true,
requiredVersion: '^3.0.0',
eager: true, // Load immediately, don't wait for async boundary
},
}
Key shared options
| Option | What it does | When to use |
|---|---|---|
singleton: true | Only one instance loaded, even if versions differ | Libraries with global state (React, React DOM, styled-components) |
eager: true | Bundled into the initial chunk, no async loading | The host app's entry point dependencies |
requiredVersion | Semver range the loaded version must satisfy | Always — prevents silent incompatibilities |
strictVersion: true | Throws an error instead of warning on version mismatch | Production safety for critical libraries |
Dynamic Remote Loading
What if you don't know all your remotes at build time? Static configuration won't cut it. Dynamic remotes are loaded at runtime, opening up powerful scenarios like plugin architectures or A/B testing different micro frontends.
// Load remote configuration from an API or config file
async function loadRemote(scope: string, module: string, url: string) {
// 1. Load the remoteEntry.js script dynamically
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => resolve();
script.onerror = reject;
document.head.appendChild(script);
});
// 2. Initialize the remote container
const container = (window as any)[scope];
await container.init(__webpack_share_scopes__.default);
// 3. Get the module factory
const factory = await container.get(module);
return factory();
}
// Usage
const ProductCard = React.lazy(async () => {
const module = await loadRemote(
'catalog',
'./ProductCard',
'https://catalog.acme.com/remoteEntry.js'
);
return { default: module.default };
});
Dynamic remotes can fail — network errors, version mismatches, the remote application is down. Every dynamic remote import must be wrapped in both a Suspense boundary (for loading) and an ErrorBoundary (for failures). Without error boundaries, a single broken remote crashes the entire host application.
Federation at Runtime vs Build Time
There's an important nuance to understand here: Module Federation operates at runtime, but the configuration is set at build time. This creates a real tension:
Build-time decisions:
- Which modules to expose/consume
- Shared dependency configuration
- The remote entry URL (unless using dynamic remotes)
Runtime decisions:
- Which version of a shared dependency actually loads
- Whether a remote is available or has been updated
- The actual module code (fetched on demand)
Module Federation 2.0 and Rspack
Module Federation 2.0 (available in Rspack and modern Webpack) adds runtime plugins, a manifest protocol for better version negotiation, and type safety across remote boundaries. The manifest replaces the opaque remoteEntry.js with a JSON manifest describing types, versions, and dependencies. This enables better tooling — TypeScript types can be generated from the manifest, giving you compile-time safety for cross-application imports.
Next.js with Module Federation
If you're using Next.js, things get a bit more involved. Next.js uses its own Webpack configuration, and integrating Module Federation requires the @module-federation/nextjs-mf plugin.
// next.config.js
const NextFederationPlugin = require('@module-federation/nextjs-mf');
module.exports = {
webpack(config, options) {
config.plugins.push(
new NextFederationPlugin({
name: 'shell',
remotes: {
catalog: `catalog@${process.env.CATALOG_URL}/remoteEntry.js`,
},
shared: {
react: { singleton: true, requiredVersion: false },
'react-dom': { singleton: true, requiredVersion: false },
},
filename: 'static/chunks/remoteEntry.js',
})
);
return config;
},
};
Key considerations for Next.js:
- Server Components cannot be federated — Module Federation operates in the browser
- The remote entry must be available at a stable URL (CDN recommended)
- SSR requires separate federation configuration for server and client builds
- Pages using dynamically loaded federated modules cannot be statically generated — they need runtime resolution. Module Federation 2.0's manifest protocol may enable build-time resolution in some cases
Pitfalls That Break Production
These are the ones that will cost you a weekend. Pay attention.
Version Mismatches
Remote A ships with design-system@3.2.0. Remote B ships with design-system@3.0.1. If design-system has breaking changes between 3.0 and 3.2, one of them will break — whichever loads second gets the other's version.
Fix: Pin shared dependency versions strictly in CI. Run compatibility tests that load all remotes together before deploying any individual remote.
Shared State Leaks
If two remotes share a singleton library that has internal state (e.g., a theme provider, a query client), they share that state too. Remote A's TanStack Query cache becomes visible to Remote B.
Fix: Share stateless libraries as singletons. For stateful libraries, either don't share them (accept the duplication cost) or explicitly initialize shared instances in the host and pass them down.
CSS Conflicts
Module Federation shares JavaScript, not CSS. Two remotes with conflicting .button styles will clash.
Fix: CSS Modules (class name hashing), CSS-in-JS with unique prefixes, or Shadow DOM encapsulation. Tailwind CSS works well because utility classes are deterministic and conflict-free.
The most insidious Module Federation bug: a remote updates a shared dependency, and the new version loads in production before the host has been tested against it. Singleton shared dependencies mean the host now runs with a dependency version it was never built or tested with. Always gate remote deployments behind integration tests that run the host with the updated remote's shared versions.
Alternatives to Module Federation
Module Federation isn't the only game in town. Depending on your constraints, something simpler might work just as well.
Import Maps (Browser Native)
<script type="importmap">
{
"imports": {
"react": "/shared/react@19.0.0.js",
"@acme/header": "https://header.acme.com/dist/index.js"
}
}
</script>
Import maps are simpler but offer no version negotiation, no shared scope management, and no build-tool integration. Good for simple cases with strict version alignment.
Native ESM Federation
Load remote ES modules directly via dynamic import() from a URL. No Webpack plugin needed, but no shared dependency deduplication either.
const { Header } = await import('https://header.acme.com/dist/header.js');
Works today in all modern browsers. The trade-off is that each module must bundle everything it needs or rely on import maps for shared deps.
- 1Module Federation enables runtime code sharing between independently built applications — the key enabler for true micro frontend architecture.
- 2Remotes expose modules via remoteEntry.js. Hosts consume them via dynamic import. An app can be both host and remote.
- 3Shared singleton dependencies (React, React DOM) must be configured consistently across all federated apps to prevent duplicate instances.
- 4Dynamic remote loading enables plugin architectures but requires error boundaries and Suspense boundaries at every remote import.
- 5Shared stateful singletons (query clients, theme providers) leak state across micro frontends — share stateless libraries, not stateful ones.
- 6Always run integration tests with all remotes loaded together before deploying — a remote's dependency update can break the host at runtime.
- 7For simple cases where all apps use identical dependency versions, browser import maps are simpler than Module Federation.
Q: You're architecting a micro frontend platform with 6 teams. Three use React 19, two use React 18, and one uses Svelte. How would you configure Module Federation's shared dependencies?
A strong answer: React cannot be a singleton across React 18 and 19 — they have different APIs and internals. Configure React as a shared singleton within each version group: the three React 19 apps share one instance, the two React 18 apps share another. The Svelte app doesn't use React at all and bundles its own framework. Use requiredVersion: '~19.0.0' (tilde, not caret) for tight control within the React 19 group, and requiredVersion: '~18.2.0' for the React 18 group. The host application should use the majority version (React 19) and load React 18 apps with singleton: false so they can bring their own version.