Design micro frontends
The method applied to a store built by four teams that release separately: when micro frontends are worth it, how the page is composed, shared dependencies, routing and event contracts, releases and failure isolation, and style isolation, with live demos.
"Our store is built by four teams: a platform team and the catalog, checkout and account teams. Every release waits for all of them. Design a frontend that lets each team ship on its own." Micro frontends are the usual answer: split the frontend into pieces that are built, tested and released separately, then put them back together so the shopper sees one store. The split mostly solves an organisational problem, and you pay for it in runtime integration, page weight and coordination. A strong answer says when that trade is worth making, then works through the design method as usual.
Every demo below works on the same sample store: a shell and three micro frontends, with a product page that shows pieces from three teams at once. The composition approaches and tools are compared side by side and none of them is taught on its own.
Requirements and architecture
Start with who needs what. Here the shoppers need one fast, consistent store and the engineers need to release without waiting for each other.
- Functional: one store that looks and behaves like one product; each team owns its pages and widgets end to end, from UI to the API it calls; the header and the cart count are shared across every page.
- Non-functional: a team releases and rolls back without the others; a broken piece doesn't take the page down; the product page stays inside its load-time and JavaScript budgets; the pieces look consistent; content is in the HTML for search engines.
- Scope cuts: how many teams (four here), whether pieces from different teams share one page (yes, on the product page), whether teams may pick their own framework (no), and whether a legacy app has to keep running alongside (not here).
- Architecture: a thin shell owned by the platform team handles routing, the header and the layout. Each team's micro frontend has its own repository or folder and pipeline and is deployed to its own versioned URL, and the shell keeps a manifest of which versions are live. Name the alternatives: one app, or a modular monolith that keeps one deploy.
Try it: the sample store and its alternatives
One app
One codebase, one build, one deploy. Teams work in folders of the same app.
- Wins
- Simplest to build, test and run; one copy of every library; refactors across features are one commit.
- Costs
- Every team ships on the same release train, and one team's broken build or test blocks the rest.
Modular monolith
One deployed app made of packages with enforced boundaries, often in a monorepo.
- Wins
- Clear ownership and boundaries without runtime integration; affected builds and caching keep CI fast.
- Costs
- Still one deploy: teams can work apart but can't release apart, and everyone upgrades the framework together.
Micro frontends
Separately built and released pieces, put together into one product at build time, on the server or in the browser.
- Wins
- Each team releases on its own schedule and can roll back alone; a legacy app can be replaced one route at a time.
- Costs
- Integration work at runtime, shared-library negotiation, cross-team contracts, consistent styling and more to monitor.
Do: Split by business domain, so each team owns a slice of the product (catalog, checkout) with its pages, widgets and API.
Avoid: Splitting by technical layer (a header team, a components team, a data team): every feature then needs all of them.
Read more:Cam Jackson: Micro Frontends(opens in a new tab)micro-frontends.org(opens in a new tab)The design method(opens in a new tab)
Should you use micro frontends?
Micro frontends fix one problem well: teams that block each other's releases. If that isn't your problem, they add cost and solve nothing. A senior answer argues from criteria, and the first question is how many teams there are.
- One team has nobody to release independently from. Keep one app with clear feature folders.
- Several teams that rarely collide do well with a modular monolith: packages with enforced boundaries and owners in one deploy, as in the monorepo case study.
- When releases block each other every week, or a legacy app must be replaced one route at a time, separate releases start paying for themselves. Then pick the lightest composition that fits: a route-level split when each team owns whole pages, and composition on one page only when pieces have to share a page.
- Count the cost before you commit: a platform team to own the shell and shared libraries, contracts between teams, more to monitor, and every extra framework loaded by every visitor.
Try it: describe your situation and get a recommendation
Recommendation
Micro frontends, composed with: edge composition
- Releases block each other often, and independent deploys remove the shared release train.
- With many teams, one deploy pipeline becomes the bottleneck and the blast radius of every bad merge.
- Pieces share a page and search engines and first paint need the content in the HTML, so compose on the server or at the edge.
One example rule set. Real decisions also weigh how much platform work you can staff: someone has to own the shell, the shared libraries and the contracts. The route-level split is the cheapest form, so check whether it is enough before composing pieces on one page.
Do: Write down the release conflict you are solving, and try a route-level split before you compose pieces on one page.
Avoid: Adopting micro frontends so each team can choose its own framework, or because the architecture looks modern.
Read more:Next.js: Multi-Zones(opens in a new tab)Vercel: Microfrontends(opens in a new tab)Building Micro-Frontends, 2nd ed.(opens in a new tab)
Composition: where the page is put together
Composition is where separately built pieces become one page, and it is the decision every later one depends on. You can compose at the router, at build time, on the server, at the edge, in the browser, or with iframes.
- A route-level split gives each team whole pages and never mixes them. It is the simplest form, and moving between teams' pages becomes a full page load.
- Build-time packages are easy, but the shell has to rebuild to ship a team's change. That brings back the shared release train you were trying to remove.
- Server and edge composition put every piece in the first HTML, which is good for search and first paint, but someone has to run the layer that stitches the fragments. On the server the page waits for the slowest fragment; at the edge, cached pieces arrive first and personalised ones follow.
- Client-side runtime composition (Module Federation, import maps) needs only static files and shares libraries well. The cost is a chain of requests before content appears. Iframes isolate everything, and you lose shared libraries, simple routing and easy accessibility.
Try it: compose the product page six ways
The browser loads the shell, reads a manifest, then loads each piece's code from its own URL at runtime and shares libraries between them.
Product details on screen after 1040 ms; every piece interactive after 1040 ms; 300 kB of JavaScript.
| Step | Timeline (0 to 1040 ms, the same scale for every approach) |
|---|---|
| Static shell HTML from the CDN (network) | from 0 ms to 100 ms |
| Shell JS and shared framework (90 kB) (script) | from 100 ms to 380 ms |
| Manifest of live versions (network) | from 380 ms to 480 ms |
| Catalog entry file (network) | from 480 ms to 580 ms |
| Catalog code (90 kB) (script) | from 580 ms to 860 ms |
| Catalog data (data) | from 860 ms to 1010 ms |
| Catalog renders (render) | from 1010 ms to 1040 ms |
| Checkout entry file (network) | from 480 ms to 580 ms |
| Checkout code (70 kB) (script) | from 580 ms to 820 ms |
| Checkout data (data) | from 820 ms to 970 ms |
| Checkout renders (render) | from 970 ms to 1000 ms |
| Account entry file (network) | from 480 ms to 580 ms |
| Account code (50 kB) (script) | from 580 ms to 780 ms |
| Account data (data) | from 780 ms to 930 ms |
| Account renders (render) | from 930 ms to 960 ms |
A model, not a measurement. The thin vertical line marks when product details appear. Assumed: 100 ms round trip to the origin, 30 ms to the CDN edge, 2 ms per kB of JavaScript to download and parse, 150 ms per data request from the browser, 30 ms to render or hydrate a piece, and a 50 kB shared framework. Preloading, HTTP caching and streaming would shorten every line.
- Teams release independentlyYes
- Update the manifest and the next page load picks up the new version.
- No composition server to runYes
- Static files on a CDN.
- Content in the first HTMLNo
- Content appears after several round trips, unless server rendering is added on top.
- Shared libraries load onceYes
- The runtime negotiates one copy of shared libraries.
- Styles and scripts isolatedPartly
- Possible with shadow DOM or scoped CSS, not by default.
- Teams can mix frameworksPartly
- Possible, but every framework is another download.
| Tool | Approach | Status | What it does |
|---|---|---|---|
| Next.js Multi-Zones | Route-level split | Documented in Next.js 16 | Separate Next.js apps per set of paths, each with its own assetPrefix, joined by rewrites. Moving between zones is a full page load, so links across zones use <a>. |
| Vercel Microfrontends | Route-level split | Generally available | Managed path routing of several projects under one domain, set in microfrontends.json. |
| A reverse proxy or CDN rules | Route-level split | Any host | Path-based routing in nginx or your CDN does the same job without a framework feature. |
| Workspace packages | Build-time packages | Any package manager | Each piece is a package in a monorepo or on a registry; the shell installs and bundles them. |
| Server Side Includes (nginx) | Server-side | Stable | An include directive in the HTML names a fragment URL; nginx fetches several includes in parallel. |
| Podium | Server-side | Maintained (5.4.10) | Node.js podlets serve HTML fragments; a layout server fetches and stitches them. |
| Tailor (Zalando) | Server-side | Archived (2022) | A streaming layout service for fragments. Its repository is archived, so pick something maintained. |
| Edge Side Includes | Edge | W3C Note from 2001 | <esi:include src="…" alt="…"> with a fallback URL, processed by CDNs that support it. |
| web-fragments (Cloudflare) | Edge | Pre-1.0 (0.8.2) | Renders fragments on edge workers and moves them into a client app once ready. |
| Module Federation 2.0 | Client-side runtime | Active (2.9.2) | Shared-library negotiation, a manifest, runtime remotes and plugins. Runs on webpack, Rspack and Vite (@module-federation/vite). |
| Native Federation | Client-side runtime | Active (core 4.8.1) | The Module Federation model on ES modules and import maps, with an Angular adapter that supports SSR. |
| single-spa + import maps | Client-side runtime | 6.0.3 stable, 7.0 in beta | A root config mounts apps by route; its docs recommend import maps to load each app and the framework once. |
| Module Federation for Next.js | Client-side runtime | Maintenance mode | Pages Router only, supports up to Next.js 15. Next.js points to Multi-Zones instead. |
| Plain iframes + postMessage | Iframes | Built into browsers | No library needed; each frame needs a title, and its height has to be sent to the parent. |
One snapshot. These tools change quickly, so check their docs before you choose.
Do: Pick per page: a route-level split by default, server or edge composition where pieces share a page and SEO matters, runtime composition where they don't.
Avoid: A client-side waterfall on a page that needs fast first paint, with no preloading of the manifest and entry files.
Read more:Module Federation docs(opens in a new tab)single-spa: recommended setup(opens in a new tab)nginx: SSI module(opens in a new tab)Podium(opens in a new tab)
Deep dive: routing and communication
How pieces find each other and talk decides how coupled the teams really are. Keep the shared surface small and treat every part of it as a versioned contract.
- Routing is two-level. The shell maps top-level prefixes to pieces (
/productsto catalog,/cartto checkout) and each piece routes inside its prefix. Adding a section means one change to the shell's table; everything else stays inside a team. - Prefer the URL for state that should survive a reload, and browser events for news between pieces (an item was added). Give events a team prefix, and have a piece read current state when it mounts, since it can't receive events fired before it loaded.
- Avoid a global store shared by every piece. It ties all teams to one state shape and one library version, and changing it becomes a coordinated release.
- Event payloads, props passed from the shell and URL parameters are APIs. Adding a field is safe. Renaming, removing or changing a field's type breaks every consumer still on the old shape, at runtime and with no compile error.
Try it: route a URL, then change an event's shape
Who owns this URL?
The shell matches /products and mounts Catalog (Discovery team), whose own router handles /espresso-grinder. The header and layout stay the shell's.
Catalog adds an item to the cart
dispatchEvent(new CustomEvent("cart:item-added", { detail: {"sku":"GRN-200","quantity":1,"price":89} }))
Compatible change Every field from version 1 is still sent with the same type, so listeners written against it keep working.
- Header cart count (Shell)3 items: reads only quantity.
- Mini cart total (Checkout)€209: every field it reads is there with the type it expects.
Before the event the cart held 2 items worth €120. Both listeners were written against version 1: { sku: string, quantity: number, price: number }. One example contract; teams often publish it as a typed package or a JSON Schema and run a contract test in each consumer's pipeline.
The URL
Anything that should survive a reload or a shared link: the product id, search terms, filters.
Cost: Only strings, and only for state that belongs in the address.
Browser events
Fire-and-forget news between pieces (an item was added, the user signed in), with a team prefix in the event name.
Cost: No delivery guarantee: a piece that mounts later misses earlier events, so it reads current state on mount.
A shared store
Rarely. One global store couples every team to one state shape and one library version.
Cost: Changing the store is a coordinated release, which is what micro frontends were meant to avoid.
Do: Publish each contract as a typed package or schema, make only additive changes, and run consumer contract tests in every team's pipeline.
Avoid: Pieces reaching into each other's DOM, globals or storage keys, or a shared store that every team writes to.
Read more:MDN: CustomEvent(opens in a new tab)Cam Jackson: Micro Frontends(opens in a new tab)micro-frontends.org(opens in a new tab)
Deep dive: releases and failure isolation
Independent release is the reason to adopt micro frontends, and containing failure is what makes it safe. Each piece should be able to go out, go wrong and come back without touching the others.
- Deploy each build to its own immutable, versioned URL and let the shell read a manifest of live versions. Releasing changes one manifest entry, and rolling back points it at the previous build, which is still on the CDN.
- Wrap every piece in an error boundary with a fallback, and give it a timeout, so a crash or a slow server costs one slot instead of the page. Keep the shell small and boring: nothing above it can catch its errors.
- Boundaries don't catch a broken contract, because the page renders with wrong data. That needs contract tests before release and monitoring of errors and key numbers per piece after it.
- Roll out gradually: send a share of users to the new version through the manifest and watch that piece's errors before going to everyone. The catch is that two versions run at once, so contracts must work with both.
Try it: release, break and roll back one piece
Shell (Platform team)v4.2.0
Catalog (Discovery team)v3.8.1
Checkout (Checkout team)v2.4.0
Account (Accounts team)v1.9.3
manifest.json {"shell":"4.2.0","catalog":"3.8.1","checkout":"2.4.0","account":"1.9.3"}
What the shopper sees on /products/espresso-grinder
Every piece rendered.
- One broken piece doesn't take down the page
- Page meets its load-time budget
- Pieces agree on shared data
- Each team released without waiting for the others
Simplified. Each release bumps a minor version and only that piece's entry in the manifest changes; rolling back points the manifest at the previous build, which is still on the CDN. Without isolation the shell renders every piece in one tree and waits for all of them. With it, an error boundary catches a crash and a 1.5 s timeout lets the rest of the page render first.
Do: Give every slot a boundary, a timeout and a fallback, tag errors and metrics with the piece and version, and keep old builds on the CDN.
Avoid: Deploying all pieces together "to be safe": that is a distributed monolith with all of the cost and none of the independence.
Read more:React: error boundaries(opens in a new tab)Module Federation: runtime plugins(opens in a new tab)Module Federation: manifest(opens in a new tab)
Deep dive: styling and consistency
Style collisions are easy to ship when teams release separately. Two teams write .title, the one that loads last wins, and the header changes when checkout deploys.
- A team prefix on every class (
checkout-title) is cheap and works if everyone follows it. Scoped class names, generated by CSS Modules and similar tools, do the same automatically. Neither stops element selectors such asbuttonfrom leaking. - A shadow tree blocks selectors in both directions. Inherited properties (font, color) and CSS custom properties still cross the boundary, so theme tokens reach every piece. The cost is that global styles, including the design system's, don't; each shadow root has to load them itself.
- Consistency comes from one design system with shared tokens for color, spacing and type, owned by the platform team and loaded once. It should hold UI only, never business logic.
Try it: make checkout's styles leak, then stop them
/* shell */
.title { font-weight: 600; color: var(--brand); }
.button { background: var(--primary); border-radius: 6px; padding: 4px 12px; … }
/* checkout */
.title { text-transform: uppercase; letter-spacing: 0.06em; }
.button { background: var(--destructive); color: var(--background); }
.price { color: var(--brand); font-weight: 600; }
Real CSS, read back with getComputedStyle. Scoped names are what CSS Modules and similar tools generate at build time; the prefix is the same idea done by hand. The theme tokens are this site's own custom properties, which is why the demo follows light and dark mode even inside the shadow roots.
Do: Use scoped class names by default, theme through shared custom properties, and keep global CSS to a reset and the design system.
Avoid: Element or generic class selectors in a piece's CSS, and a design system copied into each team's repository.
Read more:MDN: using shadow DOM(opens in a new tab)MDN: ::part()(opens in a new tab)CSS Modules(opens in a new tab)
Back to the method: this case study used the same stages as the design method and the other case studies: the news feed, the autocomplete and the monorepo. The monorepo and micro frontends answer different questions and often go together: one is how teams share code, the other is how they release it.