← Back to the library
ArchitectureHow it works · Deep dive · 15 min

Micro-frontends: integration styles, costs and when not to

How do independently deployed frontends get stitched into one product, and what does that cost?

Micro-frontends split one user-facing product into parts that separate teams build and deploy on their own, then compose them at build time, on the server or edge, or in the browser at runtime. They solve an organizational bottleneck and charge you for it in payload, version skew and operational complexity.

THE MENTAL MODEL

The unit is the deploy, not the component. If two pieces always ship together, they are modules of one app, however many repos they live in. A micro-frontend architecture is a contract plus a composition point: the contract is routes, a mount/unmount interface, events and design tokens; the composition point is where pieces meet (the bundler, an edge proxy, the HTML, or a runtime loader in the browser). The later the composition, the more independent the deploys, and the more you pay at runtime for duplicate dependencies, extra requests and failures that only exist in production. Most teams get the autonomy they want from route-level splits or a well-bounded modular monolith, and reach for runtime composition only when several teams must share one page.

HOW IT FITS TOGETHER

Integration styles comparedOrdered roughly from earliest to latest composition. Later composition buys deploy independence with runtime cost and complexity.
IsolationIndependent deployRuntime costComplexityGood for
Build-time npm packagesNone: one bundle, one runtimeNo: host must bump and redeployLowest; tree-shaken, dedupedLowShared libraries; not truly micro-frontends
Route split (multi-zones)Full app per path prefixYes, per zoneHard navigation between zonesLow to medium: proxy rules, shared shellSeparate areas: marketing, docs, dashboard
Server or edge compositionProcess-level; shared page CSS/JSYes, per fragment serviceLow client cost; edge latency per fragmentMedium: caching, fragment timeoutsContent-heavy pages, SSR-first sites
Module FederationSame JS realm and DOM; shared singletonsYes; host reads remote manifest at runtimeExtra manifest and chunk requests; shared depsHigh: share negotiation, version skew, typesSeveral teams on one SPA page, same framework
Import maps + native ESMSame JS realm and DOMYes; update the map entryOne request per module; no bundler dedupeMedium: map hosting, version pinningFramework-agnostic shells, CDN-hosted apps
single-spaSame realm; lifecycles per appYes; usually with import mapsRouter plus each app's framework runtimeHigh: root config, framework adaptersMixed frameworks during a migration
Web components wrapperDOM and CSS via shadow DOM; same realmYes, when loaded at runtimeEach wrapper may bring its own frameworkMediumWidgets embedded across stacks
iframesStrong: separate document, can be cross-originYesFull document per frame; sizing and focus costsLow to build, high for UX polishUntrusted or third-party apps, payments
A shell loading a federated remote at runtimeModule Federation 2.0 with a manifest. Nothing about the checkout team's release is baked into the shell build except the remote's name and manifest URL.
  1. Shell app (browser) → MF runtimeRoute /checkout: import("checkout/Cart")Lazy, behind a Suspense boundary and an error boundary.
  2. MF runtime → Checkout CDNGET /mf-manifest.jsonThe manifest is the deploy pointer: short cache TTL, hashed assets behind it.
  3. Checkout CDN → MF runtimeExposes, shared deps, asset URLs
  4. MF runtime → Checkout CDNGET remoteEntry.js
  5. Checkout CDN → MF runtimeContainer with init() and get()
  6. MF runtime → MF runtimeinit(shareScope): pick react@19 from hostSingletons resolve to one version. A requiredVersion mismatch warns or fails, depending on strictVersion.
  7. MF runtime → Checkout CDNGET Cart.[hash].js (+ CSS)
  8. Checkout CDN → MF runtimeModule factory
  9. MF runtime → Shell app (browser)Cart component
  10. Shell app (browser) → Checkout APICart fetches its own dataEach micro-frontend owns its backend calls; the shell passes identity, not data.
  11. Checkout API → Shell app (browser)Cart JSON
  12. Shell app (browser) → Shell app (browser)Remote fails? Render fallback, keep shellA remote outage must degrade one region of the page, not blank the app.

KEY TERMS

