Skip to content
Back to Knowledge Base

Migrate from Nx Module Federation to the official plugins

Migrate Nx Module Federation to the official plugins

Migrate this workspace off the Nx Module Federation APIs that Nx v24 will remove, keeping each app's current bundler. Follow every step at https://nx.dev/docs/kb/migrate-from-nx-module-federation.md in order. Scope is React client-side Rspack and webpack. Stop and report if you find Angular or server-side rendering apps.

Rules that override any shortcut you are tempted to take:

1. Before editing, run production builds and keep every `dist/apps/<app>/mf-stats.json`. Its `shared` array, `exposes`, and container name are what you reproduce. Do not derive sharing from package.json.

2. Replace `withModuleFederation` and `NxModuleFederationPlugin` with `ModuleFederationPlugin` from `@module-federation/enhanced/rspack` or `/webpack`. Keep `NxAppRspackPlugin`, `NxAppWebpackPlugin`, `NxReactRspackPlugin`, `NxReactWebpackPlugin`. Do not switch bundlers or regenerate apps.

3. Set `name` to the project name with every character outside `[a-zA-Z0-9_$]` replaced by `_`, plus `filename: 'remoteEntry.js'`, `dts: false`, and on webpack `remoteType: 'script'`. Drop any `library` entry.

4. Write the `shared` map from the baseline `mf-stats.json`, copying `singleton`, `strictVersion`, `requiredVersion`, and `eager` per entry. Secondary entry points are separate keys. Give a source-only workspace library an explicit `version`.

5. Move remotes to a runtime manifest: `registerRemotes` with `type: 'global'` and full `remoteEntry.js` URLs, `loadRemote('<container-name>/Module')` at the call sites, and `implicitDependencies` on the host. Keep static `remotes` entries only if the user asks to stay static.

6. Restore the bundler settings the wrapper used to set: `output.uniqueName`, `output.publicPath: 'auto'`, `output.clean: true`, `optimization.runtimeChunk: false`, app-plugin `runtimeChunk: false` and `commonChunk: false`, `devServer.hot: true` with an `Access-Control-Allow-Origin` header, `lazyCompilation: false` on Rspack, and `output.scriptType: 'text/javascript'` on webpack.

7. Move `build` and `serve` to `nx:run-commands` running the Rspack or webpack CLI, with `NODE_ENV` per configuration. Add a `serve-static` target per app using `@nx/web:file-server` with `staticFilePath`, `dependsOn: ['build']`, `spa: true`, `watch: false`, never `buildTarget`.

8. Delete the old config files and remove `@nx/module-federation` only after the new setup builds and runs.

9. Do not claim hot-update parity, shared-state parity, or deployment-path correctness from a green build. Report what you verified and what you did not.

Reference: https://nx.dev/docs/kb/migrate-from-nx-module-federation.md

Migrate this workspace off the Nx Module Federation APIs that Nx v24 will remove, keeping each app's current bundler. Follow every step at https://nx.dev/docs/kb/migrate-from-nx-module-federation.md in order. Scope is React client-side Rspack and webpack. Stop and report if you find Angular or server-side rendering apps.

Rules that override any shortcut you are tempted to take:

1. Before editing, run production builds and keep every `dist/apps/<app>/mf-stats.json`. Its `shared` array, `exposes`, and container name are what you reproduce. Do not derive sharing from package.json.

2. Replace `withModuleFederation` and `NxModuleFederationPlugin` with `ModuleFederationPlugin` from `@module-federation/enhanced/rspack` or `/webpack`. Keep `NxAppRspackPlugin`, `NxAppWebpackPlugin`, `NxReactRspackPlugin`, `NxReactWebpackPlugin`. Do not switch bundlers or regenerate apps.

3. Set `name` to the project name with every character outside `[a-zA-Z0-9_$]` replaced by `_`, plus `filename: 'remoteEntry.js'`, `dts: false`, and on webpack `remoteType: 'script'`. Drop any `library` entry.

4. Write the `shared` map from the baseline `mf-stats.json`, copying `singleton`, `strictVersion`, `requiredVersion`, and `eager` per entry. Secondary entry points are separate keys. Give a source-only workspace library an explicit `version`.

