adnansaleem

Splitting a React monolith into micro frontends with Module Federation

When micro frontends are worth it, how a shell and its remotes fit together, and the contracts that keep independent deploys from breaking each other.

By M. Adnan Saleem · · 3 min read

On the Qatar Events Platform I rearchitected a monolithic React frontend into micro frontends using Module Federation. With versioned contracts between the shell and the remotes, release cycle time dropped by about 40% and teams could deploy independently. This is the shape of that kind of split, and the parts that decide whether it helps or hurts.

First, check that you have the problem

Micro frontends solve an organisational problem, not a technical one. They are worth the cost when several teams ship into one frontend and block each other: one release train, one long build, one team's unfinished work holding up another team's fix. If one team owns the whole frontend, a well-structured monolith with code splitting is simpler and faster.

The pieces

A shell (also called the host) owns the page frame, routing, authentication and the shared design system. Each remote is a separately built and deployed application that exposes one or more modules. At runtime the shell fetches a small manifest from each remote, called remoteEntry.js, and loads the exposed module from it.

The remote declares what it exposes:

// events/webpack.config.js
new ModuleFederationPlugin({
  name: 'events',
  filename: 'remoteEntry.js',
  exposes: {
    './EventsApp': './src/EventsApp',
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  },
});

The shell declares where to find it:

// shell/webpack.config.js
new ModuleFederationPlugin({
  name: 'shell',
  remotes: {
    events: 'events@https://events.example.com/remoteEntry.js',
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  },
});

The shared block matters most. React must be a singleton: two copies on one page break hooks and context. Declaring a required version makes a mismatch a visible warning, not a silent second copy.

The contract is the product

Independent deploys only work if the boundary between shell and remote is treated as a public interface. In practice that means three rules.

  • Small, typed surface. A remote exposes a root component with a handful of props (the user, a base path, callbacks for navigation). It does not reach into the shell's store.
  • Versioned. A breaking change to the props is a new major version of the contract, and the shell keeps supporting the previous one until every remote has moved.
  • Checked in CI. The contract types live in a shared package, so a remote that drifts fails its own build, not production.

Fail small

A remote is a network dependency, so it can fail to load. Load each one lazily behind an error boundary, so a broken remote takes down its own section and not the whole page:

const EventsApp = React.lazy(() => import('events/EventsApp'));

<ErrorBoundary fallback={<SectionUnavailable name="Events" />}>
  <Suspense fallback={<SectionSkeleton />}>
    <EventsApp user={user} basePath="/events" />
  </Suspense>
</ErrorBoundary>

What goes wrong

  • Shared state creep. The moment two remotes share a store, they deploy together again. Pass data through the contract or through the URL.
  • CSS leaking across remotes. Use scoped styles (CSS Modules or a prefix per remote) and keep global styles in the shell only.
  • Dependency drift. If remotes upgrade shared libraries at different times, users download several versions. Agree an upgrade window for the shared set.
  • Slower first load. More requests before the first paint. Preload the remote entry for the route the user is most likely to open.

How to tell it worked

Measure the thing you were trying to fix: time from merge to production for a single team, and how often one team's release waits on another. Release cycle time is the number that moved for us. Bundle size and load time should stay flat or improve; if they got worse, the shared configuration needs another look.

The project behind this

Read the case study

What I built, where, and the numbers that came out of it.

↑ ↓ to move · Enter to run · Esc to close