Setting Up Module Federation
Webpack Module Federation lets one application (the Host) load code from another application (the Remote) at runtime — without a shared build step. This article walks through the full setup inside an Nx monorepo.
Project structure
A typical Nx workspace with two microfrontends looks like this:
apps/
shell/ ← Host app (React, port 3000)
dashboard/ ← Remote app (React, port 4001)
libs/
ui/ ← Shared design system
api-client/ ← Shared API utilities
The libs/ directory is the main reason for using a monorepo here — you can
share code without publishing to npm.
Bootstrapping the workspace
There are two mainstream ways to set this up: Nx (full framework, first-class Module Federation support) or Turborepo (lightweight task runner, less structure). The webpack Module Federation config is identical either way — the difference is how you manage the monorepo around it.
Option A — Nx workspace
npx create-nx-workspace@latest myorg --preset=empty
cd myorg
nx add @nx/react
nx g @nx/react:app shell
nx g @nx/react:app dashboard
nx g @nx/react:lib ui
Nx generates project.json for each app with targets for build, serve,
test, and lint. The generated webpack config is in each app's
webpack.config.js. The @nx/react:module-federation generator can also wire
up the Module Federation config automatically.
Option B — Turborepo workspace
npx create-turbo@latest myorg
cd myorg
Turborepo doesn't scaffold apps for you — it manages tasks across whatever structure you create. A typical manual layout:
apps/
shell/ ← standard Webpack + React app
dashboard/ ← standard Webpack + React app
packages/
ui/ ← shared component library
turbo.json ← task pipeline config
The turbo.json pipeline:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"serve": {
"cache": false,
"persistent": true
}
}
}
Run all apps in parallel:
turbo run serve
Turborepo has no opinion on your webpack config — you write the Module
Federation config exactly as shown in the sections below, in each app's
webpack.config.js.
Configuring the Remote app
The Remote (dashboard) exposes a component via Module Federation:
// apps/dashboard/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'dashboard',
filename: 'remoteEntry.js',
exposes: {
'./DashboardApp': './src/app/App',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};
Key points:
namemust be unique across all remotes.filename: 'remoteEntry.js'is the manifest the host fetches.exposesmaps a public key (./DashboardApp) to a local module path.shared.reactwithsingleton: trueensures only one React instance runs across host and remote — critical for hooks compatibility.
Configuring the Host app
The Shell (host) declares the remotes it wants to consume:
// apps/shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
dashboard: 'dashboard@http://localhost:4001/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};
Loading a remote component
In the shell, you load remote modules using dynamic import with React's
Suspense for the async boundary:
// apps/shell/src/app/App.tsx
import React, { Suspense } from 'react';
const DashboardApp = React.lazy(() => import('dashboard/DashboardApp'));
export function App() {
return (
<Suspense fallback={<div>Loading dashboard...</div>}>
<DashboardApp />
</Suspense>
);
}
The TypeScript compiler doesn't know about dashboard/DashboardApp by default.
Add a declaration file:
// apps/shell/src/declarations.d.ts
declare module 'dashboard/DashboardApp' {
const DashboardApp: React.ComponentType;
export default DashboardApp;
}
Fallback strategy
Network failures happen. The remote may be down. You must handle this:
import { Suspense, lazy } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
const DashboardApp = lazy(() =>
import('dashboard/DashboardApp').catch(() => ({
default: () => <div>Dashboard unavailable. Please try again later.</div>,
}))
);
export function App() {
return (
<ErrorBoundary fallback={<div>Dashboard failed to load.</div>}>
<Suspense fallback={<div>Loading...</div>}>
<DashboardApp />
</Suspense>
</ErrorBoundary>
);
}
The .catch() on the import handles the case where the remote remoteEntry.js
itself fails to load. The ErrorBoundary handles any error thrown inside the
remote after it loads.
Shared dependency management
The shared config in Module Federation is the most common source of bugs.
Rules to follow:
Always use singleton: true for React and React-DOM. Two React instances
on the same page causes hooks to fail silently.
Pin requiredVersion to a range, not an exact version. This lets you
upgrade the shell without needing to redeploy every remote immediately.
Do not put your own application code in shared. Only stable, truly
shared libraries belong there. Business logic should go in libs consumed at
build time.
In Nx, use @nx/react:module-federation generators. They handle the
shared config, bootstrap.ts entrypoint split, and TypeScript declarations
automatically.
Running locally
With Nx:
# Terminal 1 — start remote
nx serve dashboard
# Terminal 2 — start host
nx serve shell
With Turborepo:
# Starts all apps in parallel (as defined in turbo.json)
turbo run serve
# Or target a specific app
turbo run serve --filter=shell
turbo run serve --filter=dashboard
Open http://localhost:3000. The shell loads and fetches
http://localhost:4001/remoteEntry.js at runtime. The Dashboard component
renders inside the shell without being part of the shell's bundle.
Production considerations
In production, remoteEntry.js should be served from a CDN with a versioned
path. The host should reference the remote via an environment variable
so it can be changed without a host redeploy:
// Resolved at build time or runtime config
const remoteUrl = process.env.DASHBOARD_REMOTE_URL ?? 'https://cdn.example.com/dashboard/remoteEntry.js';
Nx Cloud and Turborepo both support remote caching so that unchanged apps are not rebuilt on every CI run — essential when you have 5+ apps in the monorepo.