Composition point
Where independently built pieces meet: the bundler (packages), a proxy or edge worker (routes and fragments), the HTML (import maps, script tags) or a runtime loader (Module Federation, single-spa). It decides what can deploy independently and what fails at runtime instead of at build time.
Module Federation 2.0
Remotes publish an mf-manifest.json describing exposed modules and shared dependencies; hosts load them at runtime and negotiate shared singletons. 2.0 (stable in 2026) adds dynamic TypeScript type hints, a runtime plugin system, preloading, shared-dependency tree shaking, devtools and SSR via Modern.js, with plugins for webpack, Rspack, Rsbuild, Vite and others. The Next.js plugin is in maintenance mode and refuses to run with the App Router.
Shared dependencies and version skew
Two React copies on one page break hooks and context; one shared React means every team must run a compatible version. Singletons with requiredVersion ranges turn skew into a warning or failure you can see. Upgrades of shared majors become coordinated releases again, so keep the shared set small.
Routing ownership
Exactly one router owns the URL at each level: the shell maps path prefixes to micro-frontends, and each app owns routes beneath its prefix. Two routers listening to history without a contract produce double navigations and broken back buttons.
Cross-app communication
Prefer the URL for shareable state, a backend for durable state, and DOM CustomEvents with a versioned, typed contract for notifications. A shared global store recreates the coupling you split to escape and turns every refactor into a cross-team change.
Design consistency
Share design tokens and a component library (often web components or a framework package) rather than whole pages. Pin versions per micro-frontend and budget for visual drift between deploys.
Modular monolith frontend
One deployable app with enforced internal boundaries: per-team folders or packages, lint rules on imports, CODEOWNERS, and route-level lazy loading. It gives ownership and smaller bundles without runtime integration, and can be split later along the same seams.

IN YOUR STACK

TypeScript · Module Federation 2 + Rsbuild Client

// checkout/rsbuild.config.ts (remote)
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { pluginModuleFederation } from "@module-federation/rsbuild-plugin";

const shared = { react: { singleton: true, requiredVersion: "^19.0.0" }, "react-dom": { singleton: true, requiredVersion: "^19.0.0" } };

export default defineConfig({
  plugins: [pluginReact(), pluginModuleFederation({
    name: "checkout",
    exposes: { "./Cart": "./src/Cart.tsx" },
    shared,
  })],
});

// shell/rsbuild.config.ts (host): same imports and shared map
export default defineConfig({
  plugins: [pluginReact(), pluginModuleFederation({
    name: "shell",
    remotes: { checkout: "checkout@https://checkout.example.com/mf-manifest.json" },
    shared,
  })],
});

// shell/src/routes.tsx: const Cart = lazy(() => import("checkout/Cart"));
  • Point remotes at the manifest, not at a hashed bundle: the remote team deploys by replacing mf-manifest.json, so give it a short cache TTL.
  • The 2.0 plugin downloads the remote's types for import("checkout/Cart") during development. Without it, type the import yourself or the boundary is any.
  • For remotes chosen at runtime (per tenant, per feature flag) use createInstance and loadRemote from @module-federation/enhanced/runtime instead of static remotes.
Module Federation 2 + Rsbuild documentation (opens in new tab)

HTML + JavaScript · Import maps + dynamic import Client

<script type="importmap">
{
  "imports": {
    "@acme/checkout": "https://cdn.example.com/checkout/4.12.0/app.js",
    "@acme/search": "https://cdn.example.com/search/2.3.1/app.js",
    "@acme/platform/": "https://cdn.example.com/platform/1.8.0/"
  }
}
</script>
<main id="outlet"></main>
<script type="module">
  // Each micro-frontend exports mount(el, props) and unmount(el).
  const routes = { "/checkout": "@acme/checkout", "/search": "@acme/search" };
  const outlet = document.getElementById("outlet");
  let current = null;
  async function route() {
    const prefix = Object.keys(routes).find((p) => location.pathname.startsWith(p));
    await current?.unmount(outlet);
    current = prefix ? await import(routes[prefix]) : null; // resolved via the map
    await current?.mount(outlet, { basePath: prefix });
  }
  addEventListener("popstate", route);
  route();
</script>
  • Deploying a micro-frontend means changing one URL in the map, which the server renders into the shell HTML. Rollback is the previous URL.
  • The map must appear before any module that uses its specifiers. Add an integrity map entry per URL when assets come from another origin.
  • Bare specifiers are not bundled or deduped across apps; share libraries through map entries such as @acme/platform/ or accept duplication.
