DEV Community

Cover image for Migrating a Webpack-Era Federated Module to Vite Without Breaking the Host Contract
Mateus Oliveira
Mateus Oliveira

Posted on • Originally published at Medium

Migrating a Webpack-Era Federated Module to Vite Without Breaking the Host Contract

A practical guide to migrating a federated remote to Vite, based on lessons from a real migration.

I was tasked with updating a legacy React application that did not support Module Federation. That integration was added first so the app could run as a remote inside a larger host application. Later, the remote needed to migrate from Create React App (CRA) to Vite. By that point, the host already depended on the remote's loading behavior. The tricky part was not replacing CRA with Vite. It was preserving the runtime contract while only the remote changed bundlers.

If you own a CRA or webpack-era remote that still has to load cleanly inside an existing host, this post covers the cleanup work beforehand, the core CRA-to-Vite swap, the federation-specific deployment fixes, and a local dev harness for debugging the full host loading sequence without redeploying every change.

Terms for reference

  • CRA: Create React App. For years it was the default easy on-ramp for React apps before being deprecated in 2025.
  • CRACO: Create React App Configuration Override
  • Module Federation: A way for one application to load code from another at runtime instead of bundling everything together up front.
  • Host: The application that loads another app at runtime.
  • Remote: The application that exposes code for the host to load.
  • Runtime contract: The files and exported APIs the host already expects.

Why migrate?

  1. Dependabot alerts. The biggest issue was that the CRA dependency tree had kept accumulating a number of high-risk Dependabot alerts, and patching around them was getting harder to justify.

  2. Slow builds. CRA and webpack took over a minute for a cold-start build.

  3. Too many config layers. CRACO was overriding CRA's webpack config, plus custom build scripts for module federation.

  4. Stale tooling. ESLint was still on the legacy .eslintrc format. Jest had its own separate config.

  5. Dependency rot. Years of Dependabot patches left dozens of manual resolutions in the dependency manifest that nobody fully understood anymore.

The goal was not just "swap the build tool." It was to reduce dependency risk, simplify the toolchain, and leave the project in a state that another engineer could pick up. Vite had already earned a strong reputation. What was different now was that there was finally enough maintenance pressure to justify spending sprint time on the migration.

Step 1: Remove dead weight

Before touching the build tool, everything that would conflict with Vite or had become dead weight needed to go.

Remove webpack and Babel dependencies

Some dependencies werent really "dependencies" so much as assumptions about the old toolchain:

  • Babel macros like preval.macro that ran at compile time. Vite doesnt run your app through the same pipeline that a CRA stack does.
  • CRA-specific packages like react-scripts, craco, react-app-rewired
  • Packages like jsonwebtoken that were built for Node.js and rely on polyfills that webpack injected automatically. Vite does not do this, so if anything in the browser code imports Node.js built-ins like crypto or Buffer, it will break.

Remove stale deps and manual resolutions

The package dependencies were audited and around a dozen were removed. Then the pile of old manual resolutions that had accumulated from years of Dependabot fixes was cleared out. Most of those overrides were for transitive deps of packages that were already gone.

Check for Sass compatibility

Worth checking early: a shared design system was still using deprecated Sass @import patterns, and it had to be updated before the new toolchain would build cleanly.

Step 2: The CRA-to-Vite swap

With the codebase cleaned up, the core migration came down to a few straightforward steps:

  1. Replace CRA/CRACO config with a single vite.config.ts
  2. Move index.html from public/ to the project root and point it at the module entry
  3. Rename REACT_APP_* env vars to VITE_*; in application code, replace process.env usage with import.meta.env
  4. Update any legacy ReactDOM.render calls to createRoot
  5. Modernize surrounding tooling where it made sense, like moving ESLint to flat config
  6. Update scripts for vite, vite build, vite preview, and vitest

Replace Jest with Vitest

Once Vite was the build tool, Vitest was the obvious test runner. It shares the same config file, understands the same path aliases, and removed a lot of separate config glue.

Add the test config directly to vite.config.ts:

import { defineConfig } from 'vite';

export default defineConfig({
  // ...build config above...
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    coverage: {
      reporter: ['text', 'html'],
      include: ['src/**/*.{ts,tsx}'],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

No separate jest.config.js. No babel-jest transform. No moduleNameMapper to keep in sync with path aliases.

Step 3: Module Federation with Vite

This is where the migration stopped being a normal bundler swap. The host still ran webpack and expected all of this to keep working:

host -> fetch asset-manifest.json
host -> load remoteEntry.js
host -> init shared scope
host -> get exposed module
host -> call inject(container, props)
host -> later call unmount()
Enter fullscreen mode Exit fullscreen mode

Configuring the federation plugin

Install @module-federation/vite and add it to your Vite config:

import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: 'remoteApp',
      filename: 'remoteEntry.js',
      exposes: {
        './RemoteModule': './src/remote/entry.ts',
      },
    }),
  ],
  // ...
});
Enter fullscreen mode Exit fullscreen mode

The exposed entry file should export the lifecycle functions the host expects:

// src/remote/entry.ts
export { inject, unmount } from './RemoteModule';
export { default } from './RemoteModule';
Enter fullscreen mode Exit fullscreen mode
import { MemoryRouter } from 'react-router-dom';
import { createRoot, type Root } from 'react-dom/client';
import App from '../App';

let root: Root | null = null;

export const inject = (
  container: string | HTMLElement,
  _props?: Record<string, unknown>
): void => {
  const element =
    typeof container === 'string'
      ? document.getElementById(container)
      : container;
  if (!element) return;

  // Guard against duplicate roots if the host mounts twice.
  root?.unmount();

  root = createRoot(element);
  root.render(
    <MemoryRouter>
      <App />
    </MemoryRouter>
  );
};

export const unmount = (): void => {
  if (root) {
    root.unmount();
    root = null;
  }
};
Enter fullscreen mode Exit fullscreen mode

Note: The inject(container, props) and unmount() API here is host-specific. MemoryRouter made sense because the embedded remote needed internal navigation but not deep-linkable standalone URLs. Standalone development used BrowserRouter instead.

Generating a host-compatible asset manifest

The host fetched asset-manifest.json and expected specific keys for remoteEntry.js and main.css. Vite produced a different file (manifest.json) with a different shape, so even after renaming the file, the host couldnt parse it.

The fix was a small Vite plugin that generates a compatible manifest after the build:

import { promises as fs } from 'node:fs';
import path from 'node:path';
import type { Plugin } from 'vite';

export const rewriteHostManifest = (): Plugin => ({
  name: 'rewrite-host-manifest',
  async writeBundle(options, bundle) {
    const outputDir = options.dir || 'dist';
    const files = Object.keys(bundle);
    const remoteEntry = files.find((file) => file.endsWith('remoteEntry.js'));
    const mainCss = files.find((file) => file.endsWith('.css'));
    if (!remoteEntry || !mainCss) {
      throw new Error('remoteEntry.js not found in bundle output');
    }
    const manifest = {
      files: {
        'remoteEntry.js': `/${remoteEntry}`,
        'main.css': `/${mainCss}`,
      },
    };
    await fs.writeFile(
      path.join(outputDir, 'asset-manifest.json'),
      JSON.stringify(manifest, null, 2)
    );
  },
});
Enter fullscreen mode Exit fullscreen mode

Add it to the plugins:

plugins: [
  react(),
  federation({ /* ... */ }),
  rewriteHostManifest(),
],
Enter fullscreen mode Exit fullscreen mode

Adapt the manifest shape to whatever the host actually reads. This was specific to this setup.

Confirm the base path

If the built assets are served from a CDN or cloud storage bucket, you need to tell Vite:

export default defineConfig({
  base: process.env.ASSET_BASE_PATH || '/',
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Without this, Vite generates root-relative paths like /assets/chunk-abc123.js. The host resolves those relative to its own origin, which in this case served index.html instead of the JS file, producing MIME type errors. Setting base to the bucket or CDN path fixed it.

Split fonts from the main bundle (if applicable)

The module bundled custom fonts, but the host already loaded the same fonts globally. The fix was to move the @font-face declarations into a separate SCSS file and only import it in standalone mode, not in the federated entry.

Step 4: Local federation dev harness

This was the biggest QOL improvement, and probably the most reusable part of the migration. Testing a federated module usually means deploying to a test environment and loading it through the host. That's a slow feedback loop. Instead, a local dev harness was built to replicate the host's loading sequence.

The harness used vite build --watch plus vite preview instead of the normal dev server because the goal was to validate the real emitted artifacts: asset-manifest.json, remoteEntry.js, built CSS, and chunk URLs. The standard dev server is great for app development, but it doesnt produce the same output the host will actually fetch in production.

The harness did the following:

  1. Build the module in development mode with vite build
  2. Keep rebuilding with vite build --watch
  3. Serve the output with vite preview
  4. Use a simple intermediary UI to collect runtime props (locale, auth token, environment details)
  5. Fetch asset-manifest.json from the local preview server
  6. Load remoteEntry.js
  7. Call container.init() and container.get()
  8. Call inject() with configurable props and verify unmount() cleanup

That made it possible to test the full federation lifecycle locally, including script loading, module init, prop injection, CSS loading, auth handling, and unmount cleanup, without deploying anything.

The entry point ended up with three runtime modes:

// src/main.tsx
if (import.meta.env.VITE_USE_FEDERATION_HARNESS === 'true') {
  const { FederationHarness } = await import('./dev/FederationHarness');
  root.render(<FederationHarness />);
} else if (import.meta.env.VITE_EMBEDDED_MODE === 'true') {
  const { FederatedEntry } = await import('./remote/FederatedEntry');
  root.render(<FederatedEntry />);
} else {
  const { StandaloneEntry } = await import('./standalone/StandaloneEntry');
  root.render(<StandaloneEntry />);
}
Enter fullscreen mode Exit fullscreen mode
  • start runs standalone app development
  • dev runs federation development against a local preview server
  • build produces the production remote for the real host

Pitfalls to watch out for

  • Vite's manifest is not webpack's manifest. Dont assume the formats will match.
  • base matters for remote hosting. Forget it and every chunk import will 404 or return HTML instead of JavaScript.
  • Shared dependencies are not automatic wins. They are one of the biggest selling points of Module Federation, but cross-bundler setups and older integration contracts can make them risky to use.
  • Suppress lint rules temporarily. A build tool migration will surface new lint errors from updated configs. Add temporary warn overrides and fix them in separate PRs and keep momentum.
  • Fix things at the source. For example, dont patch CI when the build config is wrong :)

Verification

These were the checks that mattered more than "the build passed":

  • Standalone development still worked with the app's normal router and env vars
  • The local federation harness could fetch asset-manifest.json, load remoteEntry.js, and mount the module
  • CSS loaded correctly from the built output
  • Production hosting used the correct base path and chunk URLs all resolved correctly
  • Full regression test of all features

Results

  • Resolved all the open dependabot alerts
  • Removed .babelrc, craco.config.js, jest.config.js, and custom webpack overrides
  • Consolidated build, dev, preview, and test config into vite.config.ts
  • Cold-start build time went from 63.4s in CRA/webpack to 9.3s in Vite
  • The lockfile diff had a reduction of ~10k lines

If you're maintaining a federated micro frontend on CRA, the path to Vite is worth the effort. Just remember to analyze the host's loading contract and build yourself a local harness that exercises the real federation lifecycle.


A note on Vite 8: Vite 8 shipped recently, after this migration was already complete. Its release notes mention Module Federation support as one of the capabilities unlocked by the new Rolldown-based architecture, which looks promising. If I were starting today, I would look into this first.


References

Top comments (1)

Collapse
 
crisiscoresystems profile image
CrisisCore-Systems

This was a strong read because it understands the real danger in migrations: not the tool swap itself, but the false confidence that comes from thinking a tool swap is the whole job.

What I liked most is that this treats the host contract as sacred. That is the part too many engineers underestimate. Once another system depends on your runtime behavior, your outputs, loading sequence, and exposed lifecycle are no longer implementation details. They are part of the product whether you like it or not.

That is why this felt grounded. It was not Vite evangelism. It was engineering reality. Clean up the rot first, reduce the dead weight, then make the migration without casually breaking the assumptions the host has already built around your remote.

The local harness angle was especially good. That is the kind of detail that separates a clean migration story from a hopeful one. If you cannot reproduce the real loading path locally, you are not really testing the contract. You are testing a comforting approximation.

Strong post overall. The deeper lesson is bigger than CRA or Vite: when a system already depends on your behavior, modernization only counts as success if the surrounding trust boundary survives the rewrite.