Module Federation Configuration Deep Dive

How the module federation configuration actually works — host config, remote config, shared dependencies, and the environment variable problem you will hit in production.

March 21, 20264 min read3 / 6

The module federation configuration has a reputation for being complex. After working through it, I think the complexity is mostly accidental — it's a few core concepts with a lot of surface area. Understanding what each setting does makes the configuration feel much less like guesswork.

The Repository Setup

One thing to get out of the way: whether the host and remotes live in the same repo or different repos doesn't change how module federation works. Separate repos can federate. One repo can contain multiple independently-deployed apps. These are different dimensions — repo topology vs runtime composition.

For exploration and development, having everything in one repo is practical. You can start both servers with one command, keep configs in sync easily, and avoid managing multiple git remotes. That's a tooling convenience, not an architectural commitment.

The Host Configuration

The host (shell) config looks like a standard Vite or Rsbuild configuration with one additional plugin. Here's the shape of what matters:

TypeScript
// rsbuild.config.ts (host/shell app) import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; export default { plugins: [ pluginModuleFederation({ name: 'host', remotes: { 'remote-analytics': 'remote-analytics@http://localhost:3001/mf-manifest.json', }, shared: { react: { singleton: true, eager: true }, 'react-dom': { singleton: true, eager: true }, }, }), ], };

name — identifies this app in the federation. The shell is the host.

remotes — a manifest of where to find each remote app. The key (remote-analytics) is how you'll import it in code. The value tells the runtime where to fetch the remote's manifest JSON.

The localhost:3001 URL is the immediate problem in production. Every deploy environment needs a different URL. The standard approach is environment variables interpolated at build time:

TypeScript
remotes: { 'remote-analytics': `remote-analytics@${process.env.ANALYTICS_REMOTE_URL}/mf-manifest.json`, },

There's no universal elegant solution here — environment variables are the standard approach, and the specific implementation depends on your infrastructure. I've also seen teams import their package.json as a JavaScript object and derive remotes from workspace entries, which is clever for monorepos but adds its own complexity.

shared — tells module federation which dependencies to load only once across the entire federated system. singleton: true means "only ever one copy of this." eager: true means "load this with the initial bundle rather than waiting until it's first imported."

The reasoning for eager: true on React: since every piece of the app uses React, there's no benefit to lazy-loading it. Fetch it upfront, avoid the extra round trip.

The Remote Configuration

The remote (team app) config mirrors the host in structure:

TypeScript
// rsbuild.config.ts (remote/team app) import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; export default { plugins: [ pluginModuleFederation({ name: 'remote-analytics', exposes: { './Dashboard': './src/Dashboard.tsx', }, shared: { react: { singleton: true, eager: true }, 'react-dom': { singleton: true, eager: true }, }, }), ], };

name — matches the key used in the host's remotes config. This is how the federation runtime knows which manifest is which.

exposes — the API surface of this remote. Similar to the exports field in package.json: these are the things other apps can import. Everything not listed here is private implementation detail — teams can't go digging into internals via path hacks.

The naming convention matters less than the consistency. I'd call it something descriptive of what the team owns (dashboard, billing, analytics) rather than remote-analytics, but either works.

The Manifest JSON

When you run the dev server or build, the module federation plugin generates a mf-manifest.json file automatically. You don't write this file — you configure it via the plugin, and it produces the manifest. The shell uses this manifest to know how to find and load each remote at runtime.

That manifest gets served at the URL you configured in the host's remotes. The shell fetches it, reads it, and knows where to find each exposed module.

The Key Insight

The whole system is essentially two config objects that agree on some shared names. The host says "I want to load something called remote-analytics from this URL." The remote says "I am remote-analytics and I expose these things." The manifest bridges them at runtime.

The build tool (Vite, Rsbuild, Webpack — they all work) handles everything else: resolving imports, generating the manifest, enforcing the exposes contract.

Understanding this makes the configuration feel much less mysterious. It's not magic — it's two JSON-shaped objects with shared naming conventions, and a runtime that knows how to fetch and resolve them.

Next: how the lazy loading actually works in the application code.

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.