Module Federation Guide

Webpack Module Federation — Complete Guide

Your Webpack 5 configuration handles entry points, loaders, and dev server setup. But the feature that makes micro frontends possible with Webpack is Module Federation — a Webpack 5 plugin that lets multiple independent builds share code at runtime without bundling them together.

This article covers how Module Federation works in a real-world micro frontend monorepo — host configuration, remote configuration, exposed modules, shared dependencies with the singleton pattern, how remoteEntry.js works under the hood, and the critical differences between local development and production setups.

This is Article 12 in the Micro Frontend Architecture series. If you haven't set up your Webpack 5 config yet, start with Webpack 5 Configuration for Micro Frontends.

Webpack Module Federation architecture showing host loading remote micro frontends

What is Webpack Module Federation?

Module Federation is a built-in Webpack 5 plugin (opens in a new tab) that allows separately built applications to share JavaScript modules at runtime. Instead of packaging everything into a single monolithic bundle, each micro frontend builds independently and exposes specific components that other apps can consume.

The key insight: Module Federation does not copy code between builds. It creates a runtime contract where one build can request modules from another build — and both builds negotiate which shared dependencies to use so nothing is duplicated in the browser.

Three core concepts make this work:

  1. Host — the main application shell that loads and orchestrates remote modules
  2. Remote — an independently deployed app that exposes modules for the host to consume
  3. Shared — dependencies that both host and remote agree to share at runtime instead of bundling separately

Module Federation vs other integration patterns. Unlike iframe-based or build-time integration, Module Federation shares actual JavaScript modules at runtime. Components from different MFEs render in the same DOM, share the same React instance, and can access the same Redux store. For a comparison of all patterns, see 5 Micro Frontend Integration Patterns.

Host Configuration — The Main App Shell

The host application defines which remotes it wants to consume and where to find them. The remotes object maps each MFE name to its remoteEntry.js URL — and this URL is completely different between local development and production.

apps/host/webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  mode: "development",
  entry: path.resolve(__dirname, "src", "index.js"),
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].bundle.js",
    publicPath: "/",
  },
  devServer: {
    port: 4000,
    hot: true,
    historyApiFallback: true,
    https: {
      key: "./certs/localhost.pem",
      cert: "./certs/localhost.pem",
    },
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "Host",
      remotes: {
        Products: "Products@https://localhost:4002/remoteEntry.js",
        Orders: "Orders@https://localhost:4004/remoteEntry.js",
        Inventory: "Inventory@https://localhost:4003/remoteEntry.js",
        Settings: "Settings@https://localhost:4009/remoteEntry.js",
        Analytics: "Analytics@https://localhost:4007/remoteEntry.js",
        Onboarding: "Onboarding@https://localhost:4001/remoteEntry.js",
        Pricing: "Pricing@https://localhost:4005/remoteEntry.js",
        Earnings: "Earnings@https://localhost:4006/remoteEntry.js",
        Reports: "Reports@https://localhost:4008/remoteEntry.js",
        Reviews: "Reviews@https://localhost:4011/remoteEntry.js",
        Support: "Support@https://localhost:4010/remoteEntry.js",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^18.3.1" },
        "react-dom": { singleton: true, requiredVersion: "^18.3.1" },
        "react-router-dom": { singleton: true, requiredVersion: "^7.1.5" },
        "@reduxjs/toolkit": { singleton: true, requiredVersion: "^2.6.0" },
        "react-redux": { singleton: true, requiredVersion: "^9.2.0" },
        "@myapp/store": { singleton: true, requiredVersion: "0.0.1" },
      },
    }),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, "src", "index.html"),
    }),
  ],
  optimization: {
    splitChunks: false,
  },
}

What Changes Between Local and Production

Config PropertyLocal DevelopmentProduction / Server
modedevelopmentproduction
publicPathhttps://localhost:PORT//mfe-name/
Remote URL (host)MFE@https://localhost:PORT/remoteEntry.jsMFE@/mfe-name/remoteEntry.js
devServerEnabled (HTTPS + CORS)Not included
splitChunksfalseEnabled (vendor splitting)
moduleIdsNot set (defaults to named)deterministic
runtimeChunkNot setsingle
filename[name].bundle.js[name].bundle.js
HTTPSSelf-signed certsHandled by reverse proxy
CORS headersAccess-Control-Allow-Origin: *Handled by reverse proxy

