What Nx module federation is: a host, remotes and a runtime contract

Module federation lets one JavaScript application load code from another at runtime, after both have been built and deployed separately. The bundler (webpack, or Rspack) emits a small entry file for each build that describes what it offers and which libraries it expects to share. Nx adds generators, configuration helpers and task orchestration on top, so a single workspace can produce several federated apps.

The model maps cleanly onto backend concepts:

  • The host is the API gateway. It owns the shell, routing and layout, and decides which remote handles a request.
  • Remote apps are independently deployed services. Each has its own build, its own artifact and its own static origin.
  • Exposed modules are each remote's public interface. Anything not exposed is internal and can change freely.
  • The manifest is service discovery. It maps remote names to URLs at runtime, so you can repoint the host without rebuilding it.
                 +-----------------------------+
                 |  Host (shell, routing)      |
                 |  reads manifest at startup  |
                 +--------------+--------------+
                                |
                 manifest: { cart: URL, account: URL }
                     /                         \
     +--------------------------+  +--------------------------+
     | cart remote              |  | account remote           |
     | cdn-a.example/cart/      |  | cdn-b.example/account/   |
     | exposes ./Routes         |  | exposes ./Routes         |
     +--------------------------+  +--------------------------+
                     \                         /
              shared singletons: react, react-dom, router
            (one instance loaded, negotiated at runtime)

Teams adopt this setup so that each team can build and release on its own schedule. A change to cart ships without rebuilding or redeploying the host.

Treat it as a topology decision rather than a framework feature. You are moving integration from build time to runtime. That brings the same costs as splitting a monolith into services: version skew, contract drift and partial failure.

+--------------------------------------------------------------+
| HOST: shell, routing, layout                  (~ API gateway) |
+--------------------------------------------------------------+
      |                                         |
      | 1. fetch at startup                     | 3. load entry,
      v                                         |    import ./Routes
+--------------------------------------+        |
| MANIFEST  (~ service discovery)      |        |
| cart    -> cdn-a.example/cart/       |        |
| account -> cdn-b.example/account/    |        |
| host repoints without rebuilding    |        |
+--------------------------------------+        |
                                                |
               +--------------------------------+
               v                                v
+-----------------------------+  +-----------------------------+
| cart remote (~ service)     |  | account remote (~ service)  |
| cdn-a.example/cart/         |  | cdn-b.example/account/      |
| exposes ./Routes            |  | exposes ./Routes            |
| own build, own release      |  | own build, own release      |
+-----------------------------+  +-----------------------------+
               |                                |
               +----------------+---------------+
                                |
                                v
+--------------------------------------------------------------+
| SHARED SINGLETONS: react, react-dom, router                  |
| used by host and remotes; one copy loaded per page           |
| version range negotiated at runtime                          |
+--------------------------------------------------------------+

The host reads the manifest to find each remote's static origin, then loads that remote's exposed ./Routes at runtime, while the host and all remotes share a single copy of react, react-dom and the router.

Who should use it and when to avoid it

Module federation pays off when several teams own distinct areas of one large frontend and need to ship on their own schedules. If checkout releases daily and admin releases monthly, a runtime contract lets each team deploy without waiting for the others. For a single team or a small app, you get the costs without the benefit, because nobody is blocked by anybody else's release.

The trade-offs are easy to underestimate:

  • Independence is partial. Workspace libraries imported by a remote are bundled into that remote at build time. Changing a shared lib still means rebuilding and redeploying every remote that consumes it.
  • Singletons force lockstep upgrades. React or Angular must load once, so a major framework upgrade has to land in every remote together.
  • Failures move to production. A broken exposed module or a version mismatch shows up when the browser loads it, not when CI compiles it.
  • Debugging spans artifacts. A single stack trace can cross several separately deployed bundles, each with its own source maps and version.
  • Operational cost scales per remote. Each remote adds its own pipeline, hosting, cache policy and monitoring.
Approach Team autonomy CI speed Runtime risk Operational cost
Modular monolith (Nx libs + affected) Low: one release Fast with affected and caching Low Low
Build-time package composition Medium: versioned packages Moderate Low Medium
Separate apps behind route splits High Fast per app Low to medium (full page loads) Medium to high
Module federation High Fast per remote High High

Start with the modular monolith. Move to federation only when release coordination, rather than build time, is what slows teams down.

Scaffolding a host and remote apps in an Nx workspace

Once release coordination is the real bottleneck, scaffold the split with Nx's generators rather than by hand. The React plugin provides a host generator that creates a host application and, optionally, a set of named remotes in a single run. It also generates a separate remote generator, so you can add remotes later as teams take ownership of new areas. Before running either, check the help output for the generator you plan to use. Option names, defaults and the bundler choice have changed across Nx releases, so the help text for your installed version is more reliable than a blog post or an old internal runbook.

