Shared from "Frontend Concepts Low Level" on Inkdown
Micro frontends extend the microservices philosophy to the browser. Instead of one monolithic frontend app, you have multiple independent frontend apps composed into a single user experience.
Think of a newspaper:
Each section is independent. The sports team doesn't wait for the weather team. They publish on their own schedule. But the reader sees one cohesive newspaper.
A micro frontend architecture consists of:
┌─────────────────────────────────────────────────────────┐
│ Shell / Host App │
│ │
│ ┌──────────┐ ┌───────────┐ ┌──────────────────────┐ │
│ │ Header │ │ Nav │ │ Footer │ │
│ │ (Team A) │ │ (Team A) │ │ (Team A) │ │
│ └──────────┘ └───────────┘ └──────────────────────┘ │
│ │
│ ┌─────────────────────┐ ┌──────────────────────────┐ │
│ │ │ │ │ │
│ │ Product Listing │ │ Recommendations │ │
│ │ (Team B) │ │ (Team C) │ │
│ │ │ │ │ │
│ └─────────────────────┘ └──────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐│
│ │ Shopping Cart ││
│ │ (Team D) ││
│ └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘Each box is a separate app, built, tested, and deployed independently. The shell orchestrates loading and composing them. The user sees a single, unified page.
User sees: What's actually happening:
┌──────────────┐ ┌──────────────────────────────────┐
│ Unified Page │ │ Shell │
│ │ │ ├─ Header App (Team A, v2.3)│
│ Header │ │ ├─ Product App (Team B, v1.7)│
│ Products │ = │ ├─ Recommend App (Team C, v3.1)│
│ Recommend │ │ └─ Cart App (Team D, v2.0)│
│ Cart │ │ │
└──────────────┘ └──────────────────────────────────┘A large frontend monolith becomes painful when:
| Problem | What Happens |
|---|---|
| Multiple teams, one codebase | Merge conflicts, blocked deploys, "who owns this component?" |
| One tech stack locks everyone | Teams can't choose what works best for their domain |
| Single deploy bottleneck | One team's change blocks everyone else's release |
| Growing build times | 10-minute builds kill productivity |
| Fear of breaking things | Changing a shared component might break 50 pages |
| Big-bang rewrites | "Let's rewrite the whole app" → 2-year project that fails |
Micro frontends are NOT for every project. They solve organizational scaling problems, not technical ones. If you have 2-3 frontend developers on one product, a monolith is fine. If you have 10+ teams building one user-facing product, micro frontends start making sense.
There are fundamentally different ways to compose micro frontends. They range from dead-simple to architecturally complex.
The simplest — just embed apps in iframes.
<iframe src="https://team-b-app.internal.com/product-listing" />┌─────────────────────────────────────┐
│ Shell (main-app.com) │
│ │
│ ┌─────────────────────────────┐ │
│ │ iframe: team-b.com/listing │ │
│ │ (completely isolated world) │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ iframe: team-c.com/recommend│ │
│ │ (another isolated world) │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘postMessage needed for communication (clunky, string-only)Legacy integration, embedding third-party content (payment widgets, maps), quick prototypes. Not for serious micro frontend architecture.
Stripe's payment elements used to be iframes. So do many ad embeds. This is fine for isolated widgets but not for building a full page.
Each team publishes their components as npm packages. The host app installs and imports them.
Team B publishes: @team-b/product-listing → npm package
Team C publishes: @team-c/recommendations → npm package
Team D publishes: @team-d/shopping-cart → npm package// Host app's package.json
{
"dependencies": {
"@team-b/product-listing": "^1.7.0",
"@team-c/recommendations": "^3.1.0",
"@team-d/shopping-cart": "^2.0.0"
}
}
// Host app's code
import { ProductListing } from '@team-b/product-listing';
import { Recommendations } from '@team-c/recommendations';
import { ShoppingCart } from '@team-d/shopping-cart';
function ShopPage() {
return (
<div>
<ProductListing categoryId="shoes" />
<Recommendations userId={currentUser.id} />
<ShoppingCart />
</div>
);
}Team B repo Team C repo Team D repo
│ │ │
▼ ▼ ▼
npm publish npm publish npm publish
│ │ │
└────────────────────┼────────────────────┘
│
▼
Host app's package.json
│
npm install
│
▼
Single webpack build
│
▼
One deployable bundleTeam B updates @team-b/product-listing from 1.7.0 → 1.8.0
→ Host app must:
1. Update package.json
2. Run tests
3. Rebuild
4. Redeploy
This means Team B's "independent" update still requires host app involvement.Small-to-medium orgs that want code separation and team ownership but don't need truly independent deployment. This is the most common "starter" approach and honestly the right choice for many teams.
Each micro frontend is loaded at runtime via <script> tags. The shell dynamically loads scripts and mounts apps.
<!-- Shell HTML -->
<div id="header"></div>
<div id="product-listing"></div>
<div id="recommendations"></div>
<!-- Each team deploys their JS to their own CDN -->
<script src="https://cdn.team-a.com/header.js"></script>
<script src="https://cdn.team-b.com/product-listing.js"></script>
<script src="https://cdn.team-c.com/recommendations.js"></script>Each micro frontend exposes a global mount/unmount API:
// Team B's product-listing.js (deployed to cdn.team-b.com)
window.ProductListing = {
mount(container, props) {
ReactDOM.render(<Listing {...props} />, container);
},
unmount(container) {
ReactDOM.unmountComponentAtNode(container);
}
};The shell calls these:
// Shell
window.ProductListing.mount(
document.getElementById('product-listing'),
{ categoryId: 'shoes', userId: currentUser.id }
);
// When navigating away:
window.ProductListing.unmount(
document.getElementById('product-listing')
);Team B deploys: cdn.team-b.com/product-listing.js
Team C deploys: cdn.team-c.com/recommendations.js
Shell (at runtime):
1. Fetches scripts from CDNs
2. Scripts register globals (window.ProductListing, etc.)
3. Shell calls mount() with props
4. Shell calls unmount() when navigating awayEvery micro frontend must implement:
{
mount(container: HTMLElement, props: Object) => void,
unmount(container: HTMLElement) => void,
// Optional:
update?(props: Object) => void, // for prop updates without unmount/remount
}<!-- Shell loads React ONCE -->
<script src="https://cdn.reactjs.org/react@18.2.0/umd/react.production.min.js"></script>
<script src="https://cdn.reactjs.org/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<!-- Each MFE uses window.React (externals in webpack) -->
<!-- Team B's webpack config: -->
<!-- externals: { react: 'React', 'react-dom': 'ReactDOM' } -->Medium orgs that need independent deployment but don't want the complexity of Module Federation. Also used in legacy modernization where you're gradually migrating from monolith.
Each micro frontend is a custom element (Web Component). The shell just uses HTML tags.
// Team B registers a custom element
class ProductListing extends HTMLElement {
connectedCallback() {
// Read attributes (props)
const categoryId = this.getAttribute('category-id');
const userId = this.getAttribute('user-id');
// Option A: Render with Shadow DOM (CSS isolation)
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<div class="listing">...</div>`;
// Option B: Mount React/Vue into shadow DOM
ReactDOM.render(
<Listing categoryId={categoryId} userId={userId} />,
this.shadowRoot
);
}
disconnectedCallback() {
// Cleanup
ReactDOM.unmountComponentAtNode(this.shadowRoot);
}
// Optional: react to attribute changes
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'category-id') {
// Re-render with new category
}
}
static get observedAttributes() {
return ['category-id', 'user-id'];
}
}
customElements.define('product-listing', ProductListing);<!-- Shell just uses HTML — framework agnostic! -->
<product-listing category-id="shoes" user-id="123"></product-listing>
<recommendations-widget user-id="123"></recommendations-widget>
<shopping-cart user-id="123"></shopping-cart>Each MFE registers a custom element:
customElements.define('product-listing', ProductListing)
Shell uses them like native HTML:
<product-listing category-id="shoes"></product-listing>
Browser handles lifecycle:
- Element appears in DOM → connectedCallback() fires → MFE mounts
- Element removed from DOM → disconnectedCallback() fires → MFE unmounts
- Attribute changes → attributeChangedCallback() fires → MFE updates// Passing data IN (attributes — strings only!)
<product-listing category-id="shoes" user-id="123"></product-listing>
// For complex data, use properties:
const el = document.querySelector('product-listing');
el.filters = { priceRange: [0, 100], brands: ['Nike', 'Adidas'] };
// Emitting data OUT (custom events)
class ProductListing extends HTMLElement {
handleAddToCart(product) {
this.dispatchEvent(new CustomEvent('add-to-cart', {
detail: { product },
bubbles: true, // bubble up through DOM
composed: true, // cross shadow DOM boundary
}));
}
}
// Shell or other MFes listen:
document.querySelector('product-listing')
.addEventListener('add-to-cart', (e) => {
cart.addItem(e.detail.product);
});React's event system uses synthetic events attached to the root. Shadow DOM creates a separate DOM tree. React's events don't naturally cross the shadow boundary. This means:
onChange, onInput don't fire for shadow DOM inputscomposed: true)ref doesn't work across shadow boundariesYou can work around these, but it's friction.
When you need maximum framework flexibility and teams use different stacks. Works best when:
This is the most sophisticated and widely adopted modern approach. Webpack 5 introduced Module Federation — it lets you configure webpack to expose and consume modules across separate builds at runtime.
This deserves its own deep dive section below.
The composition happens on the server before sending HTML to the browser.
<!-- Main page template served by CDN/edge -->
<html>
<body>
<esi:include src="https://team-a.com/header" />
<esi:include src="https://team-b.com/product-listing?category=shoes" />
<esi:include src="https://team-c.com/recommendations" />
</body>
</html>The CDN/edge server (Akamai, Fastly, Cloudflare Workers) fetches each fragment, assembles the page, and sends complete HTML to the browser.
Browser → CDN/Edge Server
│
├── fetch team-a.com/header → HTML fragment
├── fetch team-b.com/listing → HTML fragment
└── fetch team-c.com/recommend → HTML fragment
│
▼
Assemble complete HTML
│
▼
Send to browser (fully rendered)// Express server acts as composition layer
app.get('/shop', async (req, res) => {
const [header, listing, recommendations] = await Promise.all([
fetch('http://team-a-service/header'),
fetch('http://team-b-service/product-listing'),
fetch('http://team-c-service/recommendations'),
]);
res.send(`
<html>
<head>
<link rel="stylesheet" href="/shared-styles.css" />
</head>
<body>
${await header.text()}
${await listing.text()}
${await recommendations.text()}
<script src="/hydration.js"></script>
</body>
</html>
`);
});// Using React's streaming render + Suspense
app.get('/shop', async (req, res) => {
const { pipe } = renderToPipeableStream(
<ShopPage />,
{
onShellReady() {
res.statusCode = 200;
pipe(res); // Start streaming immediately
}
}
);
});
// Each section is a Suspense boundary that resolves independently
function ShopPage() {
return (
<div>
<Suspense fallback={<HeaderSkeleton />}>
<Header /> {/* Resolves fast */}
</Suspense>
<Suspense fallback={<ListingSkeleton />}>
<ProductListing /> {/* May take longer */}
</Suspense>
<Suspense fallback={<RecSkeleton />}>
<Recommendations /> {/* Can be slow */}
</Suspense>
</div>
);
}This streams HTML as each section becomes ready. The browser starts rendering immediately.
Content-heavy sites where SEO and initial load performance are critical. E-commerce product pages are the classic use case. Zalando, Amazon, and most large e-commerce sites use server-side composition for their product pages.
Module Federation is the most powerful and widely adopted approach for modern micro frontends. It deserves special attention.
Module Federation lets you configure webpack (or Rspack) so that:
// team-b/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
// ... other webpack config
plugins: [
new ModuleFederationPlugin({
name: 'productListing', // unique name for this remote
filename: 'remoteEntry.js', // the generated entry file
exposes: {
// Map of exposed module names to local file paths
'./ProductListing': './src/ProductListing',
'./ProductDetail': './src/ProductDetail',
'./useProduct': './src/hooks/useProduct',
},
shared: {
// Dependencies that can be shared with the host
react: {
singleton: true, // only ONE copy in the page
requiredVersion: '^18.0.0', // version constraint
eager: false, // lazy load (remote)
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
eager: false,
},
'@org/design-system': {
singleton: true,
requiredVersion: '^2.0.0',
},
},
}),
],
};// shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
// Map of remote names to their entry points
productListing: 'productListing@https://cdn.team-b.com/remoteEntry.js',
recommendations: 'recommendations@https://cdn.team-c.com/remoteEntry.js',
cart: 'cart@https://cdn.team-d.com/remoteEntry.js',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0',
eager: true, // HOST loads React eagerly (provides it)
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
eager: true,
},
},
}),
],
};// shell/src/App.js
import React, { lazy, Suspense } from 'react';
// Import remote modules — looks like a normal import!
const ProductListing = lazy(() => import('productListing/ProductListing'));
const ProductDetail = lazy(() => import('productListing/ProductDetail'));
const Recommendations = lazy(() => import('recommendations/Recommendations'));
const ShoppingCart = lazy(() => import('cart/ShoppingCart'));
function App() {
return (
<div>
<Header />
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/shop" element={<ProductListing />} />
<Route path="/shop/:id" element={<ProductDetail />} />
<Route path="/cart" element={<ShoppingCart />} />
</Routes>
<Recommendations />
</Suspense>
</div>
);
}This is the most important part to understand deeply.
Step 1: Shell loads in browser
│
▼
Step 2: Shell's webpack runtime fetches remote entry files:
- GET https://cdn.team-b.com/remoteEntry.js
- GET https://cdn.team-c.com/remoteEntry.js
│
▼
Step 3: Each remoteEntry.js tells the shell:
"I am 'productListing'. I expose:
- ./ProductListing (chunk: 123.js, 45KB)
- ./ProductDetail (chunk: 456.js, 32KB)
I need: react (^18), react-dom (^18)"
│
▼
Step 4: Version negotiation happens:
Shell has: react 18.2.0
Remote wants: react ^18.0.0
→ Compatible! Use shell's react (singleton)
│
▼
Step 5: User navigates to /shop
Shell calls: import('productListing/ProductListing')
│
▼
Step 6: Webpack runtime fetches chunk 123.js from cdn.team-b.com
│
▼
Step 7: Component renders normally — as if it was a local importshared Configuration — Critical DetailThe shared config is where most bugs happen. Let's break it down:
shared: {
react: {
singleton: true, // CRITICAL: React breaks with multiple instances
requiredVersion: '^18', // Semver range for compatibility check
eager: true, // true on host, false on remote
},
}singleton: trueEnsures only one copy of the module exists in the page. If host has React 18.2 and remote wants React 18.x, they share the host's copy. If singleton: false, each app might load its own copy → two React instances → hooks break.
requiredVersion: '^18'Semver range. If the version available doesn't satisfy this range, webpack loads a separate copy (fallback). This is the safety net.
eager: true vs eager: falseHost (shell):
eager: true → React is included in the host's initial bundle
The host "provides" React to all remotes
Remote:
eager: false → React is NOT in the remote's initial bundle
The remote tries to use the host's React first
If host doesn't have it, falls back to its ownScenario 1: Compatible versions
Host: React 18.2.0
Remote: requiredVersion: '^18.0.0'
→ 18.2.0 satisfies ^18.0.0 → SHARE (one instance)
Scenario 2: Incompatible versions
Host: React 18.2.0
Remote: requiredVersion: '^17.0.0'
→ 18.2.0 does NOT satisfy ^17.0.0 → LOAD SEPARATE (two instances!)
→ This will likely break React
Scenario 3: No version specified
Host: React 18.2.0
Remote: (no requiredVersion)
→ Always share, regardless of version (dangerous!)Module Federation isn't just host→remote. Remotes can also consume from the host:
// Host exposes a shared component
new ModuleFederationPlugin({
name: 'shell',
exposes: {
'./Header': './src/components/Header',
'./useAuth': './src/hooks/useAuth',
'./Button': './src/components/Button',
},
// ...
});
// Remote consumes host's component
// In Team B's code:
import Header from 'shell/Header';
import { useAuth } from 'shell/useAuth';
function ProductListing() {
const user = useAuth();
return (
<div>
<Header />
<Listing user={user} />
</div>
);
}Instead of hardcoding remote URLs at build time, you can load them dynamically:
// Load remote URL from config API at runtime
async function loadRemote(remoteName, remoteUrl) {
// Initialize the remote container
await __federation_method_loadRemote(`${remoteName}@${remoteUrl}`);
// Now you can import from it
return import(`${remoteName}/Component`);
}
// Usage
const Component = await loadRemote(
'productListing',
getConfig().teamBUrl // fetched from config API at runtime
);This lets you change remote URLs without rebuilding the shell — useful for:
// React.lazy + Suspense handles loading errors
const ProductListing = lazy(() =>
import('productListing/ProductListing')
.catch(() => {
// Remote failed to load — show fallback
return { default: () => <ErrorFallback message="Product listing unavailable" /> };
})
);
// Or use an Error Boundary for runtime errors
class MFEErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <div>This section is temporarily unavailable.</div>;
}
return this.props.children;
}
}
// Wrap each micro frontend
<MFEErrorBoundary>
<Suspense fallback={<Skeleton />}>
<ProductListing />
</Suspense>
</MFEErrorBoundary>Webpack's Module Federation has evolved. The enhanced version adds:
@originjs/vite-plugin-federation — works but not as battle-testedThis is one of the hardest parts. Independent apps need to talk to each other. How?
// Team B's product listing emits an event
window.dispatchEvent(new CustomEvent('product:add-to-cart', {
detail: { productId: '123', quantity: 1 }
}));
// Team D's cart listens
window.addEventListener('product:add-to-cart', (e) => {
const { productId, quantity } = e.detail;
addToCart(productId, quantity);
});Pros: Simple, no shared code, browser native, zero dependencies Cons: Hard to debug (events are invisible in component tree), no type safety, event names become implicit contracts, memory leaks if you forget to unsubscribe
// @org/event-bus (shared library)
type EventMap = {
'product:add-to-cart': { productId: string; quantity: number };
'cart:updated': { items: CartItem[]; total: number };
'user:logged-in': { userId: string; name: string };
'navigation:route-change': { path: string; params: Record<string, string> };
};
class TypedEventBus {
emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {
window.dispatchEvent(new CustomEvent(event, { detail: data }));
}
on<K extends keyof EventMap>(
event: K,
handler: (data: EventMap[K]) => void
): () => void {
const listener = (e: CustomEvent) => handler(e.detail);
window.addEventListener(event, listener as EventListener);
return () => window.removeEventListener(event, listener as EventListener);
}
}
export const eventBus = new TypedEventBus();
// Usage (fully typed!)
eventBus.emit('product:add-to-cart', { productId: '123', quantity: 1 });
eventBus.on('product:add-to-cart', (data) => {
// data is typed as { productId: string; quantity: number }
});All micro frontends read/write to a common store.
// @org/shared-state (shared library)
import { create } from 'zustand';
export const useCartStore = create((set) => ({
items: [],
addItem: (item) => set(state => ({ items: [...state.items, item] })),
removeItem: (id) => set(state => ({ items: state.items.filter(i => i.id !== id) })),
total: () => useCartStore.getState().items.reduce((sum, i) => sum + i.price, 0),
}));
// Team B uses it
import { useCartStore } from '@org/shared-state';
function AddToButton({ product }) {
const addItem = useCartStore(s => s.addItem);
return <button onClick={() => addItem(product)}>Add to Cart</button>;
}
// Team D uses it
function CartBadge() {
const items = useCartStore(s => s.items);
return <span>{items.length}</span>;
}| Method | How | Trade-off |
|---|---|---|
| NPM package | Each MFE installs @org/shared-state | Build-time coupling, version management |
| Module Federation shared | Host provides the store instance | Runtime, requires MF config |
| Shell provides via mount props | Shell passes store instance to mount() | Explicit but verbose |
| Global singleton | Store on window.__STORE__ | Quick and dirty, no type safety |
If each MFE bundles its own copy of Zustand/Redux, they each have their own store instance. Updates in one aren't visible in another. You MUST ensure there's exactly one store instance.
// Module Federation approach — host provides the store
// shell/webpack.config.js
shared: {
'@org/shared-state': {
singleton: true,
eager: true,
},
}
// Now all remotes use the same store instance as the hostApps communicate through the URL. This is underrated and often the simplest solution.
/shop/shoes?cart=product_123:1,product_456:2
/products?sort=price&filter=brand:nike&color=redPros: Shareable state, works with browser back/forward, SEO-friendly, zero coupling Cons: Limited data capacity (URL length ~2000 chars), serialization overhead, not great for large/complex state
The shell passes callbacks to micro frontends:
// Shell
<ProductListing
onAddToCart={(product) => cartWidget.addItem(product)}
onProductSelect={(id) => navigate(`/product/${id}`)}
onFilterChange={(filters) => updateFilters(filters)}
/>Pros: Explicit, type-safe, easy to reason about Cons: Only works for direct parent-child relationships, shell must know about all events, doesn't scale for cross-cutting concerns
For communication between tabs/windows of the same app:
const channel = new BroadcastChannel('micro-frontend-channel');
// Team B's MFE in Tab 1
channel.postMessage({ type: 'cart:updated', items: [...] });
// Team D's MFE in Tab 2
channel.onmessage = (event) => {
if (event.data.type === 'cart:updated') {
syncCart(event.data.items);
}
};| Pattern | Best For | Scale |
|---|---|---|
| Custom Events / Event Bus | Decoupled communication, any-to-any | Small to large |
| Shared State Store | Complex shared state (cart, auth, user) | Medium to large |
| URL | Filter/search state, bookmarkable state | Any |
| Callback Props | Simple parent-child communication | Small |
| BroadcastChannel | Cross-tab sync | Specialized |
Real-world apps use a combination. URL for navigation/filter state, shared store for cart/auth, event bus for cross-cutting events.
Routing in micro frontends is tricky because you have multiple apps that each might want to control the URL.
// Shell — single source of truth for routing
const routes = [
{ path: '/', element: <HomePage /> },
{ path: '/shop', element: <ProductListing /> }, // Team B
{ path: '/shop/:id', element: <ProductDetail /> }, // Team B
{ path: '/cart', element: <ShoppingCart /> }, // Team D
{ path: '/account', element: <AccountPage /> }, // Team E
{ path: '/account/orders', element: <OrderHistory /> },// Team E
];Each micro frontend doesn't know about routing — the shell decides what to render where.
Pros: Simple, centralized control, easy to reason about Cons: Shell becomes a bottleneck, must be updated for every new route, teams can't add routes autonomously
// Shell delegates entire subtrees
const routes = [
{ path: '/', element: <HomePage /> },
{ path: '/shop/*', element: <ShopMicroFrontend /> }, // Team B handles all /shop/*
{ path: '/cart', element: <ShoppingCart /> }, // Team D
{ path: '/account/*', element: <AccountMicroFrontend /> }, // Team E handles all /account/*
];
// Inside Team B's ShopMicroFrontend:
// They have their own router for sub-routes:
// /shop → listing
// /shop/:id → product detail
// /shop/:id/reviews → reviews
// /shop/compare → comparison pagePros: Teams control their own routes, shell is thinner, true autonomy Cons: URL structure must be pre-agreed, deep linking needs coordination, debugging routing issues is harder
Micro frontends register their routes with the shell at runtime:
// Team B's MFE registers its routes on load
window.__ROUTE_REGISTRY__.register({
basePath: '/shop',
routes: [
{ path: '/', component: 'ProductListing', title: 'Shop' },
{ path: '/:id', component: 'ProductDetail', title: 'Product' },
{ path: '/:id/reviews', component: 'Reviews', title: 'Reviews' },
],
loadComponent: (name) => import(`./pages/${name}`),
});
// Shell reads all registrations and builds router dynamically
function buildRouter() {
const allRoutes = window.__ROUTE_REGISTRY__.getAllRoutes();
return allRoutes.map(route => ({
path: route.path,
element: <Suspense fallback={<Skeleton />}>
<route.component />
</Suspense>,
}));
}Pros: Fully dynamic, no shell redeployment needed for new routes, true plug-and-play Cons: Complex, hard to debug routing issues, no static analysis, route conflicts possible
Each team gets a URL prefix. No coordination needed.
team-a (layout): / → handles header, nav, footer for ALL routes
team-b (shop): /shop/* → owns everything under /shop
team-c (account): /account/* → owns everything under /account
team-d (cart): /cart → owns the cart pageThe shell is thin — it just matches prefixes and delegates:
function App() {
const path = usePathname();
if (path.startsWith('/shop')) return <ShopMFE />;
if (path.startsWith('/account')) return <AccountMFE />;
if (path.startsWith('/cart')) return <CartMFE />;
return <HomePage />;
}Pros: Zero routing conflicts, simple, teams are fully autonomous Cons: URL structure is rigid, can't have routes that span multiple MFes naturally
When using Strategy B or D, only one MFE is "active" at a time. The active MFE's router handles the URL. The inactive ones should not try to read the URL.
// In single-spa, this is handled automatically:
// - Active MFE gets the URL
// - Inactive MFes are unmounted
// In Module Federation, you need to handle it:
function ShopMicroFrontend() {
const path = usePathname();
// Only render if this MFE is active for the current route
if (!path.startsWith('/shop')) return null;
return <ShopRouter />; // Shop's internal router
}One of the biggest practical challenges. If Team A uses Tailwind and Team B uses Bootstrap, styles will collide.
/* Team A's code */
.btn { background: blue; }
/* Team B's code */
.btn { background: red; }
/* Whichever loads last wins → unpredictable styling *//* Team B's ProductCard.module.css */
.card { padding: 16px; border: 1px solid #ccc; }
.title { font-size: 18px; font-weight: bold; }import styles from './ProductCard.module.css';
<div className={styles.card}>
<h2 className={styles.title}>Product Name</h2>
</div>
// Compiles to:
// <div class="ProductCard_card__x7f2a">
// <h2 class="ProductCard_title__a3b9c">Product Name</h2>
// </div>Class names are hashed to unique strings. No collisions possible.
Pros: Simple, zero runtime overhead, great TypeScript support Cons: No style sharing between MFes (each has its own hashed classes)
// Team B uses styled-components
const Card = styled.div`
padding: 16px;
border: 1px solid #ccc;
.title {
font-size: 18px;
}
`;
// Generates: <div class="sc-xyz123"> with scoped CSSPros: Dynamic styles, theming, no class name management Cons: Runtime overhead, larger bundle, SSR complexity, multiple CSS-in-JS libraries = multiple runtime instances
Web Components with Shadow DOM isolate CSS completely:
this.attachShadow({ mode: 'open' });
// CSS inside shadow DOM doesn't leak out or get affected by outside CSSTrade-offs already covered in the Web Components section.
/* Team B always prefixes with their team namespace */
.team-b-product-card { ... }
.team-b-product-card__title { ... }
.team-b-product-card--featured { ... }
/* Team C uses their own namespace */
.team-c-recommendation-card { ... }
.team-c-recommendation-card__title { ... }Pros: Simple, no build tool needed, works everywhere Cons: Relies on discipline, no enforcement, verbose
Instead of fighting CSS, agree on a design system:
/* @org/design-tokens (shared package) */
:root {
/* Colors */
--color-primary: #0066ff;
--color-primary-hover: #0052cc;
--color-secondary: #6c757d;
--color-success: #28a745;
--color-danger: #dc3545;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* Typography */
--font-body: 'Inter', sans-serif;
--font-heading: 'Cal Sans', sans-serif;
--font-size-sm: 14px;
--font-size-md: 16px;
--font-size-lg: 20px;
--font-size-xl: 24px;
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
}// @org/ui-components (shared component library)
export function Button({ variant = 'primary', children, ...props }) {
return <button className={`btn btn-${variant}`} {...props}>{children}</button>;
}
// Every team uses the same Button, Card, Input, etc.
import { Button, Card } from '@org/ui-components';Pros: Visual consistency, no CSS collisions, shared language Cons: Design system must be maintained, changes require coordination, some teams may need customizations beyond what the system offers
Combine CSS Modules (for isolation) + Design Tokens (for consistency):
@org/design-tokens → CSS variables for colors, spacing, typography
@org/ui-components → Base components (Button, Input, Modal) built on tokens
Each MFE:
- Uses CSS Modules for component-specific styles
- Uses design tokens for consistency
- Uses shared UI components for common patterns
- Custom CSS only for unique, team-specific UIThe #1 technical challenge in micro frontends.
Shell: React 18.2.0
Team B: React 18.3.0
Team C: React 17.0.2
Team D: React 18.2.0| Strategy | Behavior |
|---|---|
| Build-time (npm) | npm/yarn dedupes to one version (usually highest). Team C's React 17 code might break. |
| Module Federation | Shared config negotiates. Team C's requiredVersion: '^17' won't match 18.2 → loads its own copy. |
| Script tag / externals | Each app bundles its own. Multiple instances guaranteed. |
| Iframe | Complete isolation. No conflict possible. |
React, Vue, and most frameworks break with multiple instances.
If two copies of React exist:
- Hooks (useState, useEffect) throw errors
- Context doesn't propagate between instances
- State gets lost
- Event handlers break// Organization-level policy
"All micro frontends must use React ^18.2.0"
// Enforced via:
// 1. CI checks that scan package.json across all MFE repos
// 2. Shared package.json template / scaffold
// 3. Dependency version bot (Dependabot/Renovate) keeps all repos in syncshared: {
react: {
singleton: true, // FORCE one copy
requiredVersion: '^18.2.0',
eager: true, // Host provides it
},
}
// If a remote needs React 17, it's forced to use the host's React 18.2
// This may break that remote — but at least there's only one instance<!-- Shell loads React once globally -->
<script src="https://cdn.reactjs.org/react@18.2.0/umd/react.production.min.js"></script>
<script src="https://cdn.reactjs.org/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<!-- All MFes use window.React (no bundling) -->
// In each MFE's webpack config:
externals: { react: 'React', 'react-dom': 'ReactDOM' }Each MFE has its own document → its own React instance. No conflict. But you lose all benefits of sharing.
| Dependency | Singleton Required? | Why? |
|---|---|---|
| React | YES | Hooks and Context break with multiple instances |
| React DOM | YES | Tied to React version |
| React Router | YES | Multiple routers fight over URL |
| Redux | Depends | Multiple stores = separate state (might be intentional) |
| Zustand | Depends | Same as Redux |
| styled-components | No | Each instance generates its own classes (but duplicates CSS) |
| Tailwind | No | CSS-only, no runtime |
| Lodash | No | Pure functions, stateless |
| date-fns | No | Pure functions, stateless |
| Axios | No | Each instance works independently |
The original micro frontend framework. Framework-agnostic router that orchestrates mounting/unmounting.
import { registerApplication, start } from 'single-spa';
// Register a micro frontend
registerApplication({
name: 'product-listing',
// Function that loads the MFE (returns a module with mount/unmount)
app: () => System.import('//cdn.team-b.com/product-listing.js'),
// When should this MFE be active?
activeWhen: ['/shop', '/products'],
// Props to pass to the MFE
customProps: { authToken: getToken() },
});
registerApplication({
name: 'shopping-cart',
app: () => System.import('//cdn.team-d.com/cart.js'),
activeWhen: '/cart',
});
start(); // Begin routingEach MFE must export lifecycle functions:
// Team B's product-listing.js
export async function mount(props) {
ReactDOM.render(<Listing {...props} />, document.getElementById('container'));
}
export async function unmount(props) {
ReactDOM.unmountComponentAtNode(document.getElementById('container'));
}
export async function bootstrap(props) {
// One-time initialization (called once before first mount)
}
export async function update(props) {
// Called when props change (optional)
}Pros: Mature, framework-agnostic, handles routing and lifecycle, large ecosystem Cons: Doesn't handle shared dependencies, no built-in module sharing, requires SystemJS for script loading, older architecture
Built on top of single-spa. Adds a feed service for discovering micro frontends at runtime.
┌───────────────────────────────────────┐
│ Piral Shell (App Shell) │
│ ├── Piral Instance │
│ ├── Feed Service (discovery) │ ← discovers available MFEs at runtime
│ ├── Extension System │ ← MFes can extend shell "slots"
│ ├── Auth / Notifications / State │
│ └── Layout / Routing │
└───────────────────────────────────────┘How discovery works:
Pros: Full platform with auth, notifications, extensions, tile-based dashboard Cons: Opinionated, heavy, steep learning curve, Piral-specific concepts
Micro frontend framework with web component support and a built-in development console.
// Luigi config
Luigi.setConfig({
navigation: {
nodes: () => [
{
pathSegment: 'shop',
label: 'Shop',
viewUrl: 'https://team-b.com/shop.html',
},
],
},
});Pros: Good for enterprise, iframe-based isolation, SAP ecosystem integration Cons: iframe-based (inherits iframe limitations), SAP-centric
Server-side rendering focused micro frontend framework. Each component is a small Node.js service that renders HTML.
Component-driven development platform. Each component is independently versioned and shared.
# Team B creates and exports a component
bit create react product-listing
bit tag --message "updated filters"
bit export team-b.componentsPros: Granular (component-level, not app-level), great DX Cons: More suited for component libraries than full micro frontends
| Feature | single-spa | Piral | Luigi | Module Federation |
|---|---|---|---|---|
| Runtime routing | Yes | Yes | Yes | No (host handles) |
| Shared deps | No | Yes | No | Yes (built-in) |
| Framework agnostic | Yes | Partial | Yes | No (bundler-specific) |
| Discovery | Static config | Feed service | Config file | Static config |
| SSR support | Limited | Yes | Iframe only | With setup |
| Complexity | Medium | High | Medium | Medium-High |
| Maturity | High | Medium | Medium | High |
| Learning curve | Medium | Steep | Low-Medium | Steep |
| Best for | Any stack | Full platform | Enterprise/SAP | React/Vue shops |
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Team A │ │ Team B │ │ Team C │
│ (Shell) │ │ (Products) │ │ (Cart) │
│ CI/CD │ │ CI/CD │ │ CI/CD │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ CDN: │ │ CDN: │ │ CDN: │
│ shell/ │ │ remoteEntry │ │ remoteEntry │
│ index.html │ │ .js + chunks│ │ .js + chunks│
│ assets/ │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
│
▼
User's browserremoteEntry.v3.js or hash-based (remoteEntry.a1b2c3.js) for cache bustingHow does the shell know which version of each remote to load?
Option A: Hardcoded URLs in shell build
→ Shell must rebuild when remote URL changes (defeats independent deployment)
Option B: Config API (recommended)
→ Shell fetches config at runtime:
GET /api/mfe-config
→ { "productListing": "https://cdn.team-b.com/v3/remoteEntry.js" }
→ Shell loads remotes dynamically
Option C: Well-known URLs
→ Each remote always lives at the same URL
→ CDN cache headers handle versioning
→ remoteEntry.js always returns the latest versionScenario: Team B deploys v4 of product listing, it has a bug.
With independent deployment:
1. Team B rolls back to v3 on their CDN
2. Shell doesn't need to change
3. Users get v3 automatically (CDN serves the rolled-back version)
4. No other team is affected
This is the power of independent deployment.remoteEntry.js → Cache-Control: no-cache (always check for new version)
chunk.abc123.js → Cache-Control: max-age=31536000, immutable (hash = never changes)
The entry file is the "manifest" — it's always fresh.
The chunks are immutable — once deployed, they never change.Each team has their own CI/CD pipeline:
# Team B's .github/workflows/deploy.yml
name: Deploy Product Listing MFE
on:
push:
branches: [main]
jobs:
build-and-deploy:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run lint
- run: npm run test
- run: npm run typecheck
- run: npm run build
# Deploy to CDN
- run: aws s3 sync dist/ s3://team-b-cdn/v${{ github.sha }}/
- run: aws s3 cp s3://team-b-cdn/v${{ github.sha }}/remoteEntry.js s3://team-b-cdn/latest/remoteEntry.js
# Update version registry
- run: |
curl -X POST https://config-api.internal/mfe/product-listing \
-d '{"version": "${{ github.sha }}", "url": "https://cdn.team-b.com/v${{ github.sha }}/remoteEntry.js"}'Organization-level checks that run on ALL MFE repos:
# .github/workflows/shared-checks.yml (reusable workflow)
# Triggered on every MFE repo
- Check: React version matches org policy
- Check: No prohibited dependencies
- Check: Bundle size within budget
- Check: Linting passes (shared ESLint config)
- Check: TypeScript strict mode enabled
- Check: Accessibility audit passesWhen org updates React from 18.2 → 18.3:
1. Update @org/eslint-config to require React ^18.3
2. Renovate/Dependabot opens PRs on ALL MFE repos
3. Each team reviews and merges in their own timeline
4. Once all MFes are on 18.3, update the shared Module Federation config
5. Old MFes on 18.2 will still work (version negotiation loads separate copy)
but it's suboptimal → creates urgency to update┌─────────────────────────────────────────┐
│ E2E Tests (across all MFes) │ ← Test complete user journeys
│ Cypress / Playwright │
├─────────────────────────────────────────┤
│ Integration Tests (MFE + Shell) │ ← Test MFE works in shell context
│ Testing Library + MF harness │
├─────────────────────────────────────────┤
│ Unit Tests (per MFE) │ ← Test MFE logic in isolation
│ Jest / Vitest │
└─────────────────────────────────────────┘Each team tests their MFE in isolation:
// Team B tests ProductListing independently
import { render, screen } from '@testing-library/react';
import ProductListing from './ProductListing';
test('shows products for given category', async () => {
render(<ProductListing categoryId="shoes" />);
expect(await screen.findByText('Nike Air Max')).toBeInTheDocument();
expect(screen.getByText('Adidas Ultra Boost')).toBeInTheDocument();
});Test that the MFE works correctly when loaded by the shell:
// Test harness that simulates the shell
import { mount } from './lifecycle';
test('ProductListing mounts correctly with shell props', async () => {
const container = document.createElement('div');
await mount({
container,
props: {
categoryId: 'shoes',
authToken: 'test-token',
onAddToCart: jest.fn(),
},
});
expect(container.querySelector('.product-listing')).toBeTruthy();
});Test complete user journeys that span multiple micro frontends:
// Playwright E2E test
test('user can add product to cart', async ({ page }) => {
await page.goto('/shop/shoes');
// This exercises Team B's ProductListing MFE
await page.click('text=Nike Air Max');
// This exercises Team B's ProductDetail MFE
await page.click('text=Add to Cart');
// This exercises Team D's Cart MFE
await page.goto('/cart');
await expect(page.locator('.cart-item')).toContainText('Nike Air Max');
await expect(page.locator('.cart-total')).toContainText('$129.99');
});Test that MFes honor their contracts (mount/unmount API, event bus events, shared state):
// Contract test for the event bus
test('product:add-to-cart event has correct shape', () => {
const handler = jest.fn();
eventBus.on('product:add-to-cart', handler);
eventBus.emit('product:add-to-cart', { productId: '123', quantity: 1 });
expect(handler).toHaveBeenCalledWith({
productId: expect.any(String),
quantity: expect.any(Number),
});
});| Signal | Description |
|---|---|
| 10+ frontend developers | Multiple teams stepping on each other's code |
| Independent team deployment | Team A can't wait for Team B's release cycle |
| Distinct business domains | Shop, Account, Checkout are clearly separable |
| Long-lived product | You'll maintain this for years, worth the investment |
| Incremental migration needed | Moving from monolith to microservices gradually |
| Different tech needs per domain | Checkout needs high security, Shop needs rich interactivity |
| Signal | Description |
|---|---|
| < 5 frontend developers | Communication is easy, monolith is fine |
| Single product, single team | No organizational scaling problem |
| Tight coupling between features | If everything depends on everything, separation is artificial |
| Short-lived project | Not worth the infrastructure investment |
| Team doesn't have DevOps maturity | Micro frontends need CI/CD, CDN, monitoring |
| "We want to use different frameworks" | Usually not a good enough reason alone |
| Benefit | Cost |
|---|---|
| Independent deployment | More infrastructure (CDNs, config APIs, monitoring) |
| Team autonomy | Coordination overhead (contracts, shared libs, design system) |
| Tech stack freedom | Shared dependency hell, inconsistent UX |
| Fault isolation | More complex error handling, more failure modes |
| Smaller per-team bundles | More HTTP requests, more complex loading |
| Incremental upgrades | Version management across N repos |
Most teams get 80% of the benefit from build-time integration (npm packages) with clear team ownership. Only go to runtime integration (Module Federation) when you truly need independent deployment.
| Criteria | Iframe | NPM Packages | Script Tag | Web Components | Module Federation | Server-Side |
|---|---|---|---|---|---|---|
| Independent Deploy | Yes | No | Yes | Yes | Yes | Yes |
| JS Isolation | Perfect | None | None | Shadow DOM | Shared scope | N/A |
| CSS Isolation | Perfect | None | None | Shadow DOM | Manual | Manual |
| Shared Deps | No | Yes (deduped) | Manual | No | Automatic | N/A |
| TypeScript | No | Full | No | Limited | Good | N/A |
| SSR | No | Yes | No | Partial | Complex | Yes (native) |
| SEO | Bad | Good | Good | Good | Good | Best |
| Performance | Worst | Best (single bundle) | Good | Good | Good | Best (SSR) |
| DX | Bad | Best | Medium | Medium-Hard | Medium-Hard | Medium |
| Complexity | Low | Low | Medium | Medium | High | High |
| Framework Lock | None | Same framework | Same framework | None | Same framework | Any |
| Maturity | Ancient | Standard | Standard | Standard | Modern | Standard |
| Best For | 3rd party embeds | Small orgs | Medium orgs | Mixed stacks | Large orgs | Content sites |
Micro frontends solve organizational problems, not technical ones. If your team is small, use a monolith.
Start with build-time integration (npm packages). Only move to runtime integration when you truly need independent deployment.
Module Federation is the modern standard for runtime micro frontends in React/Vue ecosystems. Understand the shared config deeply.
Communication is the hardest part. Use a typed event bus for decoupled communication and a shared store for complex shared state.
CSS isolation matters. CSS Modules + Design Tokens is the pragmatic sweet spot.
Shared dependencies must be managed carefully. Singleton frameworks (React, Vue) MUST have only one instance.
Deployment independence is the whole point. If you can't deploy independently, you don't have micro frontends — you have a distributed monolith.
Don't mix frameworks unless you have a very good reason. The "framework freedom" promise sounds great but creates enormous operational overhead.
Server-side composition is best for SEO/performance. Client-side composition is best for interactivity. Many production apps use both.
The shell should be thin. If the shell grows large, you've just rebuilt the monolith with extra steps.
"How many frontend teams do you have?"
├── 1-3 → Monolith with clear module boundaries
├── 4-8 → Build-time micro frontends (npm packages)
└── 8+ → Runtime micro frontends (Module Federation)
"Do you need independent deployment?"
├── No → Build-time integration is enough
└── Yes → Module Federation or Script Tag approach
"Is SEO critical?"
├── Yes → Server-side composition + client hydration
└── No → Client-side composition is fine
"Do teams use different frameworks?"
├── Yes → Web Components or Iframe
└── No → Module Federation (simpler, better DX)