All lessons
FrontendAdvanced12 min readWorth knowing

Micro-frontends

Splitting a large frontend by team: Module Federation, composition patterns and the real costs.

  • Module Federation
  • Monorepos
  • Team boundaries
  • Design systems
Step 1/4
A monolith is one big app that does everything: orders, users, payments. Simple to build and run.

In 30 seconds

Micro-frontends split one large web application into smaller frontend pieces that different teams build, test and deploy independently, then combine into one experience for the user. It is the frontend version of microservices. Done for the right reasons it lets many teams move without blocking each other; done too early it adds a lot of complexity, duplicated code and slower pages.

Not in my daily stack — I know the concepts and would learn the details when a project needs it.

Why it matters

  • In large organisations, a single frontend owned by many teams becomes a bottleneck: one release train, one pipeline, endless merge conflicts.
  • Micro-frontends let each team own a business area end to end, from database to UI, and deploy on its own schedule.
  • They make incremental migration possible, such as moving an old AngularJS or jQuery app to React page by page instead of a risky big-bang rewrite.
  • Understanding the pattern also helps you recognise when it is not needed, which is the more common and valuable decision.

How it works

01The idea: split by team and business domain

Imagine an e-commerce site built by six teams: search, product pages, checkout, account, content and marketing. In a single frontend codebase, every change goes through one build and one deploy, and a bug in checkout can block a search release. With micro-frontends, each team owns a vertical slice: its own repository or package, its own pipeline, and its own deploy. A shell (also called host or container app) provides the page frame, routing and shared services, and loads the right micro-frontend for each area. Think of a shopping mall: one building, shared corridors and security, but each shop is run independently. The key principle is to split by business capability, not by technical layer such as 'header team' and 'button team'.

  • Shell / host: composes the page and owns routing and global concerns.
  • Remote / micro-frontend: an independently deployed part of the UI.
  • Contract: the agreed interface between shell and remote (props, events, routes).

02Ways to compose micro-frontends

Composition can happen at different times and places. Build-time integration publishes each part as an npm package that the shell imports; simple and fast, but you lose independent deployment because the shell must rebuild. Server-side composition stitches HTML fragments on the server or edge (Edge Side Includes, a composition service, or framework features such as Next.js Multi-Zones, where different apps own different URL paths behind one domain). Run-time integration loads JavaScript from each team in the browser: Module Federation, import maps with native ES modules, or Web Components. Iframes give the strongest isolation but poor integration for layout, routing and accessibility. The simplest effective approach is often route-level splitting: each team owns whole pages, and only the shell's navigation is shared.

03Module Federation explained

Module Federation, introduced in webpack 5 and now available as a standalone runtime (Module Federation 2.0) with plugins for webpack, Rspack, Rsbuild and Vite, lets one build load modules from another build at run time. A remote exposes modules (for example ./ProductPage) through a small entry file, often called remoteEntry.js or described by an mf-manifest.json. A host declares remotes and imports their modules as if they were local; the code is fetched only when needed. Shared dependencies such as React are declared as singletons so the page loads one copy, with version ranges negotiated at run time. Version 2.0 adds a manifest protocol, type hints for remotes, a runtime plugin system and devtools.

ts
// rsbuild.config.ts (remote: product team)
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { pluginModuleFederation } from "@module-federation/rsbuild-plugin";
 
export default defineConfig({
  plugins: [
    pluginReact(),
    pluginModuleFederation({
      name: "product",
      exposes: { "./ProductPage": "./src/ProductPage.tsx" },
      shared: {
        react: { singleton: true, requiredVersion: "^19.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^19.0.0" },
      },
    }),
  ],
  server: { port: 3001 },
});

04Consuming a remote in the shell

The host declares where each remote lives and lazy-loads its modules. Wrap every remote in an error boundary and a loading fallback: a remote is a network dependency that can fail or be slow, and one broken team must not break the whole page. In production, remote URLs should come from configuration or a manifest service rather than being hard-coded, so teams can deploy and roll back without rebuilding the shell.

