Vite Federation Plugin Parity with Webpack #
Migrating a remote from Webpack’s ModuleFederationPlugin to a Vite federation plugin only works if you know exactly which options carry over, which are renamed, and which silently do nothing — because the gaps are where production breaks.
This is a practical mapping guide. It pairs the canonical Webpack federation config against the two Vite options — @originjs/vite-plugin-federation and @module-federation/vite — option by option, then covers the build-target and dev-mode caveats that have no Webpack analogue. It assumes you have already read Setting Up Vite with Federation Plugins and know the basics of Configuring Webpack Module Federation.
Prerequisites #
Pin these versions; federation plugins are version-sensitive and break across minors.
- Node.js
>=18.17(the federation runtime relies on modern ESM resolution). vite@^5.4with@originjs/vite-plugin-federation@^1.3.6or@module-federation/vite@^1.2(the latter is closer to Webpack semantics).- On the Webpack side:
webpack@^5.90(federation ships in core viawebpack.container.ModuleFederationPlugin). - A working remote that exposes at least one module, and a host that consumes it.
react@^18.2andreact-dom@^18.2declared identically in both host and remotepackage.json— version drift is the number-one cause of duplicate-singleton failures.
# Remote (Vite)
npm i -D @originjs/vite-plugin-federation
# or, for closer Webpack parity:
npm i -D @module-federation/vite
How the two runtimes differ (in one picture) #
The core mental shift: Webpack federation negotiates shared dependencies at runtime through a shared scope object. @originjs/vite-plugin-federation resolves much of this at build time and emits ESM, which is why it needs build.target: 'esnext' and why dev mode is limited. @module-federation/vite wraps the official @module-federation/runtime, so it behaves much more like Webpack.
requiredVersion and eager are where parity breaks.The option mapping table #
| Webpack option | @originjs/vite-plugin-federation |
@module-federation/vite |
Notes / gaps |
|---|---|---|---|
name |
name |
name |
Identical. Must be a valid JS identifier. |
filename |
filename |
filename |
Defaults to remoteEntry.js; keep it consistent across hosts. |
exposes |
exposes |
exposes |
Same { './Key': './path' } shape. |
remotes (object/promise) |
remotes (object, URL string) |
remotes (object, supports @name@url) |
origin.js needs the full name@url or a plain URL; no runtime promise form. |
shared (array) |
shared (array or object) |
shared (array or object) |
Both accept the shorthand array. |
shared.singleton |
shared.<dep>.singleton |
shared.<dep>.singleton |
Honored by both. |
shared.requiredVersion |
shared.<dep>.requiredVersion |
shared.<dep>.requiredVersion |
origin.js support is partial — range enforcement is weaker; @module-federation/vite matches Webpack. |
shared.eager |
(ignored) | shared.<dep>.eager |
origin.js eager-loads differently; setting it can cause init-order errors. |
shared.import / version |
limited | supported | Fine-grained control only in @module-federation/vite. |
library / shareScope |
(implicit) | shareScope |
origin.js uses a single implicit scope. |
runtime / dts |
n/a | plugin options | Type generation differs from Webpack’s. |
Step 1: The canonical Webpack remote #
Start from a known-good Webpack remote so the Vite ports are an apples-to-apples comparison.
// remote/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
mode: 'production',
output: { publicPath: 'auto' },
plugins: [
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
'./CartWidget': './src/CartWidget.tsx',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
};
Step 2: The same remote on @originjs/vite-plugin-federation #
The shape is nearly identical — the catch is in build.
// remote/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
react(),
federation({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
'./CartWidget': './src/CartWidget.tsx',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
build: {
target: 'esnext', // federation output uses top-level await — older targets fail to build
minify: false, // start unminified; some terser passes mangle the federation init
cssCodeSplit: false,
},
});
Two caveats with no Webpack equivalent:
build.targetmust beesnext(orchrome89+). The emittedremoteEntry.jscontains top-levelawait, which throwsTop-level await is not available in the configured target environmentones2019and below.minifycan break the runtime registration in older plugin versions. Get it working unminified first, then re-enableminify: 'esbuild'and re-verify.
Step 3: The same remote on @module-federation/vite #
If you need true Webpack semantics — proper requiredVersion enforcement, working eager, a shared scope — use the official plugin. The config is almost copy-paste from Webpack.
// remote/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';
export default defineConfig({
plugins: [
react(),
federation({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
'./CartWidget': './src/CartWidget.tsx',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
build: { target: 'esnext' },
});
This plugin keeps a runtime shared scope, so a Vite remote built with it and a Webpack remote can negotiate the same singleton instance — the exact scenario covered in Sharing Singletons Across Webpack and Vite Remotes.
Step 4: Wire the host to consume the Vite remote #
The host config is symmetric. Reference the remote by name and the URL of its remoteEntry.js.
// host/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
react(),
federation({
name: 'host',
remotes: {
// origin.js wants the bare URL to remoteEntry.js
checkout: 'http://localhost:5001/assets/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
build: { target: 'esnext' },
});
Consume the exposed module with a dynamic import; let a Suspense boundary handle the async load.
// host/src/App.tsx
import { lazy, Suspense } from 'react';
const CartWidget = lazy(() => import('checkout/CartWidget'));
export default function App() {
return (
<Suspense fallback={<p>Loading cart…</p>}>
<CartWidget />
</Suspense>
);
}
Add the module declaration so TypeScript stops complaining about the virtual checkout/* import:
// host/src/remotes.d.ts
declare module 'checkout/CartWidget' {
const Component: React.ComponentType;
export default Component;
}
Step 5: Handle the dev-server gap #
This is the biggest practical difference from Webpack, which serves a live remoteEntry.js from its dev server. @originjs/vite-plugin-federation does not emit remoteEntry.js during vite dev — the remote must be built and served as static files.
# Remote: build, then preview (serves the real remoteEntry.js)
npm run build && npx vite preview --port 5001 --strictPort
# Host: dev works against the previewed remote
npm run dev
For an inner loop on the host while iterating on the remote, run the remote build in watch mode and keep vite preview pointed at dist/:
# Remote terminal A
npx vite build --watch
# Remote terminal B
npx vite preview --port 5001 --strictPort
@module-federation/vite has better dev-mode support and can serve the remote entry from the dev server directly, which is one more reason to prefer it for active multi-team development.
Verification #
Confirm parity, not just “it loads.”
- Build both remotes.
npm run buildin the Webpack remote and each Vite remote should each emitremoteEntry.js. Diff the exposed keys:grep -o 'CartWidget' remote-webpack/dist/remoteEntry.js | head -1 grep -o 'CartWidget' remote-vite/dist/assets/remoteEntry.js | head -1 - Host loads the Vite remote. Open the host, watch the Network tab, and confirm a single
200forremoteEntry.jsand the lazy chunk, with no second copy ofreactorreact-dom. - Assert one React instance. In DevTools console:
// Should log the SAME version once; a second value means a duplicate slipped in console.log(window.__FEDERATION__?.__INSTANCES__?.map(i => i.name)); - Singleton check at runtime. Render the remote and host side by side and confirm a shared context (e.g. a theme provider) resolves in the remote. If hooks throw
Invalid hook call, you have two React copies — go back to thesharedblock.
Troubleshooting #
Symptom: build fails with Top-level await is not available in the configured target environment.
Diagnosis: build.target is below esnext/chrome89. Fix: set build.target: 'esnext' in both host and remote vite.config.ts. If a downstream tool re-sets the target, also pass esbuild: { target: 'esnext' }.
Symptom: react is downloaded twice; Invalid hook call in the remote.
Diagnosis: shared dedup failed because requiredVersion ranges diverge or singleton is missing. origin.js enforces ranges weakly, so a ^18.2.0 host and ^18.3.0 remote may not dedupe. Fix: pin identical versions in both package.json files, set singleton: true, and prefer @module-federation/vite for strict enforcement.
Symptom: host gets a 404 / HTML for remoteEntry.js in dev.
Diagnosis: vite dev does not generate remoteEntry.js for origin.js. The dev server returns the SPA fallback HTML instead. Fix: build the remote and serve it with vite preview (or a static server) before starting the host dev server; point the host remotes URL at the previewed origin.
Symptom: Shared module react was not eager-loaded or init-order errors.
Diagnosis: an eager: true flag copied from Webpack — origin.js ignores eager and the official plugin loads eagerly in a different order. Fix: drop eager: true and rely on the default lazy negotiation; only the host’s bootstrap should be the async-import shell, not the shared deps.