The biggest difference is the remote URLs. In local development, each MFE runs on a separate port (https://localhost:4002). In production, all MFEs are served from the same domain through a reverse proxy that routes /products/ to the Products MFE's built assets.

⚠️

publicPath must match the remote URL. If the host expects the Products remote at /products/remoteEntry.js, then the Products MFE's output.publicPath must be /products/. A mismatch causes chunk loading errors — the remote loads remoteEntry.js correctly but then requests chunks from the wrong path.

Remote Configuration — Exposing Modules

Each remote MFE uses the same ModuleFederationPlugin but with different options. Instead of defining remotes, it defines exposes — a map of module paths that the host can import.

apps/products/webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  mode: "development",
  entry: path.resolve(__dirname, "src", "index.js"),
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].bundle.js",
    publicPath: "https://localhost:4002/",
    clean: true,
  },
  devServer: {
    port: 4002,
    hot: true,
    historyApiFallback: true,
    https: {
      key: "./certs/localhost.pem",
      cert: "./certs/localhost.pem",
    },
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "Products",
      filename: "remoteEntry.js",
      exposes: {
        "./ProductList": "./src/components/ProductList",
        "./ProductCatalog": "./src/components/ProductCatalog",
        "./ProductImages": "./src/components/ProductImages",
        "./BulkUpload": "./src/components/BulkUpload",
        "./SingleUpload": "./src/components/SingleUpload",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^18.3.1" },
        "react-dom": { singleton: true, requiredVersion: "^18.3.1" },
        "react-router-dom": { singleton: true, requiredVersion: "^7.1.5" },
        "@myapp/store": { singleton: true, requiredVersion: "0.0.1" },
      },
    }),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, "src", "index.html"),
    }),
  ],
  optimization: {
    splitChunks: false,
  },
}

How Exposes Works

exposes-pattern.js
// Remote MFE — exposes configuration
new ModuleFederationPlugin({
  name: "Products",              // Container name — must be unique
  filename: "remoteEntry.js",    // Generated manifest file
  exposes: {
    // Key: module path that host imports
    // Value: local file path in the remote's source
    "./ProductList": "./src/components/ProductList",
    "./ProductCatalog": "./src/components/ProductCatalog",
    "./ProductImages": "./src/components/ProductImages",
    "./BulkUpload": "./src/components/BulkUpload",
    "./SingleUpload": "./src/components/SingleUpload",
  },
})

// Host imports these as:
// import("Products/ProductList")      → resolves to ./src/components/ProductList
// import("Products/ProductCatalog")   → resolves to ./src/components/ProductCatalog
// import("Products/BulkUpload")       → resolves to ./src/components/BulkUpload

The exposes object creates a mapping between external module names and internal file paths. When the host imports Products/ProductList, Webpack resolves it to ./src/components/ProductList inside the Products remote.

Naming Rules for Module Federation

naming-rules.js
// Module Federation naming rules:

// 1. Container name must be a valid JavaScript identifier
//    CORRECT: name: "Products"
//    WRONG:   name: "product-management"  (hyphens are invalid)

// 2. Container name must match the remote key in the host
//    Host:   remotes: { Products: "Products@/products/remoteEntry.js" }
//    Remote: name: "Products"
//    The format is: <importKey>: "<containerName>@<URL>"

// 3. Exposed module keys must start with "./"
//    CORRECT: exposes: { "./ProductList": "./src/components/ProductList" }
//    WRONG:   exposes: { "ProductList": "./src/components/ProductList" }

// 4. When importing, drop the "./" from the exposed key
//    exposes: { "./ProductList": "..." }
//    import("Products/ProductList")  ← no "./" here

All Exposed Modules in the Monorepo

Each remote MFE exposes specific components that the host loads on demand. Here is the complete mapping:

