Module Federation is the secret sauce that makes truly independent micro-frontends possible in React.
Let’s see it in action. Imagine we have a Host application and a Remote application. The Host will dynamically load a component from the Remote at runtime.
First, our Host’s webpack.config.js:
// webpack.config.js for Host
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react'],
},
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
new ModuleFederationPlugin({
name: 'host', // This application's name
remotes: {
// 'remote_app_name': 'remote_app_name@http://localhost:3001/remoteEntry.js'
remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js',
},
shared: {
react: {
singleton: true, // Ensure only one version of React is loaded
requiredVersion: '^18.2.0',
},
'react-dom': {
singleton: true,
requiredVersion: '^18.2.0',
},
},
}),
],
devServer: {
port: 3000, // Port for the Host
static: {
directory: path.join(__dirname, 'dist'),
},
},
};
And our Remote application’s webpack.config.js:
// webpack.config.js for Remote
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react'],
},
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
new ModuleFederationPlugin({
name: 'remoteApp', // This application's name
filename: 'remoteEntry.js', // The entry point for this remote
exposes: {
// './path/to/expose': './src/path/to/component.js'
'./MyButton': './src/components/MyButton.js',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.2.0',
},
'react-dom': {
singleton: true,
requiredVersion: '^18.2.0',
},
},
}),
],
devServer: {
port: 3001, // Port for the Remote
static: {
directory: path.join(__dirname, 'dist'),
},
},
};
Now, in the Host’s src/App.js, we can dynamically import the MyButton component from remoteApp:
// src/App.js for Host
import React, { Suspense } from 'react';
import './App.css';
const MyButton = React.lazy(() => import('remoteApp/MyButton'));
function App() {
return (
<div className="App">
<h1>Host App</h1>
<Suspense fallback={<div>Loading...</div>}>
<MyButton />
</Suspense>
</div>
);
}
export default App;
And in the Remote’s src/components/MyButton.js:
// src/components/MyButton.js for Remote
import React from 'react';
const MyButton = () => {
return <button onClick={() => alert('Hello from Remote!')}>Click Me (Remote)</button>;
};
export default MyButton;
When you run both Host and Remote applications (e.g., npm start in each directory), the Host will fetch remoteEntry.js from http://localhost:3001, parse its exposed modules, and dynamically load MyButton. The Suspense boundary handles the loading state, showing "Loading…" until the remote component is ready.
The core problem Module Federation solves is the "dependency hell" and tight coupling that arises when building larger applications with multiple independent teams or when you want to share components across different applications without publishing them as separate npm packages. It allows you to treat code from other applications as if it were part of your own, but with runtime isolation and dynamic loading.
Internally, Webpack’s ModuleFederationPlugin creates a remoteEntry.js file for each federated application. This file acts as a manifest, listing the modules that the application exposes and providing instructions on how to load them. The remotes configuration in the consuming application’s ModuleFederationPlugin tells Webpack where to find these remoteEntry.js files and how to map the exposed module names (like remoteApp/MyButton) to their actual locations. The shared configuration is crucial for managing dependencies. When multiple federated applications might depend on the same library (e.g., React), shared ensures that only one version of that library is loaded and shared between them. This dramatically reduces the overall bundle size and avoids version conflicts.
The name property in ModuleFederationPlugin is not just an identifier; it’s the global variable name under which the remote’s remoteEntry.js will be registered in the browser’s window object. This global registration is how the host application discovers and accesses the exposed modules of the remote.
One detail often overlooked is how Module Federation handles asynchronous loading. When you use React.lazy(() => import('remoteApp/MyButton')), you’re not just importing a module; you’re initiating a network request to fetch the code for MyButton (and any of its dependencies that aren’t already shared). Webpack, configured with Module Federation, intercepts this import() call, resolves remoteApp/MyButton against the remotes configuration, and asynchronously loads the necessary JavaScript chunks. The Suspense component then gracefully handles the loading state until the remote module is fully evaluated and ready for rendering.
The next step is managing more complex routing and state sharing between these federated applications.