5. Move remotes to a runtime manifest: `registerRemotes` with `type: 'global'` and full `remoteEntry.js` URLs, `loadRemote('<container-name>/Module')` at the call sites, and `implicitDependencies` on the host. Keep static `remotes` entries only if the user asks to stay static.

6. Restore the bundler settings the wrapper used to set: `output.uniqueName`, `output.publicPath: 'auto'`, `output.clean: true`, `optimization.runtimeChunk: false`, app-plugin `runtimeChunk: false` and `commonChunk: false`, `devServer.hot: true` with an `Access-Control-Allow-Origin` header, `lazyCompilation: false` on Rspack, and `output.scriptType: 'text/javascript'` on webpack.

7. Move `build` and `serve` to `nx:run-commands` running the Rspack or webpack CLI, with `NODE_ENV` per configuration. Add a `serve-static` target per app using `@nx/web:file-server` with `staticFilePath`, `dependsOn: ['build']`, `spa: true`, `watch: false`, never `buildTarget`.

8. Delete the old config files and remove `@nx/module-federation` only after the new setup builds and runs.

9. Do not claim hot-update parity, shared-state parity, or deployment-path correctness from a green build. Report what you verified and what you did not.

Reference: https://nx.dev/docs/kb/migrate-from-nx-module-federation.md

Nx v23 deprecates the Module Federation wrappers, runtime helpers, dev-server executors, and generators that @nx/module-federation, @nx/react, @nx/rspack, and @nx/angular ship today. Nx v24 will remove them. Their replacement is the official Module Federation plugins, which withModuleFederation already wraps internally, so your Rspack apps stay on Rspack and your webpack apps stay on webpack.

Migrate before you upgrade to v24, while the old packages still work and you can compare the result against a baseline you captured yourself. Nx will also ship migrations for this later.

Going away in v24Replacement
withModuleFederation from @nx/module-federation/rspackModuleFederationPlugin from @module-federation/enhanced/rspack
withModuleFederation from @nx/module-federation/webpackModuleFederationPlugin from @module-federation/enhanced/webpack
NxModuleFederationPlugin from @nx/module-federation/rspackModuleFederationPlugin from @module-federation/enhanced/rspack
setRemoteDefinitions, setRemoteDefinition from @nx/react/mfregisterRemotes from @module-federation/enhanced/runtime
loadRemoteModule from @nx/react/mfloadRemote from @module-federation/enhanced/runtime
@nx/react:module-federation-dev-server and the Rspack equivalentnx:run-commands running your bundler dev server
NxModuleFederationDevServerPluginNot supported
@nx/react:module-federation-static-server and the Rspack equivalentNot supported
@nx/react/module-federation and @nx/rspack/module-federation re-exportsSame as the @nx/module-federation entry they re-export
sharePackages, shareWorkspaceLibraries, mapRemotes from @nx/module-federationNot supported. Write the resolved values out, Step 4

The two unsupported entries coordinated processes rather than built code, and this migration skips them. NxModuleFederationDevServerPlugin is the Rspack form of the dev-server executor, which started each remote alongside the host so one nx serve brought the whole system up. @nx/react:module-federation-static-server served every remote you were not developing from a single port. You start the processes you want yourself now, which is why Step 5 moves remotes to a runtime manifest.

Keep NxAppRspackPlugin, NxAppWebpackPlugin, NxReactRspackPlugin, and NxReactWebpackPlugin. They are general bundler plugins and are not part of this removal.

Federation failures are quiet: a remote renders correctly while the host and the remote hold separate copies of a shared library. Write down what works now, so you have something to compare against:

  • Serve the host and its remotes, and visit the routes that load each remote.
  • Edit a file in the host and in a remote. Both should refresh the app.
  • Run a production build and note the artifacts you depend on, such as each remote's remoteEntry.js. The official plugin can order or name chunks differently, so treat the federation config as the thing to reproduce rather than a byte-identical bundle.
  • Copy each dist/apps/<app>/mf-stats.json out of dist before you rebuild. It holds the resolved shared map, exposes, and container name that Steps 3 and 4 reproduce.
  • Note each app's serve port, which the remote URLs in Step 5 use.