ts
// host: rsbuild.config.ts (excerpt)
pluginModuleFederation({
  name: "shell",
  remotes: { product: "product@https://cdn.example.com/product/mf-manifest.json" },
  shared: { react: { singleton: true }, "react-dom": { singleton: true } },
});
 
// host: src/routes/ProductRoute.tsx
import { lazy, Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
 
const ProductPage = lazy(() => import("product/ProductPage"));
 
export function ProductRoute() {
  return (
    <ErrorBoundary fallback={<p>Product details are unavailable right now.</p>}>
      <Suspense fallback={<p>Loading…</p>}>
        <ProductPage />
      </Suspense>
    </ErrorBoundary>
  );
}

05Communication and shared state

Micro-frontends should be loosely coupled, so they communicate through small, explicit contracts rather than a shared global store. Common options: URL and routing as the primary state (the product ID lives in the URL), props passed by the shell when it mounts a remote, browser custom events for notifications like 'cart updated', and backend APIs as the source of truth that each part reads independently. Avoid one global Redux store shared by all teams; it recreates the coupling you split to avoid. Authentication is usually owned by the shell, which provides a token getter or user context through a tiny shared interface.

ts
// Contract: a typed custom event
type CartUpdated = { count: number };
 
// Checkout micro-frontend
window.dispatchEvent(new CustomEvent<CartUpdated>("cart:updated", { detail: { count: 3 } }));
 
// Shell header
window.addEventListener("cart:updated", (e) => {
  const { count } = (e as CustomEvent<CartUpdated>).detail;
  renderBadge(count);
});

06Design systems and consistency

The biggest user-visible risk is an inconsistent product: different buttons, spacing and behaviours from each team. A shared design system solves this: design tokens (colours, spacing, typography as CSS variables), a component library published as a versioned package, and documentation in Storybook. Teams consume it like any dependency. Style isolation matters too: use CSS Modules, a prefix per micro-frontend, or Shadow DOM for Web Components, so one team's global CSS does not break another's page. Keep the design system's breaking changes rare and announced, because every team must upgrade; tokens as CSS variables let you change themes at run time without redeploying every remote.

07Monorepo or polyrepo, and the decision itself

Micro-frontends are about independent deployment, not necessarily separate repositories. Many teams use a monorepo (Nx, Turborepo, pnpm workspaces) with one folder per micro-frontend, separate build and deploy pipelines triggered by affected changes, and shared tooling. This keeps refactors and dependency upgrades easy while preserving team ownership. Polyrepos maximise autonomy but make shared upgrades and consistency harder. Before choosing either, ask whether you need micro-frontends at all: with fewer than about three or four frontend teams, a well-structured modular monolith with clear folder ownership, lint-enforced boundaries and fast CI usually delivers the benefits without the run-time complexity.

Use it when

  • Several autonomous teams (often four or more) working on one product and blocking each other on releases.
  • Incremental migration from a legacy frontend framework to a new one, area by area.
  • Products assembled from separately owned areas, such as a portal combining tools from different departments.
  • When independent deployment and fault isolation are worth extra infrastructure and governance.

Skip it when

  • Small or single teams; the overhead outweighs any benefit, and a modular monolith is simpler.
  • Performance-critical consumer pages where extra JavaScript, duplicated dependencies and waterfalls hurt conversions.
  • When the real problem is a slow CI or tangled code, which better structure and tooling fix more cheaply.

Alternatives

  • Modular monolith frontend

    One app and deploy, with strict internal module boundaries and ownership; simplest for most teams.

  • Monorepo with Nx / Turborepo

    Shared tooling and affected-only builds; can stay one app or grow into micro-frontends later.

  • Next.js Multi-Zones

    Several Next.js apps serve different URL paths under one domain; route-level split without run-time federation.

  • Server-side composition / islands

    Compose HTML on the server or edge; good for content-heavy pages.

Best practices

  1. Split by business domain and team ownership, never by technical layer.
  2. Prefer route-level ownership; mixing many teams' components on one screen multiplies coordination.
  3. Share as little as possible: framework singletons and the design system, not business logic or global stores.
  4. Wrap every remote in an error boundary and fallback, and monitor errors per micro-frontend.
  5. Keep remote URLs in runtime configuration so teams can deploy and roll back independently.
  6. Version contracts between shell and remotes, and test them with contract or integration tests in CI.
  7. Set a performance budget for the composed page and measure the real user experience, not each part alone.

Common mistakes

  • Adopting micro-frontends for a small team because it sounds scalable, then drowning in configuration.
  • Loading several copies of React or different versions of the design system on the same page.
  • A shared global state store that couples all teams together again.
  • No fallback when a remote fails, so one team's broken deploy takes down the whole site.
  • Each team choosing a different framework without a strong migration reason, multiplying bundle size and hiring needs.

Deep dive

08Shared dependencies and version negotiation

Module Federation's share scope is a run-time registry where each loaded build registers the shared packages it brings, with versions. When a module needs React, the runtime picks a version that satisfies requiredVersion, preferring the highest available, and singleton: true forces exactly one instance, which React, React DOM, routers and context-based libraries require, because two copies break hooks and context. If no compatible version exists, a singleton logs a warning and uses the loaded one anyway, which can cause subtle bugs; strictVersion turns that into an error. eager: true bundles a shared module into the initial chunk, which avoids an async boundary but increases size. The usual pattern is an async bootstrap: the entry file does import('./bootstrap') so shared modules can be negotiated before the app starts.

ts
// src/index.ts  (entry)
import("./bootstrap");
 
// src/bootstrap.tsx
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(<App />);
 
// shared config with strict checks for critical libs
// shared: {
//   react: { singleton: true, requiredVersion: "^19.0.0", strictVersion: true },
//   "@acme/design-system": { singleton: true, requiredVersion: "^4.2.0" },
// }

09Deployment, discovery and rollbacks

Independent deployment needs a way for the shell to find the current version of each remote. Hard-coding URLs in the shell's build couples deploys. Better: each remote publishes immutable, versioned assets (product/1.14.2/mf-manifest.json) to a CDN, and a small discovery document or service maps remote names to the active version per environment. The shell reads it at startup, or Module Federation's runtime plugins resolve remotes dynamically. Rolling back means pointing the map to the previous version, without rebuilding anything. This also enables canary releases and per-user overrides for testing. Cache the discovery document briefly, and give assets long cache lifetimes because their paths are versioned.

ts
// remotes.json served by a config endpoint (short cache)
{
  "product":  "https://cdn.example.com/product/1.14.2/mf-manifest.json",
  "checkout": "https://cdn.example.com/checkout/3.2.0/mf-manifest.json"
}
 
// shell bootstrap with the MF runtime
import { init } from "@module-federation/enhanced/runtime";
const remotes = await (await fetch("/config/remotes.json")).json();
init({
  name: "shell",
  remotes: Object.entries(remotes).map(([name, entry]) => ({ name, entry: entry as string })),
});

10Isolation: CSS, globals and failures

Run-time composition puts several teams' code in the same document, so isolation must be designed. CSS: global selectors from one remote can restyle another; use CSS Modules or a framework's scoped styles, prefix any global classes, and keep resets in the shell only. Web Components with Shadow DOM give hard style boundaries, at the cost of harder theming (use CSS custom properties, which pierce the shadow boundary). JavaScript globals: never patch window or prototypes in a remote, and namespace any storage keys. Failures: an error boundary per remote, timeouts on remote loading, and fallback UI keep the page usable. Tag errors and performance metrics with the remote name and version so on-call engineers know which team to page.

11Server-side composition and SSR

Client-side federation works for apps behind login, but public pages need fast first paint and SEO. Options: route-level server apps behind one domain (Next.js Multi-Zones or a reverse proxy routing /checkout to one app and /blog to another), which is simple and robust but means full page loads between zones; edge or server composition of HTML fragments, where a layout service fetches fragments from each team in parallel and streams them; or federated SSR, where Module Federation loads remote modules on the Node.js server too, which is powerful but complex to operate, with server-side caching and memory concerns. For most public sites, route-level zones plus a shared design system and header package is the pragmatic answer.

nginx
# nginx: route-level composition under one domain
location /checkout/ { proxy_pass http://127.0.0.1:3002; }
location /account/  { proxy_pass http://127.0.0.1:3003; }
location /          { proxy_pass http://127.0.0.1:3001; }  # main shop

Real-world scenarios

01

Migrating a legacy AngularJS admin to React

Problem

A large AngularJS admin panel is no longer supported. A full rewrite would take a year and freeze features. The business wants new screens in React now.

How to solve it

Use the strangler pattern with a shell. Put a thin shell in front that owns routing and the layout. Mount the legacy app for old routes, and build new and rewritten screens as React micro-frontends registered for their routes, loaded with Module Federation or single-spa. Share authentication through a small shell API. Migrate screen by screen, starting with the highest-value or most-changed ones, and delete legacy routes as they are replaced. When the last legacy screen is gone, the shell can collapse into a normal React app, so you do not keep micro-frontends forever unless teams need them.

02

One team's deploy broke the whole site

Problem

A remote shipped a bundle that throws on load. Because the shell imports it at the top level, every page shows a white screen.

How to solve it

Immediately roll back by pointing the discovery map to the previous remote version. Then harden the shell: lazy-load each remote only on its routes, wrap it in an error boundary with a useful fallback, and add a load timeout. Add a post-deploy smoke test that loads the composed page in a headless browser before the new version is promoted, and monitor errors tagged per remote with alerts. With versioned immutable assets and run-time discovery, rollback becomes a config change measured in seconds.

03

A small team is considering micro-frontends

Problem

A startup with five frontend developers on one product finds its Next.js app growing messy and CI slow. Someone proposes splitting into micro-frontends.

How to solve it

Fix the actual pain first. Reorganise the app into feature modules with clear public interfaces, enforce boundaries with ESLint rules or Nx module boundaries, and add CODEOWNERS per feature. Speed up CI with caching and affected-only tests through Turborepo or Nx. Extract the design system into an internal package. Revisit micro-frontends only when several independent teams genuinely block each other on releases. At five developers, run-time composition would add infrastructure, versioning and performance problems without solving the code-structure issue.

Cheat sheet

Expose a module (MF)
exposes: { "./ProductPage": "./src/ProductPage.tsx" }
Declare a remote (MF manifest)
remotes: { product: "product@https://cdn.example.com/product/mf-manifest.json" }
Share React once
shared: { react: { singleton: true, requiredVersion: "^19.0.0" } }
Async bootstrap entry
import("./bootstrap");
Lazy-load a remote (React)
const Page = lazy(() => import("product/ProductPage"))
Cross-app event
window.dispatchEvent(new CustomEvent("cart:updated", { detail }))
Rsbuild MF plugin
npm i -D @module-federation/rsbuild-plugin
Affected-only builds (Nx)
npx nx affected -t build test

Questions & answers

Q1What problem do micro-frontends actually solve?

They solve an organisational problem more than a technical one. When many teams work in one frontend codebase, they share a release train, a pipeline and a dependency tree, so they block each other and ownership blurs. Micro-frontends let each team own a business area end to end and deploy independently, with failures isolated to their part. They also enable gradual migration between frameworks. They do not make a single team faster, they do not improve performance by themselves, and they add run-time integration, versioning and consistency challenges. So the first question is always: do we have the team-scaling problem?

Q2What is Module Federation?

Module Federation lets separately built and deployed JavaScript applications load code from each other at run time. A remote application exposes modules through a small entry or manifest file; a host application declares that remote and imports its modules lazily, as if they were local packages. Shared dependencies like React are declared as singletons, so the browser loads one compatible copy instead of one per team. It started in webpack 5; Module Federation 2.0 is a standalone runtime with plugins for webpack, Rspack, Rsbuild and Vite, plus manifests, type hints and devtools.

Q3How do micro-frontends communicate?

As little and as explicitly as possible. The URL is the best shared state: if checkout needs the product ID, it reads the route. The shell can pass props or a small context object when mounting a remote. For notifications, such as a cart count change, browser custom events or a tiny typed event bus work well. Each micro-frontend fetches its own data from backend APIs, which are the real source of truth. I avoid a shared global store, because it forces teams to coordinate on its shape and version, which defeats the point of splitting.

Q4How do you keep the UI consistent across teams?

With a shared design system that is treated as a product: design tokens for colour, spacing and typography, a versioned component library, Storybook documentation and accessibility built in. Teams consume it as a dependency, ideally as a federated singleton or with tight version ranges so the page does not load several versions. Style isolation, through CSS Modules, scoped prefixes or Shadow DOM, stops one team's CSS from leaking into another's area. Governance matters too: a small platform team owns the design system and the shell, reviews contracts and keeps upgrade paths smooth.

Q5What are the performance risks?

Several independently built bundles can mean duplicated libraries, extra network requests and loading waterfalls: the shell loads, then fetches a remote's manifest, then its chunks, then its data. Each team may also ship its own polyfills and utilities. Mitigations: share framework dependencies as singletons, preload remotes for likely next routes, server-render or prerender where possible, set a page-level performance budget, and monitor Core Web Vitals for the composed page. Route-level splitting performs best, because a user only loads the remotes for the page they are on.

Q6Micro-frontends or a monorepo?

They are different axes. A monorepo is about where code lives; micro-frontends are about how the UI is deployed and composed. You can have a single app in a monorepo, micro-frontends in a monorepo, or micro-frontends in many repositories. For most companies, a monorepo with Nx or Turborepo, clear package boundaries and affected-only builds solves the collaboration pain without run-time composition. If teams later need fully independent deployment, the same monorepo can produce separately deployed micro-frontends with shared tooling and design system, which is far easier than starting with many repositories.

Senior-level questions

S1How do you decide the boundaries between micro-frontends?

I start from team structure and business capabilities, using domain-driven design: bounded contexts like catalogue, checkout and account often map well to micro-frontends, and ideally to backend services owned by the same team. A good boundary has high cohesion inside, few and stable interactions outside, and changes that rarely require two teams to deploy together. Route-level boundaries are preferable because they minimise shared screen space. Warning signs of a bad split are chatty event traffic between parts, frequent coordinated releases, and shared state everywhere. Conway's law applies: if the org chart does not match the split, the architecture will drift toward the org chart, so I align them deliberately.

S2Module Federation, import maps or Web Components: how do you choose?

Module Federation is the most capable for same-framework teams: dependency sharing with version negotiation, lazy loading and bundler integration, but it ties you to supported bundlers and its runtime. Import maps with native ES modules are standards-based and bundler-agnostic, and single-spa or similar loaders build on them; sharing dependencies requires externalising them to agreed URLs, which needs more discipline. Web Components give a framework-neutral, encapsulated contract, attributes in and events out, which is ideal when teams use different frameworks or the consumer is a CMS, but each component may ship its own framework runtime, and SSR plus forms integration is harder. I often combine them: Module Federation for loading, with Web Component wrappers only at cross-framework boundaries.

S3What governance does a micro-frontend architecture need?

Autonomy without governance becomes chaos. A small platform team owns the shell, the design system, the shared dependency policy and the deployment tooling. Teams agree on a short list of rules: allowed frameworks and major versions, singleton dependencies, performance budgets per route, accessibility standards, error and analytics conventions, and contract versioning between shell and remotes. These rules are enforced by tooling, not documents: CI checks bundle size, shared versions and contract tests; dashboards show Core Web Vitals and errors per remote. There is also a lifecycle process for upgrading the shared framework across all teams, with deadlines. The goal is that teams are independent in delivery but consistent in the user experience.

Glossary

Micro-frontend
An independently built and deployed part of a web UI, owned by one team and composed into a larger app.
Shell (host)
The container application that provides layout, routing and shared services, and loads micro-frontends.
Remote
In Module Federation, a build that exposes modules for other applications to load at run time.
Module Federation
A bundler and runtime feature that lets separately deployed builds share and load each other's modules.
Singleton dependency
A shared library forced to load only once on the page, such as React.
Import map
A browser feature mapping bare module names to URLs, enabling native ES module composition.
Multi-Zones
A Next.js pattern where separate apps serve different URL paths under one domain.
Strangler pattern
Gradually replacing a legacy system by routing parts of it to new code until the old system disappears.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.