MFE NameExposed ModulesPort (Local)
Products./ProductList, ./ProductCatalog, ./ProductImages, ./BulkUpload, ./SingleUpload4002
Inventory./InventoryDashboard, ./BuyingDashboard, ./PurchaseOrders, ./Suppliers4003
Orders./OrdersDashboard, ./OrderManagement, ./Returns, ./Refunds, ./Shipping4004
Onboarding./OnboardingApp4001
Pricing./PricingDashboard4005
Earnings./EarningsDashboard4006
Analytics./AnalyticsDashboard4007
Reports./ReportsDashboard4008
Settings./SettingsDashboard4009
Support./SupportDashboard, ./Tickets, ./TicketDetail, ./Chat, ./HelpCenter4010
Reviews./ReviewsDashboard4011

Table of all micro frontend exposed modules with MFE names and ports

Each remote runs on its own port locally. Port 4000 is reserved for the host shell. Remotes use ports 4001-4011. In production, ports don't exist — the reverse proxy maps URL paths to each MFE's static assets.

Shared Dependencies — The Singleton Pattern

Shared dependencies are the most critical part of Module Federation configuration. Without proper sharing, each MFE bundles its own copy of React, Redux, and other libraries — causing crashes, bloated bundles, and subtle bugs.

shared-config.js
// Shared dependencies — same in both host and remotes
shared: {
  react: {
    singleton: true,           // Only ONE copy of React in the entire app
    requiredVersion: "^18.3.1", // Must satisfy this semver range
  },
  "react-dom": {
    singleton: true,
    requiredVersion: "^18.3.1",
  },
  "react-router-dom": {
    singleton: true,
    requiredVersion: "^7.1.5",
  },
  "@reduxjs/toolkit": {
    singleton: true,
    requiredVersion: "^2.6.0",
  },
  "react-redux": {
    singleton: true,
    requiredVersion: "^9.2.0",
  },
  "@myapp/store": {
    singleton: true,
    requiredVersion: "0.0.1",   // Monorepo package — exact version
  },
}

Why Each Dependency Is Shared

DependencysingletonrequiredVersionWhy Shared
reacttrue^18.3.1Multiple React copies cause hooks to crash
react-domtrue^18.3.1Must match react version exactly
react-router-domtrue^7.1.5One router manages all MFE routes
@reduxjs/toolkittrue^2.6.0One Redux store across all MFEs
react-reduxtrue^9.2.0Provider must wrap entire app once
@myapp/storetrue0.0.1Shared monorepo package — same store instance

What Happens Without singleton: true

singleton-warning.js
// What happens WITHOUT singleton: true
// Host loads React 18.3.1
// Remote loads its OWN copy of React 18.3.1

// Result: TWO React instances in the browser
// Error: "Invalid hook call. Hooks can only be called inside
//         the body of a function component."

// Fix: Add singleton: true to both host AND remote configs
shared: {
  react: { singleton: true, requiredVersion: "^18.3.1" },
  "react-dom": { singleton: true, requiredVersion: "^18.3.1" },
}

The singleton: true flag is non-negotiable for React. Without it, the host and remote each instantiate their own React module. React hooks maintain state in a module-level variable — two modules mean two separate state trees, which immediately breaks useState, useEffect, and every other hook.

Version Mismatch Behavior

version-mismatch.js
// Scenario: Host has React 18.3.1, Remote has React 17.0.2

// With singleton: true + requiredVersion: "^18.3.1"
// Webpack logs a WARNING at runtime:
// "Unsatisfied version 17.0.2 of shared singleton module react
//  (required ^18.3.1)"

// The remote STILL uses the host's React 18.3.1 (singleton wins)
// But the warning tells you the remote's package.json is outdated

// With strictVersion: true (optional — not used in React MFEs):
// Webpack throws a RUNTIME ERROR instead of a warning
// Use this for packages where version mismatch WILL break things
⚠️