Search for every consumer before you edit one:

Terminal window
grep -rE "@nx/module-federation|@nx/react/module-federation|@nx/rspack/module-federation|@nx/react/mf|module-federation-dev-server|module-federation-static-server|module-federation-ssr-dev-server|NX_MF_DEV_REMOTES" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.json" --exclude-dir=node_modules --exclude-dir=.nx --exclude-dir=dist .

On Windows:

Terminal window
Get-ChildItem -Recurse -File -Include *.ts,*.tsx,*.js,*.json |
Where-Object FullName -notmatch 'node_modules|dist|\.nx' |
Select-String -Pattern '@nx/module-federation|@nx/react/module-federation|@nx/rspack/module-federation|@nx/react/mf|module-federation-dev-server|module-federation-static-server|module-federation-ssr-dev-server|NX_MF_DEV_REMOTES'

Record, per app, whether it exposes modules, consumes them, or does both, along with its production remote URLs, custom upstream options, and any runtime plugins.

Stop if the search turns up Angular apps or server-side rendering. Angular containers are ES modules and need different settings, covered in migrate Angular Module Federation. The server-side rendering executors have no equivalent here.

A workspace built by the host generator already has @module-federation/enhanced in its root package.json. Move it under dependencies if it sits in devDependencies, since application code imports the federation runtime, then run your package manager's install so the lockfile follows. Install it if it is missing:

Terminal window
npm add --save-prod @module-federation/enhanced

Step 7 runs the bundler CLI directly, so check that you have one. Rspack workspaces declare @rspack/cli already. A webpack workspace that only ever built through the Nx executor usually has no webpack-cli, even though node_modules/.bin/webpack exists:

Terminal window
npm add -D webpack-cli

Use pnpm add, yarn add, or bun add in place of npm add throughout.

Nx accepts a list of project names and resolves each one to a URL from the project graph. The official plugin takes explicit values, so write them out.

The container name is normalized. Every character outside [a-zA-Z0-9_$] becomes _, and so does a leading character that cannot start an identifier, so webpack-host publishes as webpack_host. Use that form for name.

The import alias stays the original project name. webpackRemoteA/Module keeps working because the key in remotes is the alias, not the container name.

Nx set filename to remoteEntry.js, set remoteType to script on webpack, and passed through the dts: false override from your withModuleFederation call. Set all three yourself. Without dts: false, every remote build logs [ Module Federation DTS ] Error ... #TYPE-001.

Drop any library entry. Nx never passed it to the plugin, and the { type: 'var', name: '<project-name>' } the generator wrote publishes an un-normalized global. On Rspack a var or window type was what turned on remoteType: 'script' and output.scriptType: 'text/javascript', so carry those two by hand if your Rspack config had such a library.

Each app lists only its direct remotes. A remote that consumes other remotes is both a provider and a consumer, so it gets both maps.

Generated apps also carry a webpack.config.prod.* file wired through build.configurations.production.webpackConfig, holding tuples such as ['webpackRemoteA', 'http://localhost:4701/']. Nx appended remoteEntry.js to any tuple URL without a filename, with or without a trailing slash. Fold both sets into one config keyed on NODE_ENV, with exactly one slash before remoteEntry.js.

apps/webpack-host/module-federation.config.ts
import type { ModuleFederationConfig } from '@nx/module-federation';
const config: ModuleFederationConfig = {
name: 'webpack-host',
remotes: ['webpackRemoteA', 'webpackRemoteB'],
};
export default config;

Remote configs keep their exposes map as it is. Step 5 replaces a top-level host's remotes map with a runtime manifest, so those URLs are needed only until then. A remote that consumes other remotes keeps the map it writes here.

Nx derived shared from the project graph, covering framework packages, their secondary entry points, every workspace library the app imports, and the npm dependencies of those libraries in turn. The official plugin shares only what you list.

Read the values from the baseline mf-stats.json rather than deriving them, and copy singleton, strictVersion, requiredVersion, and eager per entry:

apps/webpack-host/module-federation.config.js
module.exports = {
// ... name, filename, dts, remotes
shared: {
'@myorg/state': {
singleton: true,
requiredVersion: false,
version: '1.0.0',
},
react: {
singleton: true,
strictVersion: true,
requiredVersion: '^19.0.0',
eager: true,
},
'react-dom': {
singleton: true,
strictVersion: true,
requiredVersion: '^19.0.0',
eager: true,
},
'react-dom/client': {
singleton: true,
strictVersion: true,
requiredVersion: '^19.0.0',
},
'react/jsx-runtime': {
singleton: true,
strictVersion: true,
requiredVersion: '^19.0.0',
},
},
};

Four things decide whether the map you write matches the one Nx produced:

  • Secondary entry points are separate keys. A key such as pkg does not cover pkg/subpath requests, so list the subpaths the stats show.
  • additionalShared entries set their own values. The same package can appear with a different range and no strictVersion, which is why you copy rather than derive. An entry the stats do not list was never used and can go.
  • The stats rewrite one field. A callback that set requiredVersion: false shows up as requiredVersion: '^<version>'. For a workspace library, keep the false and the version your callback wrote.
  • A source-only workspace library needs an explicit version. It has no published version for the plugin to compare, and singleton: true alone did not make host and remotes resolve to one copy in testing. Use the version the library declares or one your team agrees on, and do not give the same version to two different implementations.

A config that called sharePackages, shareWorkspaceLibraries, or mapRemotes keeps the same shape once you replace each call with the values it returned, which the stats give you:

// before: shared: sharePackages(['react', 'react-dom'])
// after:
const sharedNpm = (names, version) =>
Object.fromEntries(
names.map((name) => [
name,
{ singleton: true, strictVersion: true, requiredVersion: version },
])
);
module.exports = {
// ...
shared: sharedNpm(['react', 'react-dom'], '^19.0.0'),
};

Step 5: move remotes to a runtime manifest

Section titled “Step 5: move remotes to a runtime manifest”

A runtime manifest holds the remote URLs in a file the host fetches at startup, so you change a URL without rebuilding and the host boots whether or not a given remote is running. It is the upstream default, documented in the manifest reference, and it is what replaces the static server this migration removes.

Register the remotes before the async boundary that imports your bootstrap, and translate loadRemoteModule(name, './Module') into loadRemote(name + '/Module').

apps/webpack-host/src/app/app.tsx
const RemoteA = React.lazy(() => import('webpackRemoteA/Module'));

Three things to get right:

  • Every value is a full entry URL. loadRemoteModule appended /remoteEntry.mjs to a bare origin such as http://localhost:4701, and registerRemotes passes the string through unchanged. output.publicPath: 'auto' then resolves each remote's chunks against wherever its entry loaded from. A deployment path prefix belongs in the manifest you ship, not the one you run locally, since @nx/web:file-server serves each app at its own root.
  • Each key is the remote's normalized container name. type: 'global' tells the runtime to read the container from globalThis[name], which is how Nx built these remotes, so the key has to match the name from Step 3. Registration replaces the alias: the registered name is also the prefix loadRemote takes, so a project named my-remote is my_remote in the manifest and loadRemote('my_remote/Module') at the call site.
  • The host loses its project-graph edge. A static import('<remote>/Module') gave Nx a host-to-remote edge. Once those imports are gone, add implicitDependencies on the host so the remotes still build first and nx affected still treats a remote change as affecting the host. You can also drop that host's <remote>/Module entries from tsconfig.base.json and any remotes.d.ts, since nothing imports those paths now.
  • Per-environment URLs move into the manifest. The NODE_ENV ternary from Step 3 has no equivalent here. The manifest is an asset, so ship the one that belongs to each environment, through a fileReplacements pair or by writing the file at deploy time.

Only a top-level host converts. A remote that consumes other remotes keeps the static remotes map from Step 3, because a host loads that remote's exposed module rather than its main.ts, so a registration placed there never runs.

A host that already calls init or registerRemotes from the upstream runtime is done, whatever generated it. A host that fetches a manifest and hands it to setRemoteDefinitions still needs the conversion above, since that helper is one of the removed APIs. A host that used setRemoteUrlResolver from @nx/react/mf to compute URLs keeps that logic and passes each resolved URL as the entry in the same registerRemotes call.

Step 6: swap the wrapper in the bundler config

