Module Federation 2.0
Anyone running a growing React application as a monolith eventually struggles with long build times, deployment dependencies and team conflicts. Module Federation 2.0 solves these problems through true runtime composition: teams deploy their parts of the app independently, and the shell app dynamically composes everything together.
Table of Contents
- 1. What micro-frontends really solve
- 2. Module Federation 2.0. Concepts and what's new
- 3. Setting up the shell app: host configuration
- 4. Building and exposing a remote app
- 5. Shared dependencies: loading React only once
- 6. Type-safe remotes with TypeScript
- 7. Routing across micro-frontend boundaries
- 8. Error handling and fallbacks
- 9. Approaches compared side by side
- 10. Summary
- 11. FAQ
1. What micro-frontends really solve
The core problem of large frontend projects isn't missing technology, it's organizational coupling. When ten teams work on the same React codebase, every change becomes a coordination task. Deployments require all parties involved, a broken PR blocks everyone else, and build times explode because every CI pipeline compiles the entire project. Micro-frontends solve the organizational problem before it becomes a technical one: each team fully owns its part of the application, code, tests and deployment pipeline.
Module Federation is not simply code-splitting. In classic code-splitting, the build process decides which chunks are produced. With Module Federation, the runtime loader decides which remote apps are loaded, and from where. That means the remote app team and the shell app team can deploy completely independently. The shell asks at runtime for the current state of the remote, not for the state at build time. That is the fundamental difference from all build-time approaches such as NPM packages or monorepo builds.
2. Module Federation 2.0. Concepts and what's new
Module Federation 1.0 shipped with Webpack 5 and for the first time enabled true runtime composition of JavaScript modules across app boundaries. Version 2.0, released as @module-federation/enhanced, brings several important improvements: an improved type system for remote exports, a runtime plugin system that can adjust behavior without a rebuild, and a Vite-compatible implementation via @originjs/vite-plugin-federation. Also new is official support for dynamic remote registration at runtime, without any prior configuration in the build.
The basic concepts remain: a host (shell app) consumes remotes. A remote exposes components or modules via a remoteEntry.js file. Shared defines which dependencies (above all React and React DOM) are shared between all apps so that each runs only once in the browser. Module Federation 2.0 additionally introduces manifest files that let the shell discover available remotes at runtime, without hardcoded URLs in the build. This considerably simplifies dynamic scaling.
3. Setting up the shell app: host configuration
The shell app is the entry point for the user. It loads the global navigation, the routing and, dynamically at runtime, the individual remote apps. The shell's Webpack configuration uses ModuleFederationPlugin as a host and defines all known remotes with their URLs. In production environments these URLs come from environment variables or a service discovery endpoint, not from the build itself. The shell itself contains no domain code belonging to the remote teams; it is pure scaffolding.
One critical detail: the shell must boot its own app asynchronously so that the shared-dependencies system of the Module Federation runtime is fully initialized before anything is rendered. The usual trick is a bootstrap.tsx that is dynamically imported, while index.ts contains only that single dynamic import line. Anyone who forgets this gets runtime errors because React gets loaded twice, once by the shell, once by a remote.
// webpack.config.ts - Shell-App (Host) configuration
import { ModuleFederationPlugin } from '@module-federation/enhanced/webpack';
export default {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
// Remote URLs come from environment variables at runtime
remotes: {
catalogApp: `catalogApp@${process.env.CATALOG_URL}/remoteEntry.js`,
checkoutApp: `checkoutApp@${process.env.CHECKOUT_URL}/remoteEntry.js`,
accountApp: `accountApp@${process.env.ACCOUNT_URL}/remoteEntry.js`,
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
'react-router-dom': { singleton: true, requiredVersion: '^6.0.0' },
},
}),
],
};
// src/index.ts - async bootstrap trick (REQUIRED for shared deps)
import('./bootstrap');
// src/bootstrap.tsx - actual app entry
import React from 'react';
import { createRoot } from 'react-dom/client';
import { ShellApp } from './ShellApp';
const root = createRoot(document.getElementById('root')!);
root.render(<ShellApp />);
4. Building and exposing a remote app
Every remote app is a standalone React application. It can be developed, tested and deployed locally without the shell needing to exist. The remote's Webpack configuration defines which components or modules it exposes to the outside world. The keyword is exposes: it is a mapping from a public name to an internal file path. The remote app then builds a remoteEntry.js file that the host can load at runtime.
It's important that exposed components make no assumptions about the context they run in. They should not expect global CSS variables that are only defined in their own app, and no global state from a store instance that might not exist. Instead, they get everything they need via props or via explicitly shared dependencies (such as a shared React Query client). Well-defined interfaces, ideally with TypeScript and exported prop types, are the foundation of stable micro-frontend integration.
// webpack.config.ts - Catalog Remote-App configuration
import { ModuleFederationPlugin } from '@module-federation/enhanced/webpack';
export default {
plugins: [
new ModuleFederationPlugin({
name: 'catalogApp',
filename: 'remoteEntry.js',
// Public interface: what the shell can import from this remote
exposes: {
'./ProductList': './src/components/ProductList',
'./ProductDetail': './src/components/ProductDetail',
'./CategoryNav': './src/components/CategoryNav',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};
// src/components/ProductList.tsx - exposed component (standalone)
import React from 'react';
interface ProductListProps {
categoryId: string;
onProductSelect: (productId: string) => void;
}
// Clean props interface, no assumptions about parent context
export const ProductList: React.FC<ProductListProps> = ({
categoryId,
onProductSelect,
}) => {
// Component fetches its own data, fully autonomous
return <div className="catalog-product-list">{/* ... */}</div>;
};
export default ProductList;
5. Shared dependencies: loading React only once
The biggest risk with micro-frontends is loading React more than once. If the shell app and a remote app have different React instances, hook calls from the remote app look like foreign objects from the shell app's point of view, which leads to the notorious "Invalid hook call" error. The shared field in the Module Federation configuration prevents this: it shares the same React bundle across all apps that declare it. singleton: true enforces that only one instance ever exists, even if different remotes specify different minor versions.
Version conflicts are the most common operational problem here. If the shell wants React 18.2 and a remote only ships React 18.1, the singleton configuration decides which version wins. Through requiredVersion you can enforce minimum versions and get a warning in the browser console at runtime on violation. For production it's recommended to have a central shared-deps package in the monorepo that dictates versions for all apps, keeping the configuration consistent without copy-pasting between multiple webpack.config.ts files.
6. Type-safe remotes with TypeScript
The classic problem with Module Federation and TypeScript: the shell imports a component from a remote with import('catalogApp/ProductList'), but TypeScript has no idea what type that component is, since there is no node_modules/catalogApp at the time the shell is compiled. Module Federation 2.0 solves this with automatically generated type declaration files. The remote build produces a @mf-types.d.ts that the shell app can consume. The types thereby always stay in sync with the remote's actual export, without any manual writing of declarations.
For teams not yet on Module Federation 2.0, there is a proven workaround: a shared @types/remotes package in the monorepo that contains all remote interfaces as TypeScript declarations. When a remote interface changes, this package gets updated, and the shell app immediately gets error messages on the next type check if it uses an outdated API. This keeps type safety intact even though runtime and compile time are separate worlds.
7. Routing across micro-frontend boundaries
Routing is one of the trickiest questions in micro-frontend architectures. The recommended pattern with React Router 6: the shell app owns the top-level routes and renders the responsible remote app for each path prefix. The remote app receives a basename prop that tells it which path prefix it manages. Internally, the remote app then uses its own router, fully independent of the shell. Navigations within the remote stay local; navigations to other micro-frontends go through the shell.
For cross-remote navigation there are two strategies: either use native browser routing with window.history.pushState, which both apps listen to via popstate events. Or define a shared event bus as a shared dependency, a simple EventEmitter instance through which remotes communicate navigation intents without knowing directly about each other. The latter is more explicit and testable, but requires a defined contract between the teams.
// Shell: lazy-load remote components with Suspense + ErrorBoundary
import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
import { ErrorBoundary } from './components/ErrorBoundary';
// Dynamic imports from remote apps, resolved at runtime
const ProductList = lazy(() => import('catalogApp/ProductList'));
const ProductDetail = lazy(() => import('catalogApp/ProductDetail'));
const Checkout = lazy(() => import('checkoutApp/CheckoutFlow'));
const Account = lazy(() => import('accountApp/AccountDashboard'));
const RemoteFallback = () => (
<div className="remote-loading">Loading application area...</div>
);
export const ShellRouter: React.FC = () => (
<Routes>
<Route
path="/catalog/*"
element={
<ErrorBoundary fallback={<div>Catalog unavailable</div>}>
<Suspense fallback={<RemoteFallback />}>
<ProductList categoryId="root" onProductSelect={() => {}} />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="/checkout/*"
element={
<ErrorBoundary fallback={<div>Checkout unavailable</div>}>
<Suspense fallback={<RemoteFallback />}>
<Checkout />
</Suspense>
</ErrorBoundary>
}
/>
</Routes>
);
8. Error handling and fallbacks
A micro-frontend system must assume that individual remotes will be temporarily unreachable. This is not an exceptional state, it's normal operation in a distributed system. React's ErrorBoundary is the first line of defense: if a remote component throws while loading or rendering, the ErrorBoundary catches the error and renders a fallback. Without an ErrorBoundary, a remote error would take down the entire shell app, an unacceptable outage for something that actually only affects a small part of the app.
For loading remoteEntry.js itself, Module Federation 2.0 offers retry logic and the ability to configure alternative URLs. In practice it's advisable to monitor remote load times: if a remoteEntry.js takes more than a second to load, that's an early warning sign of deployment problems on the remote team's side. Alerting on these metrics prevents a remote deployment issue from only surfacing through user complaints.
9. Approaches compared side by side
There are several approaches to splitting a React application into independently deployable parts. Module Federation is the most powerful, but also the most complex. The choice depends on team size, deployment frequency and the amount of complexity you're willing to tolerate.
| Approach | Deployment | Type safety | Recommendation |
|---|---|---|---|
| Module Federation 2.0 | Fully independent | Automatic via @mf-types | 3+ teams, high deploy frequency |
| NPM packages (monorepo) | Build-time coupled | Full | 1 to 2 teams, infrequent releases |
| iframe composition | Independent | None | Legacy integration only |
| Web Components | Independent | Limited | Framework-agnostic remotes |
| Single-SPA | Independent | Manual | Mixed-framework environments |
Module Federation clearly wins on deployment independence and integration into the normal React development workflow. Remote components look like normal React components, just with a different import path. The price is build complexity: two Webpack configurations, two CI pipelines, and the need to coordinate shared dependency versions. For small teams without real deployment pressure, a monorepo with NPM packages is often the better choice.
Mironsoft
React architecture, micro-frontends and Module Federation
Splitting your React app into micro-frontends?
We analyze your existing React architecture, define sensible boundaries, and implement Module Federation, with type-safe integration and a CI pipeline for each team.
Architecture review
Define boundaries, analyze shared dependencies, evaluate team structure
Implementation
Build shell app, remote apps and type sharing with Module Federation 2.0
CI/CD per team
Independent deployment pipelines with type synchronization and integration tests
10. Summary
React micro-frontends with Module Federation 2.0 solve the organizational scaling problem of large frontend projects: teams deploy independently, types stay in sync thanks to automatically generated declarations, and the shell app composes everything at runtime without build-time coupling. The shell uses singleton: true for shared dependencies so that React exists exactly once in the browser. Remote apps expose clean, props-based interfaces without assumptions about the context. ErrorBoundaries and Suspense fallbacks keep the shell stable when a remote is temporarily unreachable.
The path to a production-ready micro-frontend architecture is iterative: set up the shell first, migrate one remote, establish the deployment process. Then gradually spin out further remotes once the team understands the workflow. The biggest risk is not the technology, it's an unclear boundary. A remote that expects too much from the shell's context is just as coupled as a monolith, only harder to debug.
Module Federation 2.0. The essentials at a glance
Async bootstrap
index.ts only dynamically imports bootstrap.tsx. Mandatory so that shared dependencies are correctly initialized before React starts.
Singleton React
singleton: true in every shared configuration. Prevents duplicate React instances and "Invalid hook call" errors at runtime.
ErrorBoundary + Suspense
Wrap every remote component in ErrorBoundary and Suspense. Remote failures must never bring down the shell.
Keep types in sync
Module Federation 2.0 generates @mf-types.d.ts automatically. Alternatively, maintain a shared @types/remotes package in the monorepo.