After generation, you should see each remote as its own application project with its own build, serve and test targets, sitting next to the host. That separation is what allows a remote to be built and deployed on its own schedule later. Code that more than one app needs, such as design-system components, API clients and auth helpers, belongs in workspace libraries, not inside any single app. If you put it in an app, you get either duplication or an import from one deployable into another.

Run nx graph to view the project graph. The host should depend on the remotes, and both should depend on the shared libraries. Remotes should not depend on each other. If you find an edge between two remotes, treat it as a design problem to fix early.

Finally, open the module federation config file that the generator placed in the host and in each remote. Read what it exposes, how it names remotes and what it shares before you edit anything, because the shape of this file varies between Nx versions.

# 1. Read the options for your installed Nx version before generating.
#    Flag names, defaults and bundler choices change between releases.
npx nx g @nx/react:host --help
npx nx g @nx/react:remote --help

# 2. Preview a host with two remotes. --dry-run writes nothing to disk.
#    Check the help output above to confirm the argument shape for your
#    version. Recent releases take the project directory as a positional arg.
npx nx g @nx/react:host apps/shell --remotes=shop,cart --dry-run

# 3. Once the preview looks right, run the same command without --dry-run.
npx nx g @nx/react:host apps/shell --remotes=shop,cart

# 4. Later, add a remote for a new team and attach it to the existing host.
npx nx g @nx/react:remote apps/checkout --host=shell --dry-run

# 5. Confirm each remote is its own project with its own targets.
npx nx show projects
npx nx show project shop

# 6. Inspect dependencies visually. Expect host -> remotes -> shared libs,
#    and no edges between remotes.
npx nx graph

# 7. Find the generated module federation configs and read them
#    before editing. Their shape varies by Nx version.
find apps -name 'module-federation.config.*' -not -path '*/node_modules/*'

Shell steps for previewing and generating a React host with remotes, adding a remote later, and inspecting the resulting projects, project graph and module federation config files in an Nx workspace.

Configuring exposed modules, remote resolution and shared dependencies

The file answers three questions. name is the identifier the host uses to address a remote; it must match on both sides or resolution fails. exposes maps public paths to source files, typically a single entry module per remote. That map is the remote's API, so keep it small and treat changes to it like a breaking interface change. On the host, remotes lists which remotes exist and, in the static form, where their remoteEntry.js lives. Those URLs are fixed at build time.

Moving to dynamic remotes

Static URLs force a rebuild for every environment. The alternative is to drop URLs from the host config and ship a small JSON manifest that maps remote names to base URLs. The host fetches it at startup, registers the definitions with the federation runtime, and only then bootstraps. Staging and production serve different manifests, so you can promote the same host artifact through every environment.

Shared dependencies

The UI framework, router and state stores must be singletons. Two copies of the framework mean two separate component trees, and two routers disagree about the current location. By default, Nx shares npm packages and workspace libraries as singletons, using the versions in the root package.json. If a remote resolves a different version, you get either a duplicate instance or a runtime error, depending on the strictness settings. Opt a package out with the shared function when it is large, stateless and used by only one remote.

Each remote costs at least one extra remoteEntry request, and a loose sharing config can download the same dependency twice. Remotes that load other remotes create request waterfalls, which slow the first render.

// apps/shell/src/main.ts
// The host config lists no remote URLs. They come from a manifest
// served next to the build, so one artifact can run in every environment.
import { init } from '@module-federation/enhanced/runtime';

// Example manifest contents (a different file per environment):
// { "catalog": "",
//   "checkout": "" }
type RemoteManifest = Record<string, string>;

fetch('/assets/module-federation.manifest.json')
  .then((res) => {
    if (!res.ok) throw new Error(`Manifest load failed: ${res.status}`);
    return res.json() as Promise<RemoteManifest>;
  })
  .then((manifest) => {
    // Keys must match each remote's `name`, or resolution fails at runtime.
    const remotes = Object.entries(manifest).map(([name, baseUrl]) => ({
      name,
      entry: `${baseUrl}/remoteEntry.js`,
    }));

    // Register remote definitions before any federated import runs.
    init({ name: 'shell', remotes });
  })
  // Bootstrap only after registration, so remote imports can resolve.
  .then(() => import('./bootstrap'))
  .catch((err) => console.error(err));

A host entry point that fetches a per-environment JSON manifest, registers the remote definitions with the federation runtime, and only then bootstraps the app, so the same host build can be promoted unchanged from staging to production.

Local development and CI: affected builds and remote caching

Locally, the same graph that shapes runtime loading also shapes startup cost. Serve the host with the remotes you are actively changing passed via --devRemotes. Those run from source with hot reload. Every other remote is built once and served as static files. Startup time and memory then track how many remotes you are editing, not how many exist. That matters when the workspace grows past a handful of remotes and a full source build would compile every bundle at once.