requiredVersion is a safety net, not enforcement. When singleton: true, the first-loaded version wins regardless of requiredVersion. The version check only generates console warnings (or errors with strictVersion: true) — it does not prevent the app from loading. Keep all MFEs on the same version in your monorepo's root package.json to avoid these warnings entirely.

How remoteEntry.js Works

The filename: "remoteEntry.js" option in the remote config generates a small manifest file. This file is the entry point that the host uses to access the remote's exposed modules. Understanding this flow is essential for debugging.

remote-entry-flow.js
// What happens when the host loads a remote:

// 1. Host HTML loads — includes <script src="/products/remoteEntry.js">
//    This script registers the "Products" container globally

// 2. Host code calls: import("Products/ProductList")
//    Webpack intercepts this and calls: window.Products.get("./ProductList")

// 3. The remote container checks shared dependencies:
//    - Is React already loaded? Yes → use host's React (singleton)
//    - Is react-dom loaded? Yes → use host's react-dom
//    - Is @myapp/store loaded? Yes → use host's store

// 4. The remote container returns the ./ProductList chunk
//    Only the component code is downloaded — not React, not Redux

// 5. React.lazy() resolves the promise and renders the component

The key insight: remoteEntry.js is not the remote's entire bundle. It is a lightweight manifest that:

  1. Registers a global container (window.Products, window.Orders, etc.)
  2. Lists all exposed modules and their chunk files
  3. Lists all shared dependencies and their version requirements
  4. Provides a get() function that the host calls to retrieve specific modules

When the host calls import("Products/ProductList"), Webpack's runtime:

  • Calls window.Products.get("./ProductList")
  • The container checks if shared deps are already loaded (singleton negotiation)
  • Downloads only the chunk containing the ProductList component
  • Returns the module to React.lazy() for rendering

Loading Remote Modules in the Host

The host uses React.lazy() and Suspense to load remote components on demand. This is the same pattern you'd use for code splitting in a regular React app — Module Federation makes it work across build boundaries.

apps/host/src/App.jsx
// Host app — loading a remote module with React.lazy()
import React, { Suspense } from "react";

// Dynamic import — Webpack resolves "Products/ProductList"
// using the remote URL from ModuleFederationPlugin config
const ProductList = React.lazy(() => import("Products/ProductList"));
const OrdersDashboard = React.lazy(() => import("Orders/OrdersDashboard"));
const InventoryDashboard = React.lazy(() => import("Inventory/InventoryDashboard"));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/products" element={<ProductList />} />
        <Route path="/orders" element={<OrdersDashboard />} />
        <Route path="/inventory" element={<InventoryDashboard />} />
      </Routes>
    </Suspense>
  );
}

When a user navigates to /products, React.lazy triggers a dynamic import. Webpack intercepts import("Products/ProductList"), fetches the Products remote's remoteEntry.js (if not already loaded), negotiates shared deps, downloads the component chunk, and renders it — all transparently.

The Bootstrap Pattern — Why Your Entry Point Must Be Async

Module Federation requires an async boundary before any shared module is used. Without it, shared dependencies are consumed before Module Federation has a chance to negotiate which versions to use.

apps/host/src/index.js + bootstrap.js
// src/index.js — bootstrap pattern for Module Federation
import("./bootstrap");

// src/bootstrap.js — actual app entry point
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);

The import("./bootstrap") call creates an async chunk. Webpack uses this boundary to:

  1. Load remoteEntry.js files for all configured remotes
  2. Compare shared dependency versions between host and remotes
  3. Decide which copy of each shared dependency to use
  4. Then execute bootstrap.js with all shared deps resolved
⚠️

Every MFE needs this pattern — not just the host. If a remote MFE has its own HTML page for standalone development, it must also use the bootstrap pattern. Otherwise, running the remote standalone will crash with "Shared module is not available for eager consumption."

Common Errors and Solutions

common-errors.js
// ERROR 1: "Shared module is not available for eager consumption"
// Cause: You imported a shared module at the top level of index.js
//        Module Federation hasn't negotiated shared deps yet
// Fix: Use the bootstrap pattern
//   index.js → import("./bootstrap")
//   bootstrap.js → import React, render app

