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.

# 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.

Webpack to Vite federation option mapping Each Webpack ModuleFederationPlugin option maps to a Vite federation plugin equivalent, with two options marked as partial or build-time only. Webpack ModuleFederationPlugin Vite federation plugin name / filename exposes remotes shared (singleton / requiredVersion) shared.eager name / filename (same) exposes (same shape) remotes (URL string) shared (partial: requiredVersion limited) eager: origin.js ignores it Solid = direct parity · Dashed = gap or no-op
Most options map one-to-one; 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:

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.”

  1. Build both remotes. npm run build in the Webpack remote and each Vite remote should each emit remoteEntry.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
  2. Host loads the Vite remote. Open the host, watch the Network tab, and confirm a single 200 for remoteEntry.js and the lazy chunk, with no second copy of react or react-dom.
  3. 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));
  4. 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 the shared block.

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.