In CI, nx affected -t build test lint compares the branch against the base commit. It uses the project graph to run targets only for changed projects and their dependents. A remote cache then makes those skipped projects cheap even on fresh runners. A task whose inputs hash to a known value restores its outputs and logs instead of re-executing. Treat cache writes as trusted artifacts, so allow writes only from CI and not from developer machines.

Illustrative estimate

These figures are assumed, not measured: 12 remotes, 4 minutes each to build, test and lint, run serially, and a change touching 2 remotes.

  • Full pipeline: 12 × 4 = 48 minutes.
  • Affected pipeline: 2 × 4 = 8 minutes, plus the host if the graph marks it as a dependent.

That leaves roughly 40 minutes less waiting per change. Engineers get feedback while they still have the context loaded, and fewer runner minutes lower the compute bill directly.

#!/usr/bin/env bash
set -euo pipefail

# --- Local development ---
# Serve the host ('shell') with only the remotes you are editing
# ('cart' and 'checkout') from source with hot reload. Every other
# remote is built once and served as static files, so startup time
# and memory track the number of remotes being edited.
npx nx serve shell --devRemotes=cart,checkout

# --- CI ---
# Compare the branch against the base commit. Nx walks the project
# graph and selects only changed projects and their dependents.
BASE_REF="origin/main"
HEAD_REF="HEAD"

# Print the affected project names so the log shows what will run.
npx nx show projects --affected --base="$BASE_REF" --head="$HEAD_REF"

# Run build, test and lint only for affected projects. A task whose
# inputs hash to a known value restores its outputs and logs from
# the cache instead of re-executing.
# Give CI the credentials that can write to the remote cache, and
# give developer machines read-only access, so cached outputs stay
# trusted artifacts.
npx nx affected -t build test lint --base="$BASE_REF" --head="$HEAD_REF"

Serve the host with only the actively edited remotes from source, then run build, test and lint in CI for just the projects affected since the base branch.

Designing an independent deployment pipeline

Each remote should build into its own static location, such as a bucket prefix or a directory per build ID. Give every asset a content hash in its filename and never overwrite a published file. The host should not hard-code remote URLs. Instead, at startup it fetches a small manifest that maps each remote name to the URL of that remote's entry file.

Promoting a remote means writing its new URL into the manifest. Rolling back means writing the previous URL back. This works because the old build is still sitting at its old path, untouched.

Caching follows from that split:

  • Host HTML and the manifest: serve with no-cache or a very short max-age, so a manifest change reaches users within seconds.
  • Hashed assets: serve with a long max-age plus immutable, because their contents can never change under the same name.

If the manifest is cached aggressively, a user can load a new host against an old remote, or the reverse. A rollback then does nothing until the cache expires.

Without immutable naming, the problem is worse. A rollback means rebuilding and re-uploading the old code, and CDNs may serve a mix of old and new chunks in the meantime.

Remotes usually live on a different origin from the host. The remote's storage must send Access-Control-Allow-Origin for the host's origin, covering the manifest fetch, chunks and fonts.

When a shared library that is bundled at build time (not shared at runtime) changes, use the affected graph to rebuild every consumer. Then publish all of their new URLs in a single manifest write.

Finally, type checks inside one project cannot prove that the deployed host and remote still agree. Run smoke or contract tests against the real deployed URLs: load the host with the candidate manifest and confirm that each exposed module mounts.

Enforcing boundaries and handling remote failures at runtime

Lint the boundaries

Deployment checks catch drift after the fact. Boundary rules stop the coupling before it is written. Give every project a tag in its project configuration, such as type:remote, type:host, type:shared-ui or type:util. Then configure the module boundary lint rule (@nx/enforce-module-boundaries) so that projects tagged type:remote may depend only on designated shared library tags, never on other remotes. A direct import from one remote into another compiles fine, but it silently couples two release trains. The lint rule turns that into a CI failure, much like a platform team refuses a service that reads another service's database instead of calling its API.

Treat remotes as downstream services

A remote is a network dependency, so plan for it to fail:

  • Error boundaries. Wrap each remote mount point in an error boundary with a fallback UI. A broken checkout remote should not blank the whole shell.
  • Load timeouts. Race the dynamic import against a timer. A remote that hangs is worse than one that fails fast, because the user waits with no feedback.
  • Structured logging and alerts. Record the remote name, the resolved URL, the version or build ID, and the host version on every load failure. Alert on the failure rate. Without the version pair, you cannot reproduce the exact combination a user hit.

The coupling you can't lint away

The host and every remote share the same tooling. Upgrade Nx, the bundler or the framework across all of them together, test the combination as one unit, and check behavior against the documentation for the versions you have installed.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx module federation, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.