Import maps + dynamic import documentation (opens in new tab)

TypeScript (TSX) · Custom element + React 19 Client

import { createRoot, type Root } from "react-dom/client";
import { CheckoutApp } from "./CheckoutApp";

class CheckoutMicroApp extends HTMLElement {
  static observedAttributes = ["cart-id", "base-path"];
  #root: Root | null = null;
  connectedCallback() {
    this.#root = createRoot(this); // light DOM: inherits the shell's tokens and fonts
    this.#render();
  }
  attributeChangedCallback() { if (this.#root) this.#render(); }
  disconnectedCallback() {
    const root = this.#root; this.#root = null;
    queueMicrotask(() => root?.unmount()); // the host may be mid-render
  }
  #render() {
    this.#root?.render(<CheckoutApp
      cartId={this.getAttribute("cart-id") ?? ""}
      basePath={this.getAttribute("base-path") ?? "/checkout"}
      onOrderPlaced={(orderId: string) => this.dispatchEvent(
        new CustomEvent("checkout:order-placed", { detail: { orderId }, bubbles: true, composed: true }))}
    />);
  }
}
if (!customElements.get("checkout-app")) customElements.define("checkout-app", CheckoutMicroApp);
  • The shell only knows <checkout-app cart-id="..."> and one event; it does not care that React is inside.
  • Unmount in disconnectedCallback, or every route change leaks a React root with its listeners and timers.
  • Guard define(): two bundles that both include the wrapper would otherwise throw on the second definition.
Custom element + React 19 documentation (opens in new tab)

JavaScript · Next.js multi-zones Server

// next.config.js of the main zone: serves / and routes other zones
/** @type {import('next').NextConfig} */
module.exports = {
  async rewrites() {
    return [
      { source: "/blog", destination: `${process.env.BLOG_DOMAIN}/blog` },
      { source: "/blog/:path+", destination: `${process.env.BLOG_DOMAIN}/blog/:path+` },
      { source: "/blog-static/:path+", destination: `${process.env.BLOG_DOMAIN}/blog-static/:path+` },
    ];
  },
};

// next.config.js of the blog zone: a normal Next.js app with app/blog/...
/** @type {import('next').NextConfig} */
module.exports = {
  assetPrefix: "/blog-static", // its /_next assets must not collide with the main zone
  experimental: {
    serverActions: { allowedOrigins: ["www.example.com"] }, // the user-facing origin
  },
};

// Links into another zone are plain <a href="/blog">, not <Link>.
  • Navigation inside a zone is a soft client-side navigation; across zones it is a full page load. Put pages that users move between often in the same zone.
  • Each path must belong to exactly one zone. For flag-driven routing during a migration, rewrite in proxy.js (formerly middleware) instead of static rewrites.
  • Any reverse proxy or edge worker can play the router role; the Next.js rewrites are a convenience, not a requirement.
Next.js multi-zones documentation (opens in new tab)

TypeScript · DOM CustomEvent Client

// @acme/platform-events: a tiny contract package, versioned like a public API
export type PlatformEvents = {
  "cart:updated": { cartId: string; itemCount: number };
  "auth:signed-out": { reason: "user" | "expired" };
};

export function emit<K extends keyof PlatformEvents>(type: K, detail: PlatformEvents[K]) {
  window.dispatchEvent(new CustomEvent(type, { detail }));
}

export function on<K extends keyof PlatformEvents>(
  type: K,
  handler: (detail: PlatformEvents[K]) => void,
): () => void {
  const listener = (event: Event) => handler((event as CustomEvent<PlatformEvents[K]>).detail);
  window.addEventListener(type, listener);
  return () => window.removeEventListener(type, listener);
}

// checkout micro-app
emit("cart:updated", { cartId: "c_42", itemCount: 3 });

// header micro-app (React): subscribe on mount, unsubscribe on unmount
// useEffect(() => on("cart:updated", ({ itemCount }) => setBadge(itemCount)), []);
  • Events are fire-and-forget: an app that mounts later misses them. Late joiners read current state from the URL or the owning API, not from replayed events.
  • Additive changes only; renaming a field is a breaking change across independently deployed apps, so version the event name (cart:updated.v2) instead.
