Step-by-Step Webpack 5 Container Configuration #

You need two independently built Webpack 5 apps to share live code at runtime: a host that loads a component from a remote, with React shared as a single instance so hooks don’t break.

This guide walks the full setup end to end. You will configure a remote that exposes a component, a host that consumes it, a shared scope that deduplicates React, and the async boundary that keeps the federation runtime from crashing on startup. Every step is a runnable webpack.config.js or source file you can paste into a real project.

Prerequisites #

This guide assumes a recent, stable Webpack 5 toolchain. Module Federation ships inside webpack itself — there is no separate plugin package to install.

A useful mental model before you start: the remote and host are separate builds that never share a bundler graph. They only meet at runtime, through a manifest file called remoteEntry.js. The host downloads that manifest, negotiates which copy of each shared library wins, and then pulls the exposed module on demand. The diagram below shows that handshake.

Webpack 5 host-remote runtime handshake The host loads the remote's remoteEntry manifest, both register React into a shared scope, and the host then imports the exposed component. Host (shell) port 3000 remotes: remoteApp React.lazy(import) Remote (remoteApp) port 3001 exposes: ./Widget remoteEntry.js Shared scope react / react-dom (singleton) 1. fetch remoteEntry.js 2. both register React, highest match wins 3. import remoteApp/Widget
The host fetches the remote's manifest, both apps register React into one shared scope, then the host imports the exposed component.

For the wider picture of when and why to wire apps this way, start from Configuring Webpack Module Federation.

Step 1 — Configure the remote #

The remote owns a component and publishes it under a stable name. Two settings make this work: name (the global the host will reference) and exposes (the public-to-host module map). The remote build emits remoteEntry.js as its manifest.

// remote/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    publicPath: 'auto',
    clean: true,
  },
  devServer: {
    port: 3001,
    headers: { 'Access-Control-Allow-Origin': '*' },
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: { loader: 'babel-loader', options: { presets: ['@babel/preset-react'] } },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'remoteApp',
      filename: 'remoteEntry.js',
      exposes: {
        './Widget': './src/Widget',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
  ],
};

Two things matter here. publicPath: 'auto' lets the browser resolve chunk URLs relative to wherever remoteEntry.js was actually served from — the foundation for automatic publicPath configuration for remotes. The dev-server CORS header lets a host on a different port read the manifest.

The exposed component itself is ordinary React. Nothing federation-specific leaks into it.

// remote/src/Widget.jsx
import React, { useState } from 'react';

export default function Widget() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount((c) => c + 1)}>
      Remote widget clicked {count} times
    </button>
  );
}

Step 2 — Configure the host #

The host declares which remotes it consumes via the remotes map. The key (remoteApp) is the import prefix; the value is <remoteName>@<url-to-remoteEntry>. The shared block must mirror the remote’s so React is negotiated, not duplicated.

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

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    publicPath: 'auto',
    clean: true,
  },
  devServer: {
    port: 3000,
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: { loader: 'babel-loader', options: { presets: ['@babel/preset-react'] } },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
    new HtmlWebpackPlugin({ template: './public/index.html' }),
  ],
};

Keep the shared config identical across both apps. When ranges drift, Webpack still loads — but it may load two Reacts, which is the topic of managing shared dependencies at runtime.

A note on how the negotiation actually works. At startup each app calls into the share scope and offers its own copy of every shared entry, tagged with the version from its package.json. With singleton: true, the runtime picks one winner — the highest version that satisfies every consumer’s requiredVersion range — and discards the rest. If no single version satisfies all ranges, the singleton constraint is violated and Webpack logs a console warning while falling back to whichever copy it can. That fallback is silent enough to ship by accident, so the version-range discipline in resolving version conflicts in shared React libraries is worth applying from day one. Setting eager: false (the default) is what lets the negotiation happen asynchronously; eager: true would force the module into the initial chunk and reintroduce the eager-consumption problem Step 3 solves.

Step 3 — Add the async bootstrap boundary #

This is the single most common thing people get wrong. The federation runtime needs a synchronous tick at startup to register shared modules before any shared module is imported. If your entry file imports React directly, that tick never happens and the build throws Shared module is not available for eager consumption.

The fix is a two-file split. The entry does nothing but import() a bootstrap file dynamically. That dynamic import creates the async boundary the runtime needs.

// host/src/index.js   (and remote/src/index.js — same pattern)
import('./bootstrap');
// host/src/bootstrap.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')).render(<App />);

Apply the same index.jsbootstrap.js split in the remote too, since it also lists React in shared. The remote’s bootstrap.js can render its own standalone shell during local development so the team can run the remote in isolation:

// remote/src/bootstrap.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import Widget from './Widget';

createRoot(document.getElementById('root')).render(<Widget />);

Step 4 — Add an error boundary for the remote #

A remote can fail in ways a local import never does: the dev server is down, the manifest 404s, or the chunk fails mid-download. Suspense only handles the pending state — it does not catch a rejected import(). Without an error boundary, a failed remote takes the whole host tree down with it. Wrap every remote in a class component that catches render and load errors and shows a fallback instead.

// host/src/RemoteErrorBoundary.jsx
import React from 'react';

