angular-module-federation-2-nx-rspack ×

Setting up Angular with Module Federation 2.0, Nx, and rspack — and the eight ways it broke

BY CREATED Aug 19, 2026 ~13 min read

Wiring an Angular shell and a Module Federation 2.0 product remote together with rspack, Nx, and @module-federation/enhanced — none of it worked on the first try. Eight distinct failures stood between “it builds” and “it renders,” each with its own error code and its own one-line fix once found. Here’s the setup, start to finish, with all eight left in.

Module Federation 2.0 is the rewrite that sits on top of webpack 5’s original Module Federation — it adds a Federation Runtime, a Runtime Plugin System, a Manifest, and dynamic type hinting on top of the core export/load/share mechanics. @module-federation/enhanced is the package that ships it, and init()/loadRemote() (used throughout this post) are its runtime API, not the older static ModuleFederationPlugin-only approach.

If you’ve already got federation running and just need a graceful fallback when a remote fails to load in production, see Handling remote-load errors in Angular Module Federation v2 with Rspack instead — this post is about getting to “it renders” in the first place.

Written against Nx 23.1.1, Angular 22.0.4, rspack 2.1.10, and @module-federation/enhanced 2.8.2. Version pins matter more than usual here — several of the gotchas below are specific interactions between this exact set of releases, not durable properties of Module Federation 2.0 itself. Treat the shape of each failure as the reusable part if you’re on different versions.

Worth knowing before you invest in this setup: as of Nx v23 — the exact version pinned above — Nx’s own docs deprecated the @nx/angular:host/@nx/angular:remote webpack generators and state that “Angular Module Federation in Nx is no longer supported going forward,” recommending Native Federation (ES modules + import maps, no bundler-specific container plugin) instead. This post never used those deprecated generators — it scaffolds a plain Angular app and wires @module-federation/enhanced/rspack by hand, a separate, generator-independent path — so nothing here breaks because of the deprecation. But it’s a sign of where the ecosystem is heading, and worth weighing if you’re choosing an approach for a new project rather than debugging an existing one.

Scaffold the workspace#

create-nx-workspace asks its questions interactively — a custom Angular preset, an integrated monorepo, rspack as the bundler, the first app named shell.

bash
npx create-nx-workspace@latest --pm=pnpm
√ Where would you like to create your workspace? · ng-rspack
√ Which starter do you want to use? · custom
√ Which stack do you want to use? · angular
√ Integrated monorepo, or standalone project? · integrated
√ Application name · shell
√ Which bundler would you like to use? · rspack
√ Default stylesheet format · css
√ Do you want to enable Server-Side Rendering (SSR)? · No

 NX   Creating your v23.1.1 workspace.
✔ Installing dependencies with pnpm
✔ Successfully created the workspace: ng-rspack

This produces shell as a standalone-component Angular app already wired to rspack’s dev server and build target — no webpack.config.js, no angular.json executor indirection, just apps/shell/rspack.config.ts calling createConfig() from @nx/angular-rspack.

One thing worth flagging so it doesn’t throw you off: the generator isn’t perfectly deterministic between runs of the same version. On a second attempt at this exact workspace, the prompt list included one extra SSR-related question that hadn’t shown up the first time. If your terminal output doesn’t match this post line-for-line, that’s normal — don’t assume you did something wrong.

Install Module Federation#

rspack ships a container-plugin implementation compatible with webpack’s Module Federation, but the ergonomic layer — the JS runtime that does init() and loadRemote() at the application level — comes from @module-federation/enhanced.

bash
pnpm add @module-federation/enhanced
WARN  Issues with peer dependencies found
.
└─┬ @nx/webpack 23.1.1
  └─┬ css-loader 6.11.0
    └── ✕ unmet peer @rspack/core@"0.x || 1.x": found 2.0.4

dependencies:
+ @module-federation/enhanced 2.8.2

Done in 4s using pnpm v10.11.0

The peer-dependency warning is noise. It comes from @nx/webpack, a plugin this workspace never invokes — every app here builds with rspack, not webpack. Safe to ignore.

