Enterprise Micro-Frontends with Vite Module Federation: Shared Dependencies and Resilient Remotes

Via Mae shares how she and Amir built an enterprise micro-frontend architecture with Vite Module Federation, avoiding duplicate React instances and runtime crashes.

SB

SmartBuddy Engineering Team

Autonomous Systems & AI Tools, MCP & Dev
Enterprise Micro-Frontends with Vite Module Federation: Shared Dependencies and Resilient Remotes

Micro-frontends get a lot of criticism in frontend communities, and honestly, much of it is justified. When teams divide a simple web app into ten micro-apps for the sake of following a trend, they end up with complex build pipelines, duplicated vendor bundles, and difficult cross-app debugging.

However, in large organizations with separate engineering teams deploying features on different release cycles, a shared monolith creates its own bottlenecks. One team's deployment can block another team's release for days.

A few months ago, Amir and I helped a client decompose their SaaS platform into a container shell with independent remote micro-frontends using Vite and @originjs/vite-plugin-federation.

Moving away from heavy Webpack configurations to Vite gave us fast local build times. But Vite Module Federation has its own nuances: deduplicating shared React runtimes, handling remote downtime gracefully, and setting up type-safe event communication between isolated applications.

Here is the exact production architecture we deployed to keep our host shell and remotes running reliably.

The Biggest Failure Modes in Module Federation

Before configuring plugins, here are the three issues that cause runtime crashes:

  • Duplicate React instances: If the host shell and a remote app bundle their own copies of React, hooks break immediately with the dreaded "Invalid hook call" error.
  • Brittle remote dependencies: If an independent remote fails to load or experiences a network timeout, the entire host page goes blank unless shielded by an Error Boundary.
  • Tightly coupled state: Passing global Redux or Zustand stores across federated boundaries introduces hidden coupling. Communication should remain decoupled via an event bus.

Configuring Vite Module Federation with Shared Singletons

In our setup, the Host container lives on port 3000, while independent feature remotes (like Billing on port 3001) are built and deployed on their own cadence.

Here is the remote configuration for our Billing micro-frontend:

typescript
// remotes/billing/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: 'billing_remote',
      filename: 'remoteEntry.js',
      exposes: {
        './BillingModule': './src/BillingModule.tsx',
      },
      shared: {
        react: {
          requiredVersion: '^18.3.1',
          singleton: true,
        },
        'react-dom': {
          requiredVersion: '^18.3.1',
          singleton: true,
        },
      },
    }),
  ],
  build: {
    target: 'esnext',
    minify: false,
    cssCodeSplit: false,
  },
});

And here is the Host container configuration that imports the remote:

typescript
// 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: {
        billing_remote: 'http://localhost:3001/assets/remoteEntry.js',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
  build: {
    target: 'esnext',
  },
});

By marking react and react-dom as shared singletons with explicit version constraints, the browser loads the React runtime once, preventing duplicate hook registrations.

Resilient Remote Loading with Fallback Boundaries

Never import a federated remote directly without an Error Boundary and Suspense boundary. If the remote service is temporarily down, the rest of your application should still function smoothly.

Here is how we wrap remote components:

tsx
// host/src/components/FederatedRemoteWrapper.tsx
import React, { Suspense } from 'react';
import { ErrorBoundary } from './ErrorBoundary';

interface RemoteWrapperProps {
  children: React.ReactNode;
  fallbackTitle: string;
}

export function FederatedRemoteWrapper({ children, fallbackTitle }: RemoteWrapperProps) {
  return (
    <ErrorBoundary
      fallback={
        <div className="p-6 border border-amber-200 bg-amber-50 rounded-xl">
          <h3 className="text-base font-semibold text-amber-800">
            {fallbackTitle} Temporarily Unavailable
          </h3>
          <p className="text-sm text-amber-700 mt-1">
            Our billing service is undergoing routine maintenance. The rest of your workspace is working normally.
          </p>
        </div>
      }
    >
      <Suspense fallback={<div className="animate-pulse p-6 bg-slate-50 rounded-xl">Loading module...</div>}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
}

In your host router:

tsx
// host/src/pages/BillingPage.tsx
import React from 'react';
import { FederatedRemoteWrapper } from '../components/FederatedRemoteWrapper';

const RemoteBillingModule = React.lazy(() => import('billing_remote/BillingModule'));

export default function BillingPage() {
  return (
    <main className="max-w-5xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">Subscription & Invoices</h1>
      <FederatedRemoteWrapper fallbackTitle="Billing Portal">
        <RemoteBillingModule />
      </FederatedRemoteWrapper>
    </main>
  );
}

Decoupled Communication via a Type-Safe Event Bus

Never pass functions or global store references between micro-frontends. Instead, use an isolated event bus built on native CustomEvent interfaces:

typescript
// packages/event-bus/src/index.ts
export interface MicroAppEvents {
  'auth:token_refreshed': { token: string };
  'billing:plan_upgraded': { planId: string; tier: string };
}

export const eventBus = {
  emit<K extends keyof MicroAppEvents>(event: K, detail: MicroAppEvents[K]): void {
    window.dispatchEvent(new CustomEvent(event, { detail }));
  },

  on<K extends keyof MicroAppEvents>(
    event: K,
    callback: (detail: MicroAppEvents[K]) => void
  ): () => void {
    const handler = (e: Event) => {
      const customEvent = e as CustomEvent<MicroAppEvents[K]>;
      callback(customEvent.detail);
    };

    window.addEventListener(event, handler);
    return () => window.removeEventListener(event, handler);
  },
};

This guarantees that if the billing remote triggers an upgrade, the host shell can listen to billing:plan_upgraded and update its navigation badges without sharing memory or state objects.

Production Checklist for Micro-Frontends

  1. Pin exact peer versions: Verify both host and remotes share identical major React versions.
  2. Set up CORS headers on remote storage: If remotes are served from AWS S3 or Cloudflare R2, configure Access-Control-Allow-Origin so the host can fetch remoteEntry.js.
  3. Automate contract testing: Use TypeScript type exports across packages to catch breaking prop changes in CI before production deployments.

Did you find this technical breakdown helpful?

Tap to rate this guide · 1 views

Comments

Comments are reviewed before appearing publicly.

No comments yet — be the first.

🚀 Ready to Deploy Autonomous Skills in Production?

Get this skill (and 29 more) in the SmartBuddy Shop, or work with our engineering team to architect custom multi-agent workflows for your company.