React Micro-Frontends with Module Federation 2.0
AI generated
</>
{ }
React · Micro-Frontends · Module Federation · Webpack 5
React Micro-Frontends with
Module Federation 2.0

Anyone running a growing React application as a monolith eventually struggles with long build times, deployment dependencies and team conflicts. Module Federation 2.0 solves these problems through true runtime composition: teams deploy their parts of the app independently, and the shell app dynamically composes everything together.

18 min read Shell App · Remote · Shared Deps · TypeScript · Vite React 18 · Webpack 5 · Module Federation 2.0

1. What micro-frontends really solve

The core problem of large frontend projects isn't missing technology, it's organizational coupling. When ten teams work on the same React codebase, every change becomes a coordination task. Deployments require all parties involved, a broken PR blocks everyone else, and build times explode because every CI pipeline compiles the entire project. Micro-frontends solve the organizational problem before it becomes a technical one: each team fully owns its part of the application, code, tests and deployment pipeline.

Module Federation is not simply code-splitting. In classic code-splitting, the build process decides which chunks are produced. With Module Federation, the runtime loader decides which remote apps are loaded, and from where. That means the remote app team and the shell app team can deploy completely independently. The shell asks at runtime for the current state of the remote, not for the state at build time. That is the fundamental difference from all build-time approaches such as NPM packages or monorepo builds.

2. Module Federation 2.0. Concepts and what's new

Module Federation 1.0 shipped with Webpack 5 and for the first time enabled true runtime composition of JavaScript modules across app boundaries. Version 2.0, released as @module-federation/enhanced, brings several important improvements: an improved type system for remote exports, a runtime plugin system that can adjust behavior without a rebuild, and a Vite-compatible implementation via @originjs/vite-plugin-federation. Also new is official support for dynamic remote registration at runtime, without any prior configuration in the build.

The basic concepts remain: a host (shell app) consumes remotes. A remote exposes components or modules via a remoteEntry.js file. Shared defines which dependencies (above all React and React DOM) are shared between all apps so that each runs only once in the browser. Module Federation 2.0 additionally introduces manifest files that let the shell discover available remotes at runtime, without hardcoded URLs in the build. This considerably simplifies dynamic scaling.

3. Setting up the shell app: host configuration

The shell app is the entry point for the user. It loads the global navigation, the routing and, dynamically at runtime, the individual remote apps. The shell's Webpack configuration uses ModuleFederationPlugin as a host and defines all known remotes with their URLs. In production environments these URLs come from environment variables or a service discovery endpoint, not from the build itself. The shell itself contains no domain code belonging to the remote teams; it is pure scaffolding.

One critical detail: the shell must boot its own app asynchronously so that the shared-dependencies system of the Module Federation runtime is fully initialized before anything is rendered. The usual trick is a bootstrap.tsx that is dynamically imported, while index.ts contains only that single dynamic import line. Anyone who forgets this gets runtime errors because React gets loaded twice, once by the shell, once by a remote.


// webpack.config.ts - Shell-App (Host) configuration
import { ModuleFederationPlugin } from '@module-federation/enhanced/webpack';

export default {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      // Remote URLs come from environment variables at runtime
      remotes: {
        catalogApp: `catalogApp@${process.env.CATALOG_URL}/remoteEntry.js`,
        checkoutApp: `checkoutApp@${process.env.CHECKOUT_URL}/remoteEntry.js`,
        accountApp: `accountApp@${process.env.ACCOUNT_URL}/remoteEntry.js`,
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
        'react-router-dom': { singleton: true, requiredVersion: '^6.0.0' },
      },
    }),
  ],
};

// src/index.ts - async bootstrap trick (REQUIRED for shared deps)
import('./bootstrap');

// src/bootstrap.tsx - actual app entry
import React from 'react';
import { createRoot } from 'react-dom/client';
import { ShellApp } from './ShellApp';

const root = createRoot(document.getElementById('root')!);
root.render(<ShellApp />);

4. Building and exposing a remote app

Every remote app is a standalone React application. It can be developed, tested and deployed locally without the shell needing to exist. The remote's Webpack configuration defines which components or modules it exposes to the outside world. The keyword is exposes: it is a mapping from a public name to an internal file path. The remote app then builds a remoteEntry.js file that the host can load at runtime.

It's important that exposed components make no assumptions about the context they run in. They should not expect global CSS variables that are only defined in their own app, and no global state from a store instance that might not exist. Instead, they get everything they need via props or via explicitly shared dependencies (such as a shared React Query client). Well-defined interfaces, ideally with TypeScript and exported prop types, are the foundation of stable micro-frontend integration.


// webpack.config.ts - Catalog Remote-App configuration
import { ModuleFederationPlugin } from '@module-federation/enhanced/webpack';

export default {
  plugins: [
    new ModuleFederationPlugin({
      name: 'catalogApp',
      filename: 'remoteEntry.js',
      // Public interface: what the shell can import from this remote
      exposes: {
        './ProductList': './src/components/ProductList',
        './ProductDetail': './src/components/ProductDetail',
        './CategoryNav': './src/components/CategoryNav',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};

// src/components/ProductList.tsx - exposed component (standalone)
import React from 'react';

interface ProductListProps {
  categoryId: string;
  onProductSelect: (productId: string) => void;
}

// Clean props interface, no assumptions about parent context
export const ProductList: React.FC<ProductListProps> = ({
  categoryId,
  onProductSelect,
}) => {
  // Component fetches its own data, fully autonomous
  return <div className="catalog-product-list">{/* ... */}</div>;
};

export default ProductList;

5. Shared dependencies: loading React only once

The biggest risk with micro-frontends is loading React more than once. If the shell app and a remote app have different React instances, hook calls from the remote app look like foreign objects from the shell app's point of view, which leads to the notorious "Invalid hook call" error. The shared field in the Module Federation configuration prevents this: it shares the same React bundle across all apps that declare it. singleton: true enforces that only one instance ever exists, even if different remotes specify different minor versions.

Version conflicts are the most common operational problem here. If the shell wants React 18.2 and a remote only ships React 18.1, the singleton configuration decides which version wins. Through requiredVersion you can enforce minimum versions and get a warning in the browser console at runtime on violation. For production it's recommended to have a central shared-deps package in the monorepo that dictates versions for all apps, keeping the configuration consistent without copy-pasting between multiple webpack.config.ts files.

6. Type-safe remotes with TypeScript

The classic problem with Module Federation and TypeScript: the shell imports a component from a remote with import('catalogApp/ProductList'), but TypeScript has no idea what type that component is, since there is no node_modules/catalogApp at the time the shell is compiled. Module Federation 2.0 solves this with automatically generated type declaration files. The remote build produces a @mf-types.d.ts that the shell app can consume. The types thereby always stay in sync with the remote's actual export, without any manual writing of declarations.

For teams not yet on Module Federation 2.0, there is a proven workaround: a shared @types/remotes package in the monorepo that contains all remote interfaces as TypeScript declarations. When a remote interface changes, this package gets updated, and the shell app immediately gets error messages on the next type check if it uses an outdated API. This keeps type safety intact even though runtime and compile time are separate worlds.

7. Routing across micro-frontend boundaries

Routing is one of the trickiest questions in micro-frontend architectures. The recommended pattern with React Router 6: the shell app owns the top-level routes and renders the responsible remote app for each path prefix. The remote app receives a basename prop that tells it which path prefix it manages. Internally, the remote app then uses its own router, fully independent of the shell. Navigations within the remote stay local; navigations to other micro-frontends go through the shell.

For cross-remote navigation there are two strategies: either use native browser routing with window.history.pushState, which both apps listen to via popstate events. Or define a shared event bus as a shared dependency, a simple EventEmitter instance through which remotes communicate navigation intents without knowing directly about each other. The latter is more explicit and testable, but requires a defined contract between the teams.


// Shell: lazy-load remote components with Suspense + ErrorBoundary
import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
import { ErrorBoundary } from './components/ErrorBoundary';

// Dynamic imports from remote apps, resolved at runtime
const ProductList = lazy(() => import('catalogApp/ProductList'));
const ProductDetail = lazy(() => import('catalogApp/ProductDetail'));
const Checkout = lazy(() => import('checkoutApp/CheckoutFlow'));
const Account = lazy(() => import('accountApp/AccountDashboard'));

const RemoteFallback = () => (
  <div className="remote-loading">Loading application area...</div>
);

export const ShellRouter: React.FC = () => (
  <Routes>
    <Route
      path="/catalog/*"
      element={
        <ErrorBoundary fallback={<div>Catalog unavailable</div>}>
          <Suspense fallback={<RemoteFallback />}>
            <ProductList categoryId="root" onProductSelect={() => {}} />
          </Suspense>
        </ErrorBoundary>
      }
    />
    <Route
      path="/checkout/*"
      element={
        <ErrorBoundary fallback={<div>Checkout unavailable</div>}>
          <Suspense fallback={<RemoteFallback />}>
            <Checkout />
          </Suspense>
        </ErrorBoundary>
      }
    />
  </Routes>
);

8. Error handling and fallbacks

A micro-frontend system must assume that individual remotes will be temporarily unreachable. This is not an exceptional state, it's normal operation in a distributed system. React's ErrorBoundary is the first line of defense: if a remote component throws while loading or rendering, the ErrorBoundary catches the error and renders a fallback. Without an ErrorBoundary, a remote error would take down the entire shell app, an unacceptable outage for something that actually only affects a small part of the app.

For loading remoteEntry.js itself, Module Federation 2.0 offers retry logic and the ability to configure alternative URLs. In practice it's advisable to monitor remote load times: if a remoteEntry.js takes more than a second to load, that's an early warning sign of deployment problems on the remote team's side. Alerting on these metrics prevents a remote deployment issue from only surfacing through user complaints.

9. Approaches compared side by side

There are several approaches to splitting a React application into independently deployable parts. Module Federation is the most powerful, but also the most complex. The choice depends on team size, deployment frequency and the amount of complexity you're willing to tolerate.

Approach Deployment Type safety Recommendation
Module Federation 2.0 Fully independent Automatic via @mf-types 3+ teams, high deploy frequency
NPM packages (monorepo) Build-time coupled Full 1 to 2 teams, infrequent releases
iframe composition Independent None Legacy integration only
Web Components Independent Limited Framework-agnostic remotes
Single-SPA Independent Manual Mixed-framework environments

Module Federation clearly wins on deployment independence and integration into the normal React development workflow. Remote components look like normal React components, just with a different import path. The price is build complexity: two Webpack configurations, two CI pipelines, and the need to coordinate shared dependency versions. For small teams without real deployment pressure, a monorepo with NPM packages is often the better choice.

Mironsoft

React architecture, micro-frontends and Module Federation

Splitting your React app into micro-frontends?

We analyze your existing React architecture, define sensible boundaries, and implement Module Federation, with type-safe integration and a CI pipeline for each team.

Architecture review

Define boundaries, analyze shared dependencies, evaluate team structure

Implementation

Build shell app, remote apps and type sharing with Module Federation 2.0

CI/CD per team

Independent deployment pipelines with type synchronization and integration tests

10. Summary

React micro-frontends with Module Federation 2.0 solve the organizational scaling problem of large frontend projects: teams deploy independently, types stay in sync thanks to automatically generated declarations, and the shell app composes everything at runtime without build-time coupling. The shell uses singleton: true for shared dependencies so that React exists exactly once in the browser. Remote apps expose clean, props-based interfaces without assumptions about the context. ErrorBoundaries and Suspense fallbacks keep the shell stable when a remote is temporarily unreachable.

The path to a production-ready micro-frontend architecture is iterative: set up the shell first, migrate one remote, establish the deployment process. Then gradually spin out further remotes once the team understands the workflow. The biggest risk is not the technology, it's an unclear boundary. A remote that expects too much from the shell's context is just as coupled as a monolith, only harder to debug.

Module Federation 2.0. The essentials at a glance

Async bootstrap

index.ts only dynamically imports bootstrap.tsx. Mandatory so that shared dependencies are correctly initialized before React starts.

Singleton React

singleton: true in every shared configuration. Prevents duplicate React instances and "Invalid hook call" errors at runtime.

ErrorBoundary + Suspense

Wrap every remote component in ErrorBoundary and Suspense. Remote failures must never bring down the shell.

Keep types in sync

Module Federation 2.0 generates @mf-types.d.ts automatically. Alternatively, maintain a shared @types/remotes package in the monorepo.

11. FAQ: React Micro-Frontends with Module Federation 2.0

1Difference between Module Federation and code-splitting?
Code-splitting is build time. Module Federation is runtime, remotes are loaded dynamically, fully independent of the shell's build.
2Why import bootstrap.tsx dynamically?
The Module Federation runtime must be initialized before React. The dynamic import in index.ts creates the necessary asynchronous boundary.
3Remote unreachable, what happens?
Without an ErrorBoundary, the shell crashes. With an ErrorBoundary it renders a fallback, the rest of the app stays fully functional.
4Prevent React from loading more than once?
In every app, react and react-dom with singleton: true in the shared configuration. The runtime ensures that only one instance exists.
5Is Module Federation possible with Vite?
Yes. @originjs/vite-plugin-federation or @module-federation/enhanced for Vite. MF 2.0 offers better type integration.
6How do you test micro-frontend integration?
Unit tests in the remote, contract tests (Pact) for interfaces, E2E tests (Playwright) for the composed system.
7When is Module Federation not worth it?
With a single team or infrequent deployments, a monorepo with NPM packages is simpler and offers the same type safety without runtime overhead.
8Coordinating shared dependency versions?
A central shared-deps package in the monorepo. All apps import only this one, no diverging versions from copy-paste.
9Routing between remotes?
Shell owns top-level routes, remote gets a basename prop and manages its sub-router internally. Cross-remote via window.history or an event bus.
10What's new in Module Federation 2.0?
Automatic TypeScript declarations, runtime plugin system, Vite support and dynamic remote registration. Package: @module-federation/enhanced.