DOM CustomEvent documentation (opens in new tab)

JavaScript · single-spa 6 Client

// root-config.js: the only code that knows every application
import { registerApplication, start } from "single-spa";

registerApplication({
  name: "@acme/navbar",
  app: () => import("@acme/navbar"), // resolved through an import map
  activeWhen: () => true, // always mounted
});

registerApplication({
  name: "@acme/checkout",
  app: () => import("@acme/checkout"),
  activeWhen: ["/checkout", (location) => location.pathname.startsWith("/cart")],
  customProps: (name, location) => ({ getAccessToken: () => auth.getAccessToken() }),
});

const auth = { getAccessToken: async () => (await fetch("/session")).json().then((s) => s.token) };

start({ urlRerouteOnly: true });

// @acme/checkout entry: export lifecycles (single-spa-react generates them)
// export const { bootstrap, mount, unmount } = singleSpaReact({ React, ReactDOMClient, rootComponent: Root });
  • single-spa owns routing between apps and calls bootstrap, mount and unmount; each app keeps its own framework and internal router.
  • Pass functions (getAccessToken) through customProps rather than token values, so apps do not hold stale credentials.
  • 6.x is the stable line; 7.0 is in beta. Its docs historically assume SystemJS, but native import maps with import() work.
single-spa 6 documentation (opens in new tab)

WHEN TO REACH FOR WHAT

Modular monolith frontend

Use when Default. One to a handful of teams, one framework, and the pain is build time or ownership rather than release coordination. Enforce boundaries with packages, import lint rules and CODEOWNERS.

Avoid when Teams are genuinely blocked by a shared release train or must use different frameworks, and you have already tried trunk-based deploys and feature flags.

Route-level split (zones, proxy)

Use when Product areas rarely share a page (marketing, docs, app, admin). Each team owns a path prefix and a full app.

Avoid when Users bounce between the areas constantly (full reloads hurt) or one page needs pieces from several teams.

Module Federation

Use when Several teams must contribute to the same page in the same framework and deploy independently, and you can staff a platform team for shared deps and tooling.

Avoid when You lack that platform ownership, mix frameworks heavily, or use the Next.js App Router (the MF Next.js plugin is in maintenance mode and blocks it). Skew and runtime-only failures will eat the autonomy you wanted.

Import maps / single-spa

Use when Migrating between frameworks incrementally, or a framework-agnostic shell with apps hosted on a CDN.

Avoid when Everything is one framework; you pay for a root router and lifecycle adapters without needing them.

Server or edge composition

Use when Content-heavy, SSR-first pages where fragments (header, recommendations, reviews) come from different teams and should be cached separately.

Avoid when Highly interactive SPAs where fragments need shared client state; you end up rehydrating several apps on one page.

iframes

Use when A real security or failure boundary: third-party or untrusted apps, payment forms, legacy apps you cannot touch.

Avoid when First-party features that should feel native; sizing, focus, routing, accessibility and performance all fight you.

WHERE IT BITES

  • Splitting by technical layer or by component instead of by business capability and route. Micro-frontends that must change together on every feature are a distributed monolith with extra network hops.
  • Shipping several copies of React, a design system and date libraries: the payload grows with every team. Measure total JS per route and set a budget before adding a remote.
  • A shared global store (one Redux instance across apps). It reintroduces the coupling the split was meant to remove; share the URL, events and backend state instead.
  • Testing only in isolation. A remote can pass its own CI and still break the shell through a shared-version mismatch, CSS leakage or a renamed event; run contract tests and a composed smoke test on every remote deploy.
  • No fallback when a remote fails to load: an outage of one team's CDN blanks the whole app unless each remote sits behind an error boundary with a degraded view.
  • Global CSS from one app restyling another. Scope styles (CSS modules, prefixes, cascade layers or shadow DOM) and share only design tokens.

CLOSE THE AI. EXPLAIN THIS.

Your shell loads three federated remotes. After the search team upgrades React to a new major, checkout starts throwing invalid hook call errors in production only. Explain how shared singletons and requiredVersion produced this, and what you would change in the contract and the pipeline.

SOURCES

Explainer reviewed