(... In Part-1, we established the foundation with definition of MFE and the basic of Module Federation.)
Let's understand how the remoteEntry.js file exposes a remote app from the shell with some real code.
We'll do this in 3 layers:
1) Remote app config
2) WhatremoteEntry.jsexposes
3) Shell app runtime code
1) Remote app Module Federation config
//mfe/webpack.config.js
const {ModuleFederationPlugin} = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "cartApp" //global container name
filename: "remoteEntry.js" //this will be loaded by the shell
exposes: {
"./CartWidget" : "./src/CartWidget.js" //exposed metadata
shared: {
react: {singleton: true },
"react-dom": {singleton: true }
}
})
]
};
Let's understand the plugin keys exposes and shared.
-
exposes:- Purpose: declares what modules this remote makes available to others
- Use small composable public names like
./Header,./routes - Keep internal implementation hidden and only expose contracts.
-
shared:- Purpose: negotiate shared dependencies and avoid duplicate of shared libraries like React.
- Implemented as a per-module
sharedscope where providers(remote) register available versions and consumers(shell) request versions -
singleton: trueensures a single instance across containers (use for React, React DOM etc.) -
requiredVersionallows consumer to express the semantic version it expects -
strictVersionfails the negotiation if the version cannot be satisfied (dangerous for progressive migration). -
eager: trueloads the shared module immediately (useful for small libs), but increases initial payload.
So, with this config, the remote deploys remoteEntry.js and exposes the module, but can still build independently.
2) What remoteEntry.js exposes
After remoteEntry.js loads in browser, Webpack registers a container(a JS object) under a global name which looks like this:
window.cartApp = {
// metadata about exposed modules
get: async (moduleName) => {
// returns a FACTORY, not the module itself
},
init: async (shareScope) => {
// registers shared dependencies into global scope
}
};
3) Shell app runtime code
The remoteEntry file can be loaded on the host in 2 different ways: static and dynamic.
- Dynamic loading
Shell webpack config
// shell/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "hostApp",
remotes: {}, // 👈 NOTHING here
shared: {
react: { singleton: true },
"react-dom": { singleton: true }
}
})
]
};
Here the host bundle has no knowledge of the remotes.
Let's look at the the core of the dynamic federation, runtime loader
loadRemote.js
// shell/src/loadRemote.js
//idempotent script injection
export function loadRemoteEntry(remoteUrl, scope) {
return new Promise((resolve, reject) => {
if (window[scope]) return resolve(); // already loaded
const script = document.createElement("script");
script.src = remoteUrl;
script.type = "text/javascript";
script.async = true;
script.onload = () => {
console.log(`${scope} remote loaded`);
resolve();
};
script.onerror = () => {
reject(new Error(`Failed to load remoteEntry: ${remoteUrl}`));
};
document.head.appendChild(script);
});
}
// turns the loaded remote app into an actual usable module
export async function loadRemoteModule(remoteUrl, scope, module) {
// 1. Load remoteEntry.js
await loadRemoteEntry(remoteUrl, scope);
// 2. Initialize shared scope
await __webpack_init_sharing__("default");
// 3. Initialize the remote container
const container = window[scope];
await container.init(__webpack_share_scopes__.default);
// 4. Get module factory
const factory = await container.get(module);
// 5. Execute factory
return factory();
}
The first function loads a dynamic remoteEntry.js file by doing a script injection(ensures that a remote’s remoteEntry.js is loaded exactly once, even if multiple parts of the application attempt to load it, preventing duplicate execution and runtime conflicts) & the second function makes the remote usable by exposing the module.
Let's understand the details of the second function.
await loadRemoteEntry(..)loads the remoteEntry file using the remoteUrl produced during the build__webpack_init_sharing__and__webpack_share_scopes__are provided by Webpack's runtime.await __webpack_init_sharing__('default')ensures host shares are initializedconst container = window[scope]is the container registered by remoteEntrycontainer.init(...)registers the remote's shared modules__webpack_init_sharing__('default')must be called beforecontainer.init(...)to ensure host registered share scope exists, preventing undefined scope issues
In simple words:
- Host says: “Here is the shared dependency table”
- Remote says: “Cool, I’ll register what I can share”
-
Webpack decides:
- which React version to use
- ensures singleton rules
container.get(module)returns a factory, which on invoked returns the actual module(component/object)Static loading
Shell application declares the remote at build time
// shell/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "hostApp",
remotes: {
// 👇 STATIC binding (known at build time)
cartApp: "cartApp@http://localhost:3001/remoteEntry.js"
},
shared: {
react: { singleton: true },
"react-dom": { singleton: true }
}
})
]
};
Here the **remotes** property helps the host reference an external container.
The host already knows the remote name and URL and Webpack bakes the remote mapping into the host bundle.
Then, it can be imported like a local module
// shell/src/App.js
import CartWidget from "cartApp/CartWidget";
export default function App() {
return (
<div>
<h1>Host Application</h1>
<CartWidget />
</div>
);
}
Important things to note:
- This import is not resolved at build time
- It is transformed by Webpack into a runtime fetch from remoteEntry
- Webpack automatically performs shared scope initialisation, container.init(), container.get() and factory execution that we saw earlier in dynamic loading behind the scenes
So basically, in the background the import is rewritten by Webpack to this:
await __webpack_init_sharing__("default");
const container = await loadRemoteContainer("cartApp");
await container.init(__webpack_share_scopes__.default);
const factory = await container.get("./CartWidget);
const CartWidget = factory();
Practical MFE wiring architecture patterns
1) Simple host + static remotes
- Use when remote URLs are stable and managed tightly
- Shell webpack
remotespoints to fixed URLs- Low runtime complexity but also, low deployment flexibility
2) Host + dynamic remotes via manifest
- Shell at runtime fetches a manifest JSON mapping remote name to URL
- Host uses the loader to inject remoteEntry.js
- Gives per-environment control and allows independent deployments and releases.
Practical checklist for converting a monolith
- Pick a simple host that will control routing and layout
- Identify vertical slices(a single page or a low traffic route) as the first remote
- Make the shell load remoteEntry dynamically from a staging manifest
- Declare shared dependencies as singletons
- Smoke test integration of shell and remotes
- Ensure remotes publish a small contract file(exposed modules and versions) & shell CI validates compatibility
- Feature-flag route to new remote rollout
- Iterate the process by extracting more MFEs and investing in platform automation
To wrap it up, micro-frontends are not a silver bullet, but an organisational scaling tool disguised as a frontend architecture.
When teams, domains, and deployment velocity outgrow a single SPA, MFEs combined with Webpack Module Federation provide a pragmatic way to regain autonomy without sacrificing runtime cohesion. Used thoughtfully, they shift frontend engineering from “shipping bundles” to composing systems at runtime.
Used prematurely, they simply move complexity around and make your life harder.
As with most architectural decisions, the real win comes not from the tooling, but from aligning it with organisational structure, ownership, and long-term business scale.
Top comments (0)