Module Federation is the magic behind modern micro-frontend architectures in Webpack, allowing independent applications to dynamically share code at runtime.
Let’s see Module Federation in action. Imagine two separate applications: host and remote.
remote application (e.g., a shared component library):
webpack.config.js (for the remote app):
const { ModuleFederationPlugin } = require('webpack');
const path = require('path');
module.exports = {
entry: './src/index.js',
mode: 'development',
output: {
publicPath: 'http://localhost:3001/dist/', // URL where this app's bundles will be served
filename: 'remoteEntry.js',
path: path.resolve(__dirname, 'dist'),
},
plugins: [
new ModuleFederationPlugin({
name: 'remote_app', // A unique name for this remote
filename: 'remoteEntry.js', // The entry point file name for this remote
exposes: {
'./Button': './src/components/Button.js', // Expose a component named 'Button'
},
}),
],
};
src/components/Button.js (for the remote app):
import React from 'react';
export default function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>;
}
host application (e.g., the main application that uses shared components):
webpack.config.js (for the host app):
const { ModuleFederationPlugin } = require('webpack');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/index.js',
mode: 'development',
output: {
publicPath: 'http://localhost:3000/', // The base URL for this host app
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
new ModuleFederationPlugin({
name: 'host_app', // A unique name for this host
remotes: {
remote_app: 'remote_app@http://localhost:3001/dist/remoteEntry.js', // Map the remote_app name to its entry point
},
shared: {
react: { singleton: true, eager: true }, // Share React, ensuring only one version is loaded
'react-dom': { singleton: true, eager: true },
},
}),
],
};
src/index.js (for the host app):
import React from 'react';
import ReactDOM from 'react-dom';
const RemoteButton = React.lazy(() => import('remote_app/Button')); // Dynamically import the exposed 'Button' from 'remote_app'
function App() {
return (
<div>
<h1>Host App</h1>
<React.Suspense fallback={<div>Loading...</div>}>
<RemoteButton onClick={() => alert('Clicked!')}>
Click Me (from Remote)
</RemoteButton>
</React.Suspense>
</div>
);
}
ReactDOM.render(<App />, document.getElementById('root'));
When you run both applications (e.g., webpack serve for each, ensuring remote runs on port 3001 and host on port 3000), the host app will dynamically load remoteEntry.js from the remote app. This remoteEntry.js contains the metadata and code for remote_app, allowing the host to resolve and load the Button component. The shared configuration ensures that if both host and remote depend on react, only one version is loaded and shared between them, preventing duplication and potential version conflicts.
Module Federation solves the problem of code sharing and dependency management in a micro-frontend architecture. It allows different applications, built and deployed independently, to dynamically load and use code from each other at runtime. This is achieved through a set of Webpack plugins and configurations. The ModuleFederationPlugin on the remote application declares what modules it exposes and gives itself a unique name. The ModuleFederationPlugin on the host application declares what remotes it wants to consume, mapping a name to the URL where the remote’s entry point (remoteEntry.js) can be found. Crucially, the shared configuration within the plugin allows for intelligent sharing of dependencies like React, ensuring that even if multiple micro-frontends rely on the same library, only a single instance is loaded into the browser, optimizing performance and memory usage.
The shared configuration is where a lot of the real power and complexity lies. By default, Module Federation attempts to share dependencies. However, you can fine-tune this behavior. For instance, setting singleton: true guarantees that only one instance of a shared module will be loaded across all federated applications. This is vital for libraries like React or Redux where multiple instances would break functionality. The eager: true option, on the other hand, forces the shared module to be loaded immediately with the initial chunk, rather than being loaded on demand when first used. This can be beneficial for critical shared dependencies where you want to avoid potential loading delays, but it increases the initial download size.
One aspect that often trips people up is how Module Federation handles versioning of shared dependencies. If your host app requires React v18 and a remote app exposes a component that was built with React v17, you’ll likely encounter runtime errors. The singleton: true option helps enforce a single version, but it’s up to you to ensure compatibility. The best practice is to define your shared dependencies and their versions in a central place, perhaps a monorepo or a dedicated shared package, and ensure all applications consuming those dependencies adhere to those versions. When Module Federation encounters a shared dependency, it checks if a compatible version is already loaded. If it is, it uses that instance. If not, and if the remote provides a compatible version, it will load that. If versions are incompatible, it will try to load the version specified by the host application.
The next step in mastering micro-frontends with Module Federation is understanding how to handle routing and state management across these independently deployed applications.