export default class RemoteErrorBoundary extends React.Component {
  state = { failed: false };

  static getDerivedStateFromError() {
    return { failed: true };
  }

  componentDidCatch(error, info) {
    // Forward to your telemetry pipeline in production.
    console.error('Remote failed to load:', error, info);
  }

  render() {
    if (this.state.failed) {
      return this.props.fallback ?? <p>This section is temporarily unavailable.</p>;
    }
    return this.props.children;
  }
}

This boundary is the seam where you graft on instrumentation later — recording which remote failed and how often is the basis for error boundary telemetry for remote apps.

Step 5 — Consume the remote in the host #

Import the exposed module by its remotes key plus the exposed path: remoteApp/Widget. Because the remote is fetched over the network, load it lazily with React.lazy, suspend on the pending state, and wrap the whole thing in the error boundary from the previous step.

// host/src/App.jsx
import React, { Suspense } from 'react';
import RemoteErrorBoundary from './RemoteErrorBoundary';

const RemoteWidget = React.lazy(() => import('remoteApp/Widget'));

export default function App() {
  return (
    <main>
      <h1>Host shell</h1>
      <RemoteErrorBoundary fallback={<p>Widget unavailable.</p>}>
        <Suspense fallback={<p>Loading remote…</p>}>
          <RemoteWidget />
        </Suspense>
      </RemoteErrorBoundary>
    </main>
  );
}

The ordering matters: the error boundary must sit outside Suspense, because a rejected dynamic import surfaces as a thrown error that only the boundary can catch. For loading remotes whose URLs aren’t known at build time, see dynamically loading remote modules at runtime.

Step 6 — Run both dev servers #

Each app needs its own webpack-dev-server on its own port. The remote already sets Access-Control-Allow-Origin: * (Step 1) so the host can fetch its manifest cross-origin. Add an npm script to each package.json and a public/index.html with a single <div id="root"></div> mount point.

// host/package.json (and remote/package.json — change the message)
{
  "scripts": {
    "start": "webpack serve --mode development"
  }
}
# terminal 1 — start the remote first so its manifest exists
cd remote && npm start   # serves http://localhost:3001/remoteEntry.js

# terminal 2 — then start the host that consumes it
cd host && npm start     # serves http://localhost:3000

Start the remote before the host. If the host loads first, its initial import('remoteApp/Widget') rejects, the error boundary catches it, and you see the fallback until the remote comes up and you refresh. In production this ordering does not matter, because the remote’s remoteEntry.js is a static artifact served from a CDN rather than a live dev process.

Verification #

Start both dev servers (webpack serve in each app), open http://localhost:3000, and confirm the wiring:

// Browser console — confirm the shared runtime is wired up
typeof __webpack_share_scopes__ !== 'undefined' && __webpack_share_scopes__.default.react;
// → an object with a single resolved version entry, not two competing ones

Troubleshooting #

Symptom: Uncaught Error: Shared module is not available for eager consumption. Diagnosis: your entry file imports a shared module (React) synchronously, so it runs before the share scope is registered. Fix: apply the Step 3 split — make index.js only import('./bootstrap'), and move every real import into bootstrap.js. Do this in both apps.

Symptom: “Invalid hook call” / “Cannot read properties of null (reading ‘useState’)” when the widget renders. Diagnosis: two copies of React are loaded because the shared config differs between host and remote, or singleton: true is missing on one side. Fix: make the shared blocks identical and ensure singleton: true with a compatible requiredVersion in both. Verify with the __webpack_share_scopes__ check above — see managing shared dependencies at runtime for version-range strategy.

Symptom: Loading script failed / 404 on remoteEntry.js, or the import hangs. Diagnosis: the remote isn’t running, the URL in remotes is wrong, or CORS blocks the cross-origin manifest fetch. Fix: confirm the remote dev server is up on the expected port, that the remotes value matches remoteName@url exactly, and that the remote sends Access-Control-Allow-Origin. Watch for a missing .js filename or a trailing-slash mismatch in the URL.

Symptom: the manifest loads but chunks 404 from the wrong origin. Diagnosis: the remote hardcoded output.publicPath to /, so the browser requests chunks from the host’s origin instead of the remote’s. Fix: set publicPath: 'auto' in the remote (Step 1) so chunk URLs resolve relative to where remoteEntry.js was served.

Symptom: the host crashes with a blank page instead of showing the loading fallback when the remote is down. Diagnosis: the remote’s failed import() threw past Suspense, which only handles the pending state, never a rejection. Fix: wrap <Suspense> in the RemoteErrorBoundary from Step 4, and confirm the boundary sits outside Suspense. Test it by stopping the remote dev server and reloading the host — you should now see the fallback text, not a white screen.

The quickest summary of the failure modes above:

Symptom Root cause Fix
Eager consumption error Shared module imported in the entry chunk index.jsbootstrap.js async split
Invalid hook call Two React copies loaded Identical shared blocks, singleton: true both sides
remoteEntry.js 404 / hang Remote down, wrong URL, or CORS blocked Start remote, fix remoteName@url, send CORS header
Chunks 404 from wrong origin Hardcoded publicPath publicPath: 'auto' in the remote
Blank page on remote failure Rejection escapes Suspense Error boundary outside Suspense