Setting Up Vite with Federation Plugins #

Vite gives you instant dev servers and ESM-native builds, but it has no built-in equivalent to Webpack’s ModuleFederationPlugin. Module Federation in Vite is bolted on by a plugin, and that plugin works very differently under the hood — it relies on Rollup’s static analysis and emits an ESM remoteEntry.js instead of a JSONP container. The moment you assume the two behave identically, things break.

The classic failure mode looks like this: your remote builds fine, the host loads it locally, and then production throws Top-level await is not available in the configured target environment, or React renders twice because two copies slipped into the bundle, or the remote’s styles never show up because nobody told Vite to inline them. Each of these traces back to a config detail that Webpack handled for you and Vite does not.

This guide is part of the broader Webpack & Vite Module Federation Implementation work, and it focuses narrowly on getting a Vite remote and a Vite (or mixed) host wired together reliably on Vite 5/6. Once the baseline is solid, the deeper guides below take over: optimizing chunk splitting for remote apps for payload control, Vite federation plugin parity with Webpack for the feature gaps you will eventually hit, and handling CSS and asset loading in Vite remotes for the styling pitfalls that bite almost everyone.

What breaks, and why #

The single biggest source of confusion is the build model. Webpack’s federation runtime is a JSONP-based container that resolves shared modules at execution time with a negotiation algorithm. The Vite plugin instead leans on Rollup: it statically rewrites your exposes and remotes into ESM, and the shared-dependency logic is a thinner shim. That gap shows up in four ways.

Top-level await. The generated remoteEntry.js uses top-level await to resolve shared modules. Browsers support it, but only if the build target allows it. Leave build.target at the default and the build either fails or silently downlevels into something broken.

Shared singletons are best-effort. Vite’s shared handling does not enforce a singleton as strictly as Webpack’s runtime. If your version ranges don’t line up, you can end up with two Reacts and a wall of Invalid hook call errors at runtime instead of a clean negotiation.

Styling and assets are not automatic. A Webpack remote injects its CSS through the runtime. A Vite remote, built as a library, may emit a separate CSS file that the host never requests — so the component mounts unstyled.

Dev mode is not the build. Vite’s dev server serves unbundled source modules; the federation transform happens during the Rollup build. The @originjs plugin therefore does not serve a working remoteEntry.js from vite dev, which surprises everyone coming from Webpack’s dev-server federation.

Key objectives #

Vite remote build feeding a host at runtime The remote build emits remoteEntry.js plus exposed chunks; the host fetches the manifest at runtime and shares one React copy. Vite Remote (build) vite build (lib mode) remoteEntry.js (ESM manifest) Dashboard chunk + CSS asset fetch at runtime over HTTP / CDN Host (runtime) import('remote/Dashboard') Suspense + ErrorBoundary shared React single instance One React copy is negotiated across both apps.
The remote build emits an ESM manifest and chunks; the host fetches them at runtime and shares a single React instance.

Choosing a plugin: @originjs vs @module-federation/vite #

Before writing config, pick a plugin, because the two diverge in ways that affect everything downstream.

@originjs/vite-plugin-federation is the mature, widely deployed option. It implements its own lightweight federation runtime on top of Rollup output. It is reliable in build mode, has no extra runtime dependency, and is the path of least resistance for a Vite-only stack. Its weaknesses are real, though: dev-mode federation is unreliable, the shared negotiation is simpler than Webpack’s, and interop with Webpack remotes is approximate.

@module-federation/vite is newer and is maintained alongside the official Module Federation 2.0 runtime (@module-federation/runtime). It speaks the same manifest protocol as modern Webpack and Rspack remotes, supports a working dev server, exposes runtime plugins for dynamic remotes and retries, and shares the singleton-negotiation semantics you get on Webpack. If you run a mixed Webpack-plus-Vite estate, need live remote editing, or want federated type-sharing, it is the more future-proof choice. The trade is a heavier runtime and a slightly larger API surface.

A practical rule: greenfield, Vite-only, build-and-deploy remotes — start with @originjs. Mixed bundlers, dev-mode HMR on remotes, or strict Webpack parity — start with @module-federation/vite. The two are config-compatible enough that the host/remote name, exposes, remotes, and shared shapes below carry over with only import-name changes. The deeper trade-offs live in the Vite federation plugin parity with Webpack guide.

Setup and configuration #

Install the plugin in both apps. The examples below use @originjs/vite-plugin-federation; the @module-federation/vite equivalent is noted where it differs.

