Building a large web application often feels like trying to organize a city where every neighborhood speaks a different language. Each team builds its own section, and before you know it, you have duplicate code everywhere, massive bundles, and a tangled mess of dependencies. I’ve been there. The promise of micro-frontends—breaking that giant, monolithic interface into smaller, independent apps—is incredibly appealing. But for years, sharing code between these separate pieces was clumsy, often involving package publishing or copying files, which defeated the purpose of independent deployment.
Then Webpack 5 introduced something that changed the game: Module Federation. It’s a native way for one built and deployed application to use parts of another at runtime. Think of it as a dynamic linking system for the web. Your user’s browser becomes the integration point, pulling in components, utilities, or libraries from different sources on the fly. This means no more giant, all-in-one bundles, and teams can update their parts without forcing a rebuild of the entire world.
Let me show you how this works from the ground up. The core of everything is a simple change in your Webpack configuration. You define two roles: a “host” that consumes code, and a “remote” that provides it. A module can be both.
Here’s a remote application, like a shared design system, that wants to expose a Button and a Card component for others to use.
// webpack.config.js for the 'design_system' remote
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
// ... your standard webpack config (mode, entry, output)
plugins: [
new ModuleFederationPlugin({
name: 'design_system', // A unique identifier for this app
filename: 'remoteEntry.js', // The manifest file webpack creates
exposes: {
// Define what you want to share
'./Button': './src/components/Button.jsx',
'./Card': './src/components/Card.jsx',
'./theme': './src/theme/index.js'
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' }
}
})
]
};
When you build this, Webpack creates a remoteEntry.js file. This isn’t your whole app bundle; it’s a small runtime manifest that tells other applications, “Here are the modules I have available for you to load.” Now, let’s look at a host application that wants to use that Button.
// webpack.config.js for the 'product_app' host
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
// ... other webpack config
plugins: [
new ModuleFederationPlugin({
name: 'product_app',
remotes: {
// Map a friendly name to that remote's manifest file
ds: 'design_system@http://localhost:3001/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' }
}
})
]
};
The magic word here is remotes. I’m telling my host app, “Hey, there’s a remote named design_system, and you can find its entry point at this URL.” The shared section is equally important. It tells Webpack, “These libraries should be shared. If the remote already loaded React, use that same instance. Don’t load it twice.” The singleton: true enforces this, which is crucial for libraries like React that get unhappy if there are multiple copies on the page.
Now, in my host application’s React code, I can use that remote Button as if it were a local file.
// In product_app/src/ProductPage.jsx
import React, { Suspense, lazy } from 'react';
// Use a dynamic import pointing to the remote
const RemoteButton = lazy(() => import('ds/Button'));
function ProductPage() {
return (
<div>
<h1>Our Featured Product</h1>
<Suspense fallback={<div>Loading button from design system...</div>}>
<RemoteButton onClick={() => alert('Added!')}>
Buy Now
</RemoteButton>
</Suspense>
</div>
);
}
This is the key moment. When this code runs, the host application dynamically fetches the ds/Button module from the remote server at http://localhost:3001. It only loads the code for the Button, not the entire design system app. The Suspense boundary is necessary because this is a network request; we need to show a loading state while the code is being fetched.
This pattern changes how you think about dependencies. You can share more than just UI components. Let’s say you have a set of authentication utilities managed by a separate team.
// In an 'auth' remote's webpack config
new ModuleFederationPlugin({
name: 'auth',
filename: 'remoteEntry.js',
exposes: {
'./utils': './src/authUtils.js', // Expose functions
'./LoginForm': './src/components/LoginForm.jsx' // Expose a component
},
shared: { /* shared dependencies */ }
});
// In authUtils.js
export const validatePassword = (pw) => { /* complex logic */ };
export const formatUserProfile = (user) => { /* formatting logic */ };
// In your host app, using the utility
import('auth/utils').then(({ validatePassword }) => {
const isValid = validatePassword(userInput);
console.log(isValid);
});
One of the trickiest parts is managing state. How does a component from the design system remote interact with the user state managed in the host app? You don’t want tight coupling. A common approach is to create a dedicated remote for shared state or use patterns like passing props or using a global event system.
For instance, you could have a shared_state remote that exposes a lightweight state store.
// In a shared_state remote
// src/store.js
import { createStore } from 'redux';
const initialState = { user: null, notifications: [] };
const store = createStore(reducer, initialState);
export const getSharedState = store.getState;
export const dispatchSharedAction = store.dispatch;
export const subscribeToSharedState = store.subscribe;
// Expose it in webpack config: './store': './src/store.js'
// In a host app component
import React, { useEffect, useState } from 'react';
function UserHeader() {
const [sharedState, setSharedState] = useState({});
useEffect(() => {
// Dynamically load the shared state module
import('shared_state/store').then(({ getSharedState, subscribeToSharedState }) => {
setSharedState(getSharedState()); // Get initial state
// Subscribe to future changes
const unsubscribe = subscribeToSharedState(() => {
setSharedState(getSharedState());
});
return unsubscribe; // Cleanup on unmount
});
}, []);
return <header>Hello, {sharedState.user?.name}</header>;
}
This keeps your applications loosely coupled. The host app doesn’t need to know if the store is Redux, Zustand, or a simple event emitter; it just imports and uses the agreed-upon interface.
Development gets interesting. You now have multiple applications running on different ports. I find it easiest to use a tool like concurrently to start them all together.
// In your root package.json
{
"scripts": {
"dev": "concurrently \"npm run dev:host\" \"npm run dev:design\" \"npm run dev:auth\"",
"dev:host": "webpack serve --config webpack.host.js --port 8080",
"dev:design": "webpack serve --config webpack.design.js --port 8081",
"dev:auth": "webpack serve --config webpack.auth.js --port 8082"
}
}
You also need to configure your development servers to allow these cross-origin requests. It’s a simple header setting.
// Inside your webpack devServer config for the design system (port 8081)
devServer: {
port: 8081,
headers: {
"Access-Control-Allow-Origin": "*", // Allows requests from the host app on port 8080
},
// Hot Module Replacement (HMR) still works!
}
When it’s time to go to production, you face new questions. Where do you host these remote entry files? How do you handle version updates? The remote URL in your host configuration shouldn’t be hardcoded to localhost. You can make it configurable.
// A more dynamic host configuration
new ModuleFederationPlugin({
name: 'product_app',
remotes: {
ds: `design_system@${process.env.DESIGN_SYSTEM_URL}/remoteEntry.js`,
},
shared: { /* ... */ }
});
Then, you can set the DESIGN_SYSTEM_URL environment variable in your build pipeline to point to a CDN URL, like https://assets.yourcompany.com/design-system/v1.2.0/. This also gives you a clear versioning story. You can update the remote independently, and host apps will use the new version the next time they load the entry file.
You must plan for failure. What if the remote server is down? Using React’s Error Boundaries is a good practice to catch failed dynamic imports and show a friendly message or a fallback UI.
class RemoteModuleErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// Update state so the next render shows the fallback UI.
return { hasError: true };
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return <div>Could not load the required component. Please try again later.</div>;
}
return this.props.children;
}
}
// Using it in your app
function App() {
return (
<RemoteModuleErrorBoundary>
<Suspense fallback={<Spinner />}>
<ProductPage /> {/* Contains lazy remote imports */}
</Suspense>
</RemoteModuleErrorBoundary>
);
}
Performance is a major benefit, but you need to be smart. You don’t want to fetch a remote module only when a user clicks a button if that will cause a visible delay. Webpack allows you to add prefetch hints.
// Instead of just a dynamic import, you can prefetch during idle time
const RemoteButton = lazy(() => import(/* webpackPrefetch: true */ 'ds/Button'));
The browser will load the remoteEntry.js for the design system early, and then fetch the Button chunk when it has spare bandwidth, making the actual user interaction feel instantaneous.
Getting started can feel like a lot, but the payoff is huge. You enable teams to own their code from development to deployment, all while presenting a single, seamless application to your user. Bundle sizes shrink because shared libraries aren’t duplicated. Caching improves because a common library update only requires downloading one new shared chunk, not rebuilding every app. It finally makes the dream of independent, collaborative frontend development a practical reality.
Start small. Take a single, well-defined component or utility function and try to federate it. Once you see that code loading from another build into your app, the pieces will click into place. You’re not just sharing code; you’re building a connected ecosystem.