// ERROR 2: "ScriptExternalLoadError: Loading script failed"
// Cause: The remoteEntry.js URL is wrong or the remote isn't running
// Fix (local): Check that the remote dev server is running on the correct port
// Fix (prod): Check that publicPath matches the reverse proxy route

// ERROR 3: "Module './ProductList' does not exist in container"
// Cause: The exposed module key doesn't match what the host imports
// Fix: The host imports "Products/ProductList"
//      The remote must expose: { "./ProductList": "./src/components/ProductList" }
//      The "./" prefix is required in exposes but NOT in the import

// ERROR 4: "Invalid hook call" (multiple React instances)
// Cause: React is not configured as singleton in shared deps
// Fix: Add singleton: true to react AND react-dom in BOTH configs

// ERROR 5: Styles missing after loading remote
// Cause: Remote's CSS is not bundled or publicPath is wrong
// Fix: Ensure MiniCssExtractPlugin is configured and publicPath
//      points to the correct asset directory

Debugging Checklist

SymptomCheckFix
ScriptExternalLoadErrorIs the remote dev server running?Start the remote on the correct port
Module not found in containerDoes the exposes key match the import?Ensure ./ prefix in exposes, no ./ in import
Invalid hook callIs React configured as singleton?Add singleton: true to both host and remote
Chunks return 404Does publicPath match the serving path?Local: https://localhost:PORT/, Prod: /mfe-name/
Styles missing from remoteIs MiniCssExtractPlugin configured?See Tailwind CSS in MFE Monorepo
Eager consumption errorIs the bootstrap pattern used?Split index.js into index.js + bootstrap.js

Module Federation vs NextFederationPlugin

This guide covers Module Federation for React MFEs using webpack.container.ModuleFederationPlugin. If your host or remotes use Next.js, the setup is different — Next.js requires NextFederationPlugin from @module-federation/nextjs-mf, which handles SSR, different remote entry paths (_next/static/chunks/remoteEntry.js), and Next.js-specific configuration like basePath and assetPrefix. The Next.js Module Federation setup is covered in a dedicated article later in this series.

What's Next?

← Back to Tailwind CSS in Micro Frontend Monorepo

Continue to Host and Remote Apps in Module Federation →


Frequently Asked Questions

What is Webpack Module Federation?

Webpack Module Federation is a Webpack 5 feature that allows multiple independent builds to share code at runtime. Each build can expose modules that other builds consume — without bundling them together at build time. This is the core mechanism that makes micro frontend architecture work with Webpack.

What is the difference between host and remote in Module Federation?

The host is the main application shell that loads and renders remote modules. It defines which remotes to consume and at what URLs. The remote is an independently deployed application that exposes specific modules (components, utilities, pages) for the host to consume. A single app can be both a host and a remote simultaneously.

Why do I need singleton: true for React in shared dependencies?

Without singleton: true, each MFE loads its own copy of React. Multiple React instances cause the "Invalid hook call" error because React hooks rely on a single internal state tree. Setting singleton: true forces all MFEs to use the same React instance — the one loaded first by the host.

What is remoteEntry.js and how does it work?

remoteEntry.js is a manifest file generated by Module Federation's filename option. It registers a global container (e.g., window.Products) that the host uses to request exposed modules. When the host calls import('Products/ProductList'), Webpack uses this container to negotiate shared dependencies and download only the component code — not the entire remote bundle.

How do remote URLs change between local development and production?

In local development, remote URLs point to localhost with specific ports (e.g., Products@https://localhost:4002/remoteEntry.js). In production, they use relative paths served by a reverse proxy (e.g., Products@/products/remoteEntry.js). The publicPath in the remote's webpack config must match — https://localhost:4002/ locally vs /products/ in production.

What is the bootstrap pattern and why is it required?

The bootstrap pattern splits your entry point into two files: index.js calls import('./bootstrap'), and bootstrap.js contains the actual app code. This async boundary gives Module Federation time to negotiate shared dependencies before any shared module is used. Without it, you get the error "Shared module is not available for eager consumption."