npm install -D @originjs/vite-plugin-federation
# or, for the official-runtime plugin:
# npm install -D @module-federation/vite

The remote’s vite.config.ts #

A remote’s job is to expose components and emit remoteEntry.js. Every line in the build block below matters.

// 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' becomes the runtime key the host references.
      name: 'remote_ui',
      // 'filename' is the manifest the host fetches. Keep it stable.
      filename: 'remoteEntry.js',
      // 'exposes' maps a public path to a real source file.
      exposes: {
        './Dashboard': './src/components/Dashboard.tsx',
      },
      // 'shared' must MATCH the host for singletons to deduplicate.
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
  ],
  build: {
    // REQUIRED: the generated remoteEntry uses top-level await.
    target: 'esnext',
    // Keep minify on for prod; disable only while debugging the manifest.
    minify: 'esbuild',
    // Inline CSS so consumers get styles (see the CSS guide).
    cssCodeSplit: false,
  },
});

Two details trip people up. First, build.target: 'esnext' is not optional — the plugin emits top-level await, and any lower target breaks the build. Second, the shared block here must be a mirror of the host’s. The negotiation that keeps React a singleton only works when both sides declare the same package with compatible version ranges; the mechanics of that negotiation are the subject of managing shared dependencies at runtime.

With @module-federation/vite the config object is almost identical, but you add manifest: true to emit the MF2 manifest and you may pass runtimePlugins for retry/fallback behavior. The exposed-path and shared shapes are unchanged, which is why migrating between the two is mostly a swap of the import line.

The host’s vite.config.ts #

The host declares the remotes it consumes by URL. In development that URL points at the remote’s preview server; in production it points at the deployed manifest.

// 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_app',
      remotes: {
        // Key here is what you import: import('remote_ui/Dashboard')
        remote_ui: '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' },
});

The remote URL is the single most common deploy-time mistake. Locally it is an absolute http://localhost:5001/...; in production it must point at the CDN or origin path where the remote’s assets/remoteEntry.js actually lands. Hard-coding the dev URL into a production build is the reason a remote “works on my machine” and 404s in staging. Drive it from an environment variable so the same source produces a correct URL per environment:

// host/vite.config.ts — environment-driven remote URL
const REMOTE_UI_URL =
  process.env.VITE_REMOTE_UI_URL ?? 'http://localhost:5001/assets/remoteEntry.js';

federation({
  name: 'host_app',
  remotes: { remote_ui: REMOTE_UI_URL },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
});

TypeScript: declaring the remote module #

Because the import path resolves only at build time, TypeScript has no type for remote_ui/Dashboard and will flag it. Add an ambient declaration so the host type-checks:

// host/src/remotes.d.ts
declare module 'remote_ui/Dashboard' {
  import type { ComponentType } from 'react';
  const Dashboard: ComponentType<{ orgId: string }>;
  export default Dashboard;
}

For real cross-app type safety rather than hand-written stubs, publish the remote’s types and consume them — covered in sharing TypeScript types across federated remotes.

Integration: wiring host and remote #

With both configs in place, consuming a remote component is an async import wrapped in a boundary. Treat every remote as a network call that can fail.

// host/src/FederatedView.tsx
import { lazy, Suspense } from 'react';
import { ErrorBoundary } from './ErrorBoundary';

// The path is "<remote name>/<exposed key>".
const RemoteDashboard = lazy(() => import('remote_ui/Dashboard'));

export function FederatedView() {
  return (
    <ErrorBoundary fallback={<p>Dashboard is temporarily unavailable.</p>}>
      <Suspense fallback={<p>Loading dashboard…</p>}>
        <RemoteDashboard orgId="acme" />
      </Suspense>
    </ErrorBoundary>
  );
}

The ErrorBoundary is not decoration. A remote can be down, mid-deploy, or blocked by CORS, and any of those rejects the dynamic import. Without a boundary, one unreachable remote takes down the whole host render tree. This separation of failure domains is exactly what the broader Webpack & Vite Module Federation Implementation approach is meant to buy you.

A minimal boundary that also reports the failure is worth keeping in the repo:

// host/src/ErrorBoundary.tsx
import { Component, type ReactNode } from 'react';

export class ErrorBoundary extends Component<
  { fallback: ReactNode; children: ReactNode },
  { failed: boolean }
> {
  state = { failed: false };
  static getDerivedStateFromError() {
    return { failed: true };
  }
  componentDidCatch(error: unknown) {
    // Ship this to telemetry, tagged with the remote name and version.
    console.error('[federation] remote failed to load', error);
  }
  render() {
    return this.state.failed ? this.props.fallback : this.props.children;
  }
}

If you also pull in a Webpack remote, note that the wiring on the host is the same import('name/Component') shape — the difference is entirely in how each remote was built, which is why the Configuring Webpack Module Federation guide stays relevant even in a Vite-first stack.

Dev server: CORS and proxying #

In development the host fetches the remote’s manifest cross-origin, so the remote must allow it:

// remote/vite.config.ts — dev server block
export default defineConfig({
  server: {
    port: 5001,
    cors: true,
    // 'origin' makes generated asset URLs absolute so the host
    // resolves chunks against the remote, not against itself.
    origin: 'http://localhost:5001',
  },
});

SSR notes #

@originjs/vite-plugin-federation is built for browser ESM and does not have a supported server-render path; its top-level-await remoteEntry.js and import.meta-relative chunk URLs assume a browser. If you need server rendering, two routes work. The pragmatic one is to render the host on the server and load remotes only on the client — gate the federated import behind a mount effect or render the Suspense subtree client-side, so the remote never executes during SSR. The more capable route is @module-federation/vite together with @module-federation/node, which provides a server-side runtime that can resolve remotes during SSR and hydrate them on the client. For most teams, client-only remotes inside an otherwise server-rendered host is the lower-risk choice.

Edge cases #

Dev mode does not run federation the same way #

This is the sharpest edge in Vite federation. The @originjs plugin only fully wires exposes/remotes in build output, not in the dev server’s unbundled module graph. In practice that means you cannot reliably vite dev the remote and consume it live from the host. The standard workaround is to build the remote and serve it with vite preview:

# remote/
vite build && vite preview --port 5001
# host/  (this one can run in dev)
vite dev

You lose HMR on the remote, but you get a faithful remoteEntry.js. Teams that need live remote editing should use @module-federation/vite, whose dev server serves a real manifest — see the parity guide below.

Dev server returns HTML instead of remoteEntry.js #

A close cousin: if you point the host at a remote running under vite dev (not preview), GET /assets/remoteEntry.js falls through to Vite’s SPA fallback and returns index.html. The host then tries to evaluate HTML as a module and throws an opaque parse error. The diagnosis is always “open the manifest URL in a browser” — if you see HTML, you are hitting the dev server, not a built manifest.

Build target mismatch and top-level-await errors #

If any consumer in your matrix targets older browsers, target: 'esnext' will ship syntax they cannot parse, but lowering the target re-triggers Top-level await is not available in the configured target environment. Set a realistic floor that still permits top-level await — for example ['chrome89', 'safari15', 'firefox89'] — and validate in a real browser, not just a passing build. Targets below this break the manifest.

Eager-loading errors on shared deps #

Vite throws Shared module is not available for eager consumption when a shared singleton is imported synchronously before the federation runtime has had a chance to initialize — typically when the host’s main.tsx imports React and renders at module top level. The fix is the same dynamic-bootstrap pattern Webpack needs: move the app entry behind an async import so federation initializes first.

// host/src/main.tsx — defer the real entry so federation initializes first
import('./bootstrap');
// host/src/bootstrap.tsx — the actual app entry
import { createRoot } from 'react-dom/client';
import { App } from './App';

createRoot(document.getElementById('root')!).render(<App />);

Shared dedup gaps vs Webpack #

Webpack’s runtime negotiates a single shared instance across all containers at execution time. The @originjs plugin’s shared is shallower: it relies on matching requiredVersion ranges and identical lockfile resolution, and it will silently fall back to bundling a second copy when ranges drift. When host and remote resolve different React versions, you get two Reacts and Invalid hook call. Pin the same range on both sides, align lockfiles, and verify at runtime (see Testing). If you genuinely need strict singletons across mixed bundlers, @module-federation/vite enforces them the way Webpack does.

Partial loads and stale manifests #

A deploy that updates hashed chunks but serves a cached remoteEntry.js leaves the host pointing at chunk names that no longer exist — a partial load that fails after the manifest resolves. The fix is a cache strategy that keeps the manifest fresh while chunks stay immutable (see Deployment).

Testing and validation #

Validate the integration in three layers, cheapest first.

  1. Manifest reachability. After vite build && vite preview on the remote, open http://localhost:5001/assets/remoteEntry.js directly. It should return JavaScript, not a 404 or an HTML fallback.

  2. Single-instance check. In the running host, confirm React is shared, not duplicated:

// Paste in the browser console of the running host.
const reacts = performance.getEntriesByType('resource')
  .filter((e) => /react(-dom)?[.@-].*\.js/.test(e.name));
console.log('react-ish chunks loaded:', reacts.map((r) => r.name));
// Expect ONE react and ONE react-dom URL — duplicates mean the
// singleton negotiation failed.
  1. Failure path. Stop the remote server and reload the host. The ErrorBoundary fallback should render and the rest of the host should stay interactive. This proves your failure domains are isolated.

For CI, treat the remote URL as configuration and run a smoke test that boots the host against a built remote, then asserts the exposed component mounts. A headless Playwright check is enough:

// e2e/federation.spec.ts
import { test, expect } from '@playwright/test';

test('host mounts the federated remote', async ({ page }) => {
  await page.goto(process.env.HOST_URL ?? 'http://localhost:5000');
  // The exposed Dashboard renders a known testid.
  await expect(page.getByTestId('remote-dashboard')).toBeVisible();
});

Deployment #

Deploying federated apps independently is the whole point, and the cache strategy is what makes independent deploys safe.

Smaller chunks make every one of these steps cheaper, which is why optimizing chunk splitting for remote apps is the natural next step once the baseline ships.

Common pitfalls #

Issue Root cause & resolution
Build fails with “Top-level await is not available” The generated remoteEntry.js uses top-level await. Set build.target: 'esnext' (or a target list that supports TLA) in both apps.
Invalid hook call / React rendered twice Singleton negotiation failed — host and remote loaded different React copies. Pin identical requiredVersion ranges with singleton: true on both sides and align lockfiles.
Shared module is not available for eager consumption The host renders at module top level before federation initializes. Defer the real entry behind a dynamic import('./bootstrap').
Remote works in vite dev of remote but host can’t consume it The plugin only fully wires federation in build output. Run vite build && vite preview for the remote, or switch to @module-federation/vite for dev-mode support.
Host gets HTML when fetching remoteEntry.js You pointed at the remote’s vite dev server, which returns the SPA fallback. Serve the built manifest via vite preview or production hosting.
remoteEntry.js 404s in production The host’s remotes URL still points at localhost or the wrong CDN path. Make the remote URL environment-specific and verify the deployed asset path.
Remote component mounts unstyled Vite split the remote’s CSS into a file the host never requests. Inline it (cssCodeSplit: false) or load it explicitly — see the CSS guide.
Stale manifest after deploy remoteEntry.js was cached, so the host requests chunk hashes that no longer exist. Serve the manifest no-cache and hashed chunks immutable.

FAQ #

Which Vite federation plugin should I use, @originjs/vite-plugin-federation or @module-federation/vite?

@originjs is the mature, widely deployed choice and is fine for most Vite-only, build-mode setups with no extra runtime. @module-federation/vite is newer, tracks the official Module Federation 2.0 runtime, enforces singletons like Webpack, and has a working dev server. If you need live HMR on remotes, strict Webpack parity, or mixed-bundler interop, choose the latter; otherwise @originjs is the safe default.

Why can’t I just vite dev the remote and consume it live like Webpack?

Because the @originjs plugin transforms exposes/remotes during the Rollup build, not in Vite’s dev server module graph. The dev server serves unbundled source and returns the SPA fallback for remoteEntry.js. Use vite build && vite preview for the remote during development, or switch to @module-federation/vite, which serves a real manifest from its dev server.

Can a Vite host consume a Webpack remote (and vice versa)?

Yes, with care. The host import shape is the same, but the shared-dependency runtimes differ, so singletons must be configured deliberately on both sides. Keep shared packages and version ranges identical, prefer @module-federation/vite for closer protocol compatibility, and lean on the managing shared dependencies at runtime guidance to avoid duplicate instances.

How do I keep React a true singleton across Vite remotes?

Declare react and react-dom with singleton: true and an identical requiredVersion in every app’s shared block, and make sure your lockfiles resolve the same version. The @originjs plugin’s dedup is best-effort and falls back to a second copy when ranges drift, so verify at runtime by checking that only one react chunk loads in the browser, as shown in the validation section. For strict enforcement, use @module-federation/vite.