Micro-Frontends Architecture
Micro-frontends apply the principles of microservices to the frontend: independent teams own independent slices of the UI, deploying independently, with independent technology choices. It's a powerful pattern β and a dangerous one if adopted without understanding the costs.
Why Micro-Frontends?
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Monolith Frontend β
β β
β Team A Team B Team C Team D β
β βββββββββββββββββββββββββββββββββββββββββββββ β
β All teams commit to the same repo β
β All teams deploy together β
β Merge conflicts, coordination overhead, slow CI β
β One bad deploy = everything broken β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βΌ Scale pain βΌ
ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ
β Team A β β Team B β β Team C β β Team D β
β Catalog β β Cart β β Checkout β β Account β
β β β β β β β β
β Own repo β β Own repo β β Own repo β β Own repo β
β Own CI β β Own CI β β Own CI β β Own CI β
β Own deployβ β Own deployβ β Own deployβ β Own deployβ
ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββThe Three Drivers
- Team autonomy: Teams can choose their own tools, frameworks, and release cadence
- Independent deployments: Ship the cart without waiting for catalog to finish their feature
- Technology heterogeneity: Team A uses React, Team C uses Vue β both coexist
Composition Patterns
Build-Time Composition
Micro-frontends are npm packages consumed by a host application at build time.
ββββββββββββββββ
β Host App β
β β npm install
β imports: ββββββββββββββββββ @org/catalog-mfe
β @org/cart ββββββββββββββββββ @org/cart-mfe
β @org/acct ββββββββββββββββββ @org/account-mfe
β β
β Webpack βββββ Single bundle βββββΆ Deploy
ββββββββββββββββPros: Simple, good tree-shaking, type safety across boundaries Cons: All teams must deploy together (defeats the purpose), version lock-in
Runtime Composition via Module Federation
The dominant pattern for production micro-frontends:
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Host Shell β
β ββββββββββββββββββββββββββββββββββββββββββββββ β
β β Header (shared) β β
β ββββββββββββββββββββββββββββββββββββββββββββββ€ β
β β βββββββββββββββββββββββββββ β β
β β Nav β Remote Module β β β
β β bar β (loaded at runtime) β β β
β β β β β β
β β β /catalog β catalog-mfe β β β
β β β /cart β cart-mfe β β β
β β β /account β account-mfe β β β
β β βββββββββββββββββββββββββββ β β
β ββββββββββββββββββββββββββββββββββββββββββββββ€ β
β β Footer (shared) β β
β ββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββIframe Composition
<iframe src="https://catalog.example.com" title="Product Catalog" />Pros: Perfect isolation (CSS, JS, security) Cons: No shared state, poor UX (no shared scroll, routing), accessibility nightmare, performance overhead
Edge-Side Includes (ESI)
Server-side composition at the CDN/edge layer:
<header>
<esi:include src="https://header.example.com/fragment" />
</header>
<main>
<esi:include src="https://catalog.example.com/fragment" />
</main>Pros: Works without JavaScript, fast TTFB Cons: Limited interactivity, CDN support varies
Module Federation 2.0 Deep Dive
Module Federation is Webpack's (and now Rspack's) native solution for sharing code between independently built applications at runtime.
Host Configuration
// shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
catalog: 'catalog@https://catalog.example.com/remoteEntry.js',
cart: 'cart@https://cart.example.com/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
'react-router-dom': { singleton: true },
},
}),
],
};Remote Configuration
// catalog/webpack.config.js
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'catalog',
filename: 'remoteEntry.js',
exposes: {
'./CatalogPage': './src/CatalogPage',
'./ProductCard': './src/components/ProductCard',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};Loading Remote Modules
const CatalogPage = React.lazy(() => import('catalog/CatalogPage'));
function App() {
return (
<Routes>
<Route
path="/catalog/*"
element={
<Suspense fallback={<PageSkeleton />}>
<ErrorBoundary fallback={<RemoteLoadError />}>
<CatalogPage />
</ErrorBoundary>
</Suspense>
}
/>
</Routes>
);
}Module Federation 2.0 Improvements
- Runtime API: Dynamic remote registration without rebuild
- Manifest protocol: Standardized remote discovery
- Type hints: Shared TypeScript types across remotes
- Version negotiation: Smarter shared dependency resolution
Shared Dependencies Strategy
The biggest challenge: how do you avoid loading React 5 times?
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Shared Dependency Strategy β
β β
β Singleton (one version loaded) β
β ββ react, react-dom β
β ββ react-router-dom β
β ββ design-system β
β β
β Scoped (each MFE has its own) β
β ββ form libraries β
β ββ date libraries β
β ββ MFE-specific utilities β
β β
β Externalized (loaded from CDN) β
β ββ Large libraries loaded once via importmap β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ{
"imports": {
"react": "https://cdn.example.com/react@18/esm/index.js",
"react-dom": "https://cdn.example.com/react-dom@18/esm/index.js"
}
}Routing Across Micro-Frontends
Shell-Owned Routing
Shell Router (top-level routes)
β
ββ /catalog/* β Catalog MFE (owns sub-routes)
β ββ /catalog/
β ββ /catalog/:id
β ββ /catalog/category/:slug
β
ββ /cart/* β Cart MFE
β ββ /cart/
β ββ /cart/checkout
β
ββ /account/* β Account MFE
ββ /account/profile
ββ /account/ordersThe contract: The shell owns top-level routes. Each MFE owns its sub-routes. Navigation between MFEs goes through the shell's router. Navigation within an MFE uses its own router.
// Shell routes
<Routes>
<Route path="/catalog/*" element={<CatalogMFE />} />
<Route path="/cart/*" element={<CartMFE />} />
<Route path="/account/*" element={<AccountMFE />} />
</Routes>
// Inside Catalog MFE β uses MemoryRouter or basename
<Routes>
<Route index element={<CatalogList />} />
<Route path=":id" element={<ProductDetail />} />
</Routes>Shared State & Communication
Micro-frontends should be loosely coupled. Communication should be through well-defined interfaces, not shared state stores.
Custom Events (Recommended)
// Cart MFE dispatches
window.dispatchEvent(new CustomEvent('cart:updated', {
detail: { itemCount: 3, total: 59.97 },
}));
// Header MFE listens
useEffect(() => {
const handler = (e: CustomEvent) => setCartCount(e.detail.itemCount);
window.addEventListener('cart:updated', handler);
return () => window.removeEventListener('cart:updated', handler);
}, []);URL State (Simplest)
/catalog?search=shoes&sort=price
Every MFE can read URL params. No shared runtime needed.Shared Event Bus (Typed)
type EventMap = {
'cart:updated': { itemCount: number; total: number };
'user:logout': undefined;
'notification:new': { message: string; type: 'info' | 'error' };
};
class EventBus {
private handlers = new Map<string, Set<Function>>();
emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {
this.handlers.get(event)?.forEach(handler => handler(data));
}
on<K extends keyof EventMap>(event: K, handler: (data: EventMap[K]) => void) {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)!.add(handler);
return () => this.handlers.get(event)?.delete(handler);
}
}
export const eventBus = new EventBus();CSS Isolation
Without isolation, one MFE's .button class clobbers another's.
| Strategy | Isolation Level | Trade-off |
|---|---|---|
| CSS Modules | File-scoped | Build tool required |
| Shadow DOM | Full encapsulation | Style sharing is harder |
| CSS-in-JS | Runtime scoped | Runtime cost, SSR complexity |
| BEM / Prefixing | Convention-based | Relies on discipline |
| CSS Layers | Cascade control | Modern browsers only |
/* Using CSS Layers for MFE isolation */
@layer shell, catalog, cart, account;
@layer catalog {
.product-card { /* only within catalog layer */ }
}
@layer cart {
.product-card { /* different styles, no conflict */ }
}Testing Strategies
ββββββββββββββββββββββββββββββββββββββββββββββββββ
β Testing Pyramid for MFEs β
β β
β β² β
β β± β² E2E (full integration) β
β β±ββββ² All MFEs composed in shell β
β β± Ctr β² Contract tests β
β β±ββββββββ² Verify MFE β Shell interface β
β β± Int β² Integration within each MFE β
β β±ββββββββββββ² β
β β± Unit β² Components, hooks, utils β
β β±ββββββββββββββββ² β
ββββββββββββββββββββββββββββββββββββββββββββββββββContract tests are critical: they verify that the shell and remotes agree on the interface (exported components, props, events) without requiring full integration.
When NOT to Use Micro-Frontends
| Signal | Reality |
|---|---|
| "It's what Netflix does" | Netflix has 1000+ frontend engineers. You have 12. |
| One team, one app | You're adding complexity without the organizational benefit |
| Premature optimization | Start monolithic, extract when pain is real |
| Tech diversity for fun | Consistency > novelty for maintainability |
| < 3 teams | The coordination cost exceeds the independence benefit |
The Complexity Cost
Monolith: 1 repo, 1 build, 1 deploy, 1 test suite
MFE: N repos, N builds, N deploys, N+1 test suites,
shared deps management, cross-MFE routing,
CSS isolation, communication protocol,
deployment orchestration, monitoring per MFEMonorepo vs Polyrepo
| Factor | Monorepo | Polyrepo |
|---|---|---|
| Code sharing | Easy (workspace imports) | Hard (publish packages) |
| Consistency | Enforced (shared config) | Varies per repo |
| CI/CD | Complex (affected-only builds) | Simple per repo |
| Tooling | Turborepo, Nx, Lerna | Standard per-repo tooling |
| Autonomy | Lower (shared conventions) | Higher (full independence) |
| Onboarding | One clone | N clones |
Recommended: Monorepo with Module Federation
monorepo/
apps/
shell/ β Host application
catalog/ β Remote MFE
cart/ β Remote MFE
account/ β Remote MFE
packages/
design-system/ β Shared UI components
shared-types/ β TypeScript interfaces
event-bus/ β Communication layer
eslint-config/ β Shared linting rules
turbo.json β Build orchestrationThis gives you the organizational benefits of micro-frontends (independent builds, independent deploys) with the developer experience of a monorepo (shared types, easy refactoring, consistent tooling).
Deployment Pipeline
ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ
β Commit βββββΆβ Build βββββΆβ Test βββββΆβ Deploy β
β β β MFE β β Unit + β β to CDN β
β (cart) β β only β β Contract β β (cart) β
ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ
β
βΌ
ββββββββββββ
β Smoke β
β Test in β
β Staging β
β (full β
β shell) β
ββββββββββββEach MFE deploys independently. The shell loads the latest version of each remote at runtime. No coordinated releases needed.
Micro-frontends are an organizational scaling pattern, not a technical optimization. Use them when you have the team structure that demands them. Don't use them because they're architecturally interesting. That's the architect's judgment call.