Section titled “Step 6: swap the wrapper in the bundler config”

composePlugins(withNx(), withReact(), withModuleFederation(config)) returns an Nx-specific config function that the Rspack and webpack CLIs cannot run. Replace it with a standard config object that adds ModuleFederationPlugin itself, which is why build and serve move to the CLI in Step 7.

The options on your old build target become NxAppRspackPlugin or NxAppWebpackPlugin options. When you move them:

  • Paths such as main, index, tsConfig, assets, and styles resolve from the project root, so apps/webpack-host/src/main.ts becomes ./src/main.ts.
  • fileReplacements paths resolve from the workspace root. Drop a pair whose files do not exist.
  • Values that differed per named configuration become process.env.NODE_ENV === 'production' ternaries, which Step 7 sets per configuration. The generator's per-configuration optimization, outputHashing, sourceMap, namedChunks, and vendorChunk values match what the plugin derives from NODE_ENV, so they can go.
  • Leave no build options behind on the target. The plugin merges whatever the running target still carries in project.json, and those win.

The wrapper also mutated the bundler config on its way through, so the config below sets those values directly:

SettingValue
output.uniqueNameThe project name, not the normalized container name
output.publicPath'auto'
output.cleantrue. The Rspack app plugin defaults it, the webpack one does not, and without it a development build lands on top of production files
output.scriptType'text/javascript' on webpack
resolve.modulesnode_modules plus the workspace root, which is what the wrapper set and what an exposes path written from the workspace root needs
optimization.runtimeChunkfalse, and the same as an app-plugin option, which overwrites the top-level value
commonChunkfalse in the app-plugin options. NxAppWebpackPlugin defaults it to true where withNx() left it unset, which adds a common.<hash>.js per remote
splitChunks.cacheGroupsdefault and common set to false, only if the old config used NxModuleFederationPlugin. withModuleFederation did not set them
lazyCompilation (Rspack)false. rspack-cli turns it on in dev mode and it breaks federation
devServer.hottrue on every app, remotes included, so editing a remote updates the host instead of resetting its state
devServer.headersAccess-Control-Allow-Origin, which is what lets a host on one port fetch remoteEntry.js from another
apps/rspack-host/rspack.config.js
const { NxAppRspackPlugin } = require('@nx/rspack/app-plugin');
const { NxReactRspackPlugin } = require('@nx/rspack/react-plugin');
const {
ModuleFederationPlugin,
} = require('@module-federation/enhanced/rspack');
const { join } = require('node:path');
const mf = require('./module-federation.config');
module.exports = {
lazyCompilation: false,
output: {
path: join(__dirname, '../../dist/apps/rspack-host'),
publicPath: 'auto',
uniqueName: 'rspack-host',
clean: true,
},
resolve: { modules: ['node_modules', join(__dirname, '../..')] },
optimization: {
runtimeChunk: false,
// only if the old config used NxModuleFederationPlugin
splitChunks: { cacheGroups: { default: false, common: false } },
},
devServer: {
port: 4703,
hot: true,
static: false,
headers: { 'Access-Control-Allow-Origin': '*' },
historyApiFallback: { index: '/index.html', disableDotRule: true },
},
plugins: [
new NxAppRspackPlugin({
// ... your existing options, minus outputPath
runtimeChunk: false,
commonChunk: false,
}),
new NxReactRspackPlugin(),
new ModuleFederationPlugin(mf),
],
};

The Nx dev server also derived devMiddleware.publicPath from baseHref, mapped publicHost to client.webSocketURL, and passed proxyConfig, allowedHosts, and the ssl options through. A workspace that served under a sub-path, behind a proxy, or over HTTPS carries each of those into devServer by hand.

The official plugin federates the build. It does not coordinate processes, so the host's serve no longer starts its remotes and you wire up the set you want.

Point build and serve at the bundler CLI, with NODE_ENV standing in for the named configurations the executor used to pass:

apps/rspack-host/project.json
{
"targets": {
"build": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/dist/apps/rspack-host"],
"defaultConfiguration": "production",
"options": {
"command": "rspack build --config rspack.config.js",
"cwd": "apps/rspack-host",
},
"configurations": {
"production": { "env": { "NODE_ENV": "production" } },
"development": { "env": { "NODE_ENV": "development" } },
},
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"defaultConfiguration": "development",
"options": {
"command": "rspack serve --config rspack.config.js",
"cwd": "apps/rspack-host",
},
"configurations": {
"development": { "env": { "NODE_ENV": "development" } },
"production": { "env": { "NODE_ENV": "production" } },
},
},
"serve-static": {
"executor": "@nx/web:file-server",
"continuous": true,
"dependsOn": ["build"],
"options": {
"staticFilePath": "dist/apps/rspack-host",
"port": 4703,
"spa": true,
"watch": false,
},
},
},
}

On webpack, the commands are webpack build --config webpack.config.js and webpack serve --config webpack.config.js.

With a runtime manifest from Step 5, you serve the host on its own and add remotes as you need them, live or from their last build:

Terminal window
nx serve rspack-host
nx run-many -t serve-static -p rspackRemoteA rspackRemoteB

One behavior change to expect: the old executor set NODE_ENV=production for every configuration, so a development build that now runs with NODE_ENV=development gets development-mode output such as react/jsx-dev-runtime, which your shared map does not list because the baseline stats came from a production build. A development serve therefore shares slightly less than the old executor did. Set NODE_ENV=production on both configurations if you want the old output back.

serve-static serves the output with staticFilePath and leaves buildTarget unset, since the file server rebuilds the app itself whenever that option is present, which duplicates the dependsOn build and can fail with Recursive task invocation detected from inside an e2e run that already built it. Keep spa: true on every app, or a refresh on a deep route returns a 404.

Point an e2e project's web server command at nx run-many -t serve-static -p <host> <remotes>, with its URL on the host's serve-static port.

Finally, remove the targetDefaults entries in nx.json keyed on @nx/rspack:rspack or @nx/webpack:webpack. The NX_MF_DEV_REMOTES input they carried is dead. Move anything else they held, such as cache, inputs, and dependsOn, onto the generic build default. Target defaults also take a filter, so settings that applied to one executor can apply to a set of projects instead:

nx.json
{
"targetDefaults": {
"build": [
{
"filter": { "projects": ["apps/*", "!apps/legacy"] },
"cache": true,
"inputs": ["production", "^production"],
},
],
},
}

See the task pipeline reference for the full syntax.

A green build is not the acceptance criterion. The migration is done when everything you recorded in the baseline works again, plus these:

  • Production and development configurations both build with --skipNxCache, production last, since both write the same mf-stats.json.
  • tsc -p apps/<app>/tsconfig.app.json --noEmit passes for each app. The app plugin type-checks asynchronously, so a build exits 0 on type errors.
  • Each app's production mf-stats.json lists the same container name, exposes, and shared entries as the baseline. A host you moved to a runtime manifest has an empty build-time remotes by design, so check its manifest against the baseline URLs instead.
  • The e2e suite passes against the replacement web server.

Record baseline failures separately rather than weakening an assertion to get a pass.

Once the new setup builds and runs, delete the files it replaced: module-federation.config.ts, rspack.config.ts or webpack.config.ts, and any *.config.prod.*. Leaving them in place keeps the graph reading the old values.

Then remove the package, once no config or source file references the removed APIs:

Terminal window
npm remove @nx/module-federation

@nx/rspack on v23 depends on @nx/module-federation, so on an Rspack workspace it stays in node_modules and its absence from package.json proves nothing. @nx/react lists it as an optional peer, so on a webpack-only workspace npm remove takes it out. Keep @module-federation/enhanced as a direct dependency and commit the lockfile.

  • --devRemotes selection. Which remotes run live and which serve from a build is now the set of targets you start.
  • Runtime library control. withModuleFederation attached a runtime plugin whenever the dev-server executor set NX_MF_DEV_REMOTES, so a live remote's copy of a shared library won over a static one. Nothing sets that variable now.
  • Automatic remote fallback. A host whose remote is unavailable fails the request, so add your own error boundary if you need one.

For anything beyond this migration, such as runtime plugins, promise-based remotes, or cross-version deployments, use the official configuration reference and runtime documentation.

Last updated: