← back to section

An online store is built by five teams: catalog, search, cart, checkout, account. The code lives in one repository and ships as one build. The cart team fixed a typo but cannot release: search tests are failing. Releases happen once a week, and every team waits for the others.

Micro-frontends solve exactly this: the page is assembled from parts that each team develops and releases on its own. Below are the ways to do it, how the most popular one, Module Federation, works, and why this is a solution for the organization rather than for the code.

host: shell, routing, shared React catalogcatalog.shop.ru cartcart.shop.ru searchsearch.shop.ru team A, release on Tuesday team B, release today team C, release on Friday parts arrive in the browser at runtime, each from its own address

The shell knows only the parts' addresses; what is inside each and when it updates is up to its team.

Why split the frontend

A micro-frontend is a part of the UI fully owned by one team, from code to release. The gain is single but big: independent releases. The cart ships five times a day without waiting for search, and a bug in search does not block anyone else.

The cost is big too. You get several builds, versions of shared libraries, agreements on styles and events, and parts loaded over the network. If one team of ten people works on the app, all of this is cost without benefit: a modular monolith with clear boundaries, as in Feature-Sliced Design, is enough. Micro-frontends pay off when there are several teams getting in each other's way at release time.

How to compose a page from parts

Parts can be joined at different stages, and that decides how independent they are.

Composing from npm packages is the simplest option, but it gives no independence: to update the cart, the host still has to be rebuilt and released. That splits the code, not the releases.

Server-side composition: a server or CDN stitches HTML from fragments served by different services. Good for pages where first paint speed matters, but each part's interactivity has to be hydrated separately.

Composition in the browser: the shell loads the parts' code at runtime. The oldest way is an iframe: full isolation, but shared navigation, sizing and a consistent look are hard. A bit more modern are Web Components: a part registers its own tag, such as <cart-widget>, and the host simply puts it in the markup. The most common option in React projects today is Module Federation.

Module Federation: modules loaded at runtime

Module Federation appeared in webpack 5 and now exists in Rspack and as a Vite plugin. The idea: one application's build can expose some of its modules, and another application imports them at runtime like a regular import, even though the code lives on another server.

The cart declares what it exposes and builds a manifest file remoteEntry.js:

new ModuleFederationPlugin({
  name: "cart",
  filename: "remoteEntry.js",
  exposes: { "./CartWidget": "./src/CartWidget" },
  shared: { react: { singleton: true, requiredVersion: "^18.2.0" }, "react-dom": { singleton: true } },
});

The host knows only the manifest address and loads the widget lazily:

new ModuleFederationPlugin({
  name: "shell",
  remotes: { cart: "cart@https://cart.shop.ru/remoteEntry.js" },
  shared: { react: { singleton: true }, "react-dom": { singleton: true } },
});

const CartWidget = lazy(() => import("cart/CartWidget"));

When the cart team releases a new version, the host rebuilds nothing: on the next page load it fetches the new remoteEntry.js. Hence the main pitfall: the cart server may not respond. A remote module is always wrapped in Suspense and an error boundary, so a failing cart does not take the page down.

Shared dependencies: one copy of React

If every part brings its own React, the page weighs twice as much and hooks break: React from one copy does not recognize a component from another and fails with "Invalid hook call". So shared libraries are declared in shared, and the loader decides which copy to use.

With singleton: true there is always one copy, the host's. If its version does not suit a remote, the loader only warns in the console, and with strictVersion: true it fails. Without singleton, an unsuitable version brings in a second copy. The selection logic in simplified form:

live example

const hostShared = { react: "18.3.1" };

function satisfiesCaret(version, range) {
  const [major, minor, patch] = version.split(".").map(Number);
  const [rMajor, rMinor, rPatch] = range.slice(1).split(".").map(Number);
  if (major !== rMajor) return false;
  return minor > rMinor || (minor === rMinor && patch >= rPatch);
}

function resolve(remote, { range, own, singleton }) {
  const host = hostShared.react;
  if (satisfiesCaret(host, range)) return `${remote}: react ${host} from host`;
  if (singleton) return `${remote}: react ${host} from host, console warning: expected ${range}`;
  return `${remote}: own react ${own} — two copies of React on the page`;
}

console.log(resolve("cart", { range: "^18.2.0", own: "18.2.0", singleton: true }));
console.log(resolve("search", { range: "^19.0.0", own: "19.1.0", singleton: true }));
console.log(resolve("search", { range: "^19.0.0", own: "19.1.0", singleton: false }));
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Hence the rule: major versions of React, the router and the design system are upgraded in lockstep by all teams. This is the one place where micro-frontends are not independent.

How parts talk to each other

The cart has to tell the header that there are now three items. A shared Redux store across all parts couples teams as tightly as a monolith: change the state shape and you break someone else's code. So parts communicate through narrow, explicit channels: the URL, Web Component attributes and browser events.

Don't: import the cart store into the header and read its fields directly.

Do: agree on an event name and payload shape; that is the contract between teams:

live example

const bus = new EventTarget();

bus.addEventListener("cart:changed", (event) => {
  console.log("header: items in cart", event.detail.count);
});

function addToCart(count) {
  bus.dispatchEvent(new CustomEvent("cart:changed", { detail: { count } }));
}

addToCart(1);
addToCart(2);
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

In the browser, window is usually used instead of a custom EventTarget. Styles are isolated with class prefixes, CSS modules or the Shadow DOM of Web Components; otherwise one team's rule recolors another team's buttons.

Summary

  • Micro-frontends mean independent releases of UI parts by different teams; for a single team they cost more than a modular monolith.
  • Ways to join parts: npm packages (no independent releases), server-side stitching, iframe, Web Components, Module Federation.
  • Module Federation: a remote exposes modules via exposes and remoteEntry.js, the host connects them via remotes and runtime import.
  • A remote module always goes into lazy, Suspense and an error boundary: its server may not respond.
  • React and other stateful libraries go into shared with singleton: true; two copies of React break hooks.
  • Parts talk through the URL, attributes and events with an agreed name and shape, not through a shared store.

Further reading