Generate the remote app#

A second Angular application, generated the same way as the first, becomes the federated remote — here named product.

bash
pnpm nx g @nx/angular:app product --bundler=rspack
√ Do you want to enable Server-Side Rendering (SSR)? · No

CREATE product/project.json
CREATE product/src/index.html
CREATE product/src/app/app.config.ts
CREATE product/src/app/app.routes.ts
CREATE product/src/app/app.ts
CREATE product/src/main.ts
CREATE product/rspack.config.ts

Two identical-looking apps now exist side by side, each with its own rspack.config.ts and its own dev-server port — shell on 4203, product on 4202 once configured. Nothing federates yet; that’s two config files and a routes file away.

Wire the host#

shell is the host: it declares product as a remote, lists the packages both apps must share as singletons, and lazily loads a component out of the remote when its route activates.

rspack.config.ts — declare the remote#

apps/shell/rspack.config.tsts
import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack';

new ModuleFederationPlugin({
  name: 'shell',
  remotes: {
    product: 'product@http://localhost:4202/remoteEntry.js',
  },
  shared: {
    '@angular/core': { singleton: true, requiredVersion: false },
    '@angular/common': { singleton: true, requiredVersion: false },
    '@angular/common/http': { singleton: true, requiredVersion: false },
    '@angular/router': { singleton: true, requiredVersion: false },
    rxjs: { singleton: true, requiredVersion: false },
  },
})

The remotes value here is the webpack-style shorthand string — 'name@url' — not an array of { name, entry } objects. That distinction matters a lot; see gotcha #2 below.

main.ts — an async boundary before anything shared loads#

Angular’s own @angular/core is one of the shared singletons, which means it can’t be required synchronously at the top of the entry file — the federation runtime needs a tick to finish negotiating the shared scope first. The fix is the same one webpack Module Federation examples have used for years: split the entry into a synchronous shim and an asynchronously-imported bootstrap.

apps/shell/src/main.tsts
import { init } from '@module-federation/enhanced/runtime';

init({
  name: 'shell',
  remotes: [{ name: 'product', entry: 'http://localhost:4202/remoteEntry.js' }],
});

import('./bootstrap').catch((err) => console.error(err));
apps/shell/src/bootstrap.tsts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';

bootstrapApplication(App, appConfig).catch((err) => console.error(err));

Note the shape of the remotes option here — an array of { name, entry } objects. That’s the runtime API’s shape, and it’s a different schema from the plugin config above. Mixing the two up is gotcha #2.

app.routes.ts — pull a component out of the remote#

loadRemote() resolves to the remote module’s full namespace object, not the class directly — a named export has to be unwrapped explicitly.

apps/shell/src/app/app.routes.tsts
import { Route } from '@angular/router';
import { loadRemote } from '@module-federation/enhanced/runtime';
import { Type } from '@angular/core';
import { Home } from './home/home';

export const appRoutes: Route[] = [
  { path: '', component: Home },
  {
    path: 'product',
    loadComponent: () =>
      loadRemote<{ Product: Type<unknown> }>('product/test1')
        .then((m) => m!.Product),
  },
];

Wire the remote#

product is the mirror image: it exposes a module by public path, declares the same shared singletons as the host, and gets the same synchronous/async entry split as shell.

product/rspack.config.tsts
new ModuleFederationPlugin({
  name: 'product',
  filename: 'remoteEntry.js',
  exposes: {
    './test1': './src/app/product/product.ts',
  },
  shared: {
    '@angular/core': { singleton: true, requiredVersion: false },
    '@angular/common': { singleton: true, requiredVersion: false },
    '@angular/common/http': { singleton: true, requiredVersion: false },
    '@angular/router': { singleton: true, requiredVersion: false },
    rxjs: { singleton: true, requiredVersion: false },
  },
})

One extra line belongs in this file that has no webpack equivalent: optimization: { runtimeChunk: false }. It’s load-bearing — the full story is gotcha #5.

The exposed file itself is an ordinary standalone component — federation doesn’t ask anything special of it:

product/src/app/product/product.tsts
import { Component } from '@angular/core';

@Component({
  imports: [],
  selector: 'app-product',
  template: `<h1>Product</h1>`,
})
export class Product {}

Run it#

Both dev servers need to be up — product on 4202 so its remoteEntry.js is reachable, shell on 4203 so there’s a page to navigate. Nx’s rspack plugin names the dev-server task serve, so both start together with:

bash
nx run-many -t serve --parallel=20

Navigate to http://localhost:4203/product and, if every piece above is in place, the header reads Product — rendered by a component that was never bundled into shell at all. Here’s what actually happens on that request:

Browsershellhost · :4203router-outlet@angular/core (shared)productremote · :4202exposes ./test1@angular/coreGET /productloadRemote()GET remoteEntry.jsreturns Product
shell resolves /product by fetching product's remoteEntry.js at request time and rendering the class it returns

The browser requests /product from shell, the router calls loadRemote(), which fetches product’s remoteEntry.js over the wire, and the component that comes back runs against the same @angular/core instance shell already has loaded, shared as a singleton between both apps.

Field notes — the eight failures#

None of the above worked on the first try. In the order they were hit:

1. The dev script runs nothing#

pnpm dev mapped to nx run-many -t dev, and Nx reported no matching tasks.

Cause: Nx’s rspack plugin (configured in nx.json) names the inferred dev-server task serve, not dev — no project defines a target called dev at all.

Fix: point the script at the real target name — "dev": "nx run-many -t serve --parallel=20".

2. Two remotes shapes, one that crashes the compiler#

TypeError: object null is not iterable (cannot read property Symbol(Symbol.iterator))
  at getRemoteInfos (@rspack/core/dist/index.js)

@module-federation/enhanced’s runtime init() API accepts remotes as an array of { name, entry } objects — but the build-time ModuleFederationPlugin option of the same name is a different, webpack-shaped schema entirely: a string shorthand or an { external, shareScope } config object. Passing the runtime shape to the plugin option compiles fine until a production build walks the internal remote list and finds a property that was never there.

Fix: keep the two APIs’ shapes straight — remotes: { product: 'product@http://…/remoteEntry.js' } in the plugin config; the array-of-objects form only inside init().

3. Bootstrapping before the shared scope exists#

[ Federation Runtime ]: Invalid loadShareSync function call from runtime #RUNTIME-006
args: {"hostName":"shell","sharedPkgName":"@angular/common"}

main.ts called init() and then bootstrapApplication() on the very next line — synchronously. The federation runtime needs an async tick to finish registering the shared scope before any shared package can be required; @angular/common got required before that tick ran.

Fix: the main.tsbootstrap.ts split shown above — everything that touches a shared package moves behind a dynamic import().

4. A red herring in the remote-entry format#

[ Federation Runtime ]: Failed to get remoteEntry exports. #RUNTIME-001
args: {"remoteName":"product","remoteEntryUrl":"http://localhost:4202/remoteEntry.js"}

The obvious suspect was module format: @nx/angular-rspack only forces ESM output (output.module: true) for production builds, never for the dev server — so setting library: { type: 'module' } on the container to “fix” this actually broke dev mode outright, throwing a literal SyntaxError: Unexpected token 'export' when the dev server’s classic chunk format collided with forced ESM syntax. Reverting that setting removed the syntax error, but RUNTIME-001 itself was still there — a different bug wearing the same error code. See #5.

Fix: don’t chase module format for this error before ruling out what #5 describes — check whether the container’s own bootstrap module is actually present in remoteEntry.js first.

5. The container’s bootstrap code lives in a file no one else fetches#

Inspecting remoteEntry.js directly in dev mode showed a startup call referencing an internal module id that was never defined anywhere in the file. Cross-checking every script the dev server serves found that exact module sitting inside runtime.js instead — a separate file.

@nx/angular-rspack sets optimization.runtimeChunk: 'single' for every browser build, splitting the webpack/rspack runtime — including the federation container’s own get/init bootstrap — into that shared file. product’s own page loads runtime.js, main.js, and remoteEntry.js together, so it never notices. shell fetches only remoteEntry.js as a standalone script, and the reference inside it resolves to nothing.

Fix: optimization: { runtimeChunk: false } in product’s rspackConfigOverrides — forces the container to inline its own bootstrap rather than depend on a sibling file.

This isn’t a quirk unique to this setup, either — the official Module Federation Angular integration guide says the same thing in almost the same words: “Due to a current bug, setting the runtimeChunk optimization to false is essential; otherwise, the Module Federation setup will break.” Angular’s own webpack/rspack builder splitting the runtime chunk by default, combined with Module Federation needing that chunk inlined, is a known open issue — not something to chase further once you’ve hit it.

6. Inlining the runtime also inlines the dev server’s HMR client — the continuous-loading loop#

With #5 fixed, the remote loaded — until the next time product recompiled. Then shell’s tab spiraled into repeated hot-update.json 404s and full-page reloads, forever.

Worth naming this on its own, because it’s a symptom that shows up across micro-frontend setups generally, not just this exact toolchain: the host tab never settles — it keeps reloading itself in a loop, sometimes visibly flashing, sometimes just quietly re-requesting the same chunk over and over in the network tab. Whenever a consumed micro-frontend behaves like that, the root cause is almost always the same shape as this one: some piece of dev-only bookkeeping (an HMR client, a version poller, a live-reload socket) got bundled inside a remote and is now running in a host page that has no way to satisfy whatever it’s waiting for.

Here, specifically: runtimeChunk: false doesn’t inline just the federation bootstrap — it inlines the whole runtime chunk, HMR client included. That client now runs inside shell’s page, permanently comparing its embedded build hash against product’s live one. Since shell never re-fetches remoteEntry.js on its own, that comparison can never resolve, and every reload just re-triggers the same failed check.

Fix: there isn’t a clean one — see the trade-off below.

7. A second Angular, quietly#

NG0203: The `EnvironmentInjector` token injection failed. `inject()` function
must be called from an injection context…

product’s shared block had been commented out. Without it, @angular/core gets bundled twice — once inside shell, once inside the exposed component — and the two copies don’t recognize each other’s injector, so dependency injection breaks the moment the remote component tries to construct.

Fix: re-enable shared on the remote, matching the host’s list exactly.

8. The whole module, not the class inside it#

NG04014: Invalid configuration of route 'product'. The component must be standalone.

The route’s loadComponent cast loadRemote('product/test1')’s result directly to Type<unknown> — but that promise resolves to the exposed file’s whole module namespace, { Product: class }, not the class itself. Angular’s router received an object with no component metadata at all and reported the closest thing it could name.

Fix: .then((m) => m.Product) — unwrap the named export explicitly, as shown in the host wiring above.

The trade-off that’s left#

runtimeChunk: false is what makes the remote loadable by another app in dev mode at all — but the same setting makes rspack-dev-server’s incremental HMR unreliable, sometimes even on the remote’s own standalone page, not only when embedded. There’s no configuration found so far that gives both at once with this exact toolchain version.

SettingStandalone dev (:4202)Consumed via shell (:4203)
runtimeChunk: 'single' (default)Full HMR / live-reloadFails — container module missing
runtimeChunk: falseHMR unreliableLoads once; reload-loops on next recompile

Until this improves upstream, the practical split is: develop product’s UI against its own page, where runtimeChunk can stay default and HMR works normally; verify federation integration with a real build — nx build followed by serve-static — rather than leaving the dev-mode remote wired in during active editing.


Eight failures, eight one-line fixes, and one trade-off that doesn’t have one yet. If you’re setting this up on a different version combination than the one pinned at the top, expect the exact error codes to drift — but the shapes of these bugs (shared-scope timing, config-schema mismatches between the plugin and the runtime, the runtimeChunk split) are durable enough to recognize on sight.

Once the remote is loading reliably, the next thing worth adding is a graceful fallback for when it doesn’t — see Handling remote-load errors in Angular Module Federation v2 with Rspack for the errorLoadRemote runtime plugin that covers that case.