Sharing React Components Across Microfrontends with Webpack Module Federation
Webpack 5 Module Federation lets microfrontends share React components at runtime instead of bundling duplicates. A minimal host/remote example, verification steps, and the real trade-offs.
19 Aug 2025, 19:53 UTC

The duplication problem nobody budgets for
Split a frontend into independently deployable micro-apps and you quickly hit an awkward bill: every app ships its own copy of React, ReactDOM, your design system, and half of lodash. Three apps, three copies of React. Users download all of them, and worse, multiple React instances on one page can break hooks and context in confusing ways.
The traditional fix is publishing shared packages to npm and rebuilding every consumer on each update. That trades runtime duplication for build-time coupling — a release of the button library becomes a coordinated rebuild of everything that uses it.
Webpack 5's Module Federation offers a middle path: apps expose modules at runtime, and other apps consume them over HTTP, while libraries like React are loaded once and shared. This post walks through a minimal host/remote setup, then covers the trade-offs honestly, because there are real ones.
How Module Federation works
A remote app declares which modules it exposes (say, a Button component). A host app declares which remotes it wants to consume. At build time, each remote emits a small manifest file, conventionally remoteEntry.js. At runtime, the host fetches that file, and webpack's module loading machinery resolves imports like checkout/Button against it — on demand, not bundled into the host.
The shared option is the other half. Both sides declare React as a shared singleton, so whichever copy loads first is reused. That is what kills the duplicate-React problem.
Assumptions for what follows: webpack 5.x, React 18, and both apps served over HTTP during development. The config API has been stable across webpack 5 minor versions, but check your installed version's docs before copying into production.
A minimal worked example
The remote app exposes a button. In remote/webpack.config.js:
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
// ...entry, loaders, devServer on port 3001
plugins: [
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};The host consumes it. In host/webpack.config.js:
new ModuleFederationPlugin({
name: 'shell',
remotes: {
checkout: 'checkout@http://localhost:3001/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
});Then in the host's source, import it like a normal module, but lazily so webpack can fetch the remote first:
import React, { Suspense, lazy } from 'react';
const Button = lazy(() => import('checkout/Button'));
export default function App() {
return (
<Suspense fallback="Loading checkout...">
<Button label="Pay now" />
</Suspense>
);
}One gotcha: because the remote loads asynchronously, the host's entry file usually can't import React synchronously at the top level. The standard workaround is a two-file bootstrap — index.js does only import('./bootstrap'), and the real app lives in bootstrap.js. Without this you'll hit a "shared module is not available for eager consumption" error.
Verifying it actually worked
Run both dev servers (npm start in each folder; no special permissions needed, just two free ports). Then check three things:
- Open the host page and confirm the remote button renders and reacts to clicks — a rendered-but-dead component often means duplicated React.
- In the browser's Network tab, confirm
remoteEntry.jsloads from the remote's origin, and that the remote's component chunk loads only when the lazy import fires. - Filter the Network tab for react chunks: React should load once, not once per app. If you see it twice, your
sharedconfig or version ranges disagree between the two builds.
For a production check, run npm run build in the host and inspect the output — the host bundle should not contain React source if sharing is configured correctly. If host and remote sit on different origins, the remote's server must send CORS headers allowing the host origin, or the fetch of remoteEntry.js fails silently into a runtime error.
The trade-offs, stated plainly
Version alignment is your job now. singleton: true with mismatched versions (host on React 17, remote on 18) produces a console warning at best and subtle breakage at worst. Loose requiredVersion ranges are convenient until a remote deploys a breaking change the host never opted into.
Runtime coupling replaces build-time coupling. If the remote's server is down, the host's lazy import rejects. You need error boundaries around federated imports and a deployment story for the remote entry URL per environment — the hardcoded localhost:3001 above won't survive CI.
Type safety erodes. TypeScript knows nothing about checkout/Button without extra tooling (shared type declaration packages or federation-aware type plugins). Budget for that if your team relies on strict typing.
The honest summary: Module Federation pays off when teams genuinely deploy independently and shared dependency weight is a measured problem. For a single team shipping one product, an npm workspace is simpler and safer.
Where to start
Build the two-app example above locally first — remote on one port, host on another — and verify the single-React behavior in the Network tab before touching real code. Then decide deliberately: write down which libraries must be singletons, pick a versioning policy for exposed modules, and wrap every federated import in an error boundary from day one. The feature is solid; the failures come from skipping that discipline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.