What Are Micro Frontends?
Micro frontends extend the concept of microservices to the frontend world. Instead of building a single, monolithic frontend application, you break it into smaller, independently developed, tested, and deployed pieces that compose together in the browser.
Each team owns a vertical slice of the product — from the database to the UI — and ships features without coordinating deployments with other teams.
Why Micro Frontends?
Working at INS-ENCO, I experienced the pain of a growing monolithic React application firsthand. As the team scaled, we faced:
- ▹Merge conflicts across teams touching the same codebase
- ▹Slow CI/CD pipelines — a small change triggered a full rebuild
- ▹Tightly coupled features that made refactoring risky
- ▹Technology lock-in — every part of the app had to use the same framework version
Micro frontends solve these problems by giving each team autonomy over their slice of the UI.
Integration Approaches
1. Build-Time Integration (NPM Packages)
Each micro frontend is published as an npm package and composed at build time.
{
"dependencies": {
"@team-a/header": "^1.2.0",
"@team-b/dashboard": "^3.0.1",
"@team-c/settings": "^2.1.0"
}
}Pros: Simple, type-safe imports, tree-shakeable.
Cons: Requires redeployment of the shell for every update. Tight coupling at build time.
2. Runtime Integration via Module Federation
Webpack 5's Module Federation is the game changer. It allows applications to load code from other builds at runtime — no rebuild of the host required.
// webpack.config.js — Remote App
new ModuleFederationPlugin({
name: "dashboard",
filename: "remoteEntry.js",
exposes: {
"./DashboardApp": "./src/bootstrap",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
});// webpack.config.js — Host/Shell App
new ModuleFederationPlugin({
name: "shell",
remotes: {
dashboard: "dashboard@https://dashboard.example.com/remoteEntry.js",
},
shared: {
react: { singleton: true },
"react-dom": { singleton: true },
},
});// In the shell app
const DashboardApp = React.lazy(
() => import("dashboard/DashboardApp")
);
function App() {
return (
<Suspense fallback={<Loading />}>
<DashboardApp />
</Suspense>
);
}This is the approach I recommend for most teams. It provides true runtime independence while sharing common dependencies like React to avoid bundle bloat.
3. single-spa Orchestration
single-spa is a framework-agnostic orchestrator that mounts and unmounts micro frontends based on routes.
import { registerApplication, start } from "single-spa";
registerApplication({
name: "@team/navbar",
app: () => System.import("@team/navbar"),
activeWhen: ["/"],
});
registerApplication({
name: "@team/dashboard",
app: () => System.import("@team/dashboard"),
activeWhen: ["/dashboard"],
});
start();Pros: Framework-agnostic — React, Vue, Angular can coexist.
Cons: More complex setup, requires an import map or SystemJS.
4. iframe-Based Isolation
The classic approach. Each micro frontend runs inside an iframe with full CSS and JS isolation.
<iframe src="https://dashboard.example.com" />Pros: Complete isolation.
Cons: Poor UX (no shared routing, limited communication, performance overhead).
Communication Between Micro Frontends
Micro frontends need to communicate without creating tight coupling:
Custom Events
// Publishing — from Dashboard micro frontend
window.dispatchEvent(
new CustomEvent("user:selected", {
detail: { userId: "123", name: "Hoai" },
})
);
// Subscribing — in Settings micro frontend
window.addEventListener("user:selected", (event: CustomEvent) => {
console.log("Selected user:", event.detail);
});Shared State via URL
The URL is a natural shared state mechanism. Each micro frontend reads query parameters or path segments relevant to it.
Event Bus (Pub/Sub)
class EventBus {
private listeners: Map<string, Set<Function>> = new Map();
emit(event: string, data: unknown) {
this.listeners.get(event)?.forEach((cb) => cb(data));
}
on(event: string, callback: Function) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
return () => this.listeners.get(event)?.delete(callback);
}
}
// Shared instance on window
window.__EVENT_BUS__ = window.__EVENT_BUS__ || new EventBus();Shared Dependencies & Versioning
The biggest challenge with micro frontends is managing shared dependencies. Loading React twice would be a disaster.
Module Federation's shared config handles this elegantly:
shared: {
react: {
singleton: true, // Only one instance in the page
requiredVersion: "^18.0.0",
eager: false, // Lazy load
},
"react-dom": {
singleton: true,
requiredVersion: "^18.0.0",
},
}The runtime negotiation ensures that the highest compatible version is loaded, and all micro frontends share it.
CSS Isolation Strategies
Without isolation, styles from one micro frontend can bleed into another.
| Strategy | Isolation Level | Trade-off |
|---|---|---|
| CSS Modules | Good | Requires build tooling |
| Shadow DOM | Complete | Limited React support |
| BEM / Prefixing | Basic | Manual discipline needed |
| CSS-in-JS | Good | Runtime cost |
| Tailwind with prefix | Good | Config per micro frontend |
My recommendation: CSS Modules or Tailwind with a team-specific prefix provide the best balance of isolation and developer experience.
Performance Considerations
Micro frontends can hurt performance if not handled carefully:
- Shared dependencies — Use Module Federation's singleton sharing to avoid duplicate React/ReactDOM loads
- Lazy loading — Load micro frontends only when their route is active
- Prefetching — Preload critical micro frontends during idle time
- Bundle analysis — Monitor each micro frontend's bundle size independently
- CDN caching — Each micro frontend gets its own cache key, so deploying one doesn't invalidate the others
When NOT to Use Micro Frontends
Micro frontends add complexity. Don't use them when:
- ▹Your team is small (< 5 frontend devs)
- ▹The application is simple with clear bounded context
- ▹You don't have independent deployment infrastructure
- ▹Teams don't need technology autonomy
A well-structured monolith with clear module boundaries is often the better choice for smaller teams.
Real-World Architecture
Here's a production architecture I've worked with:
┌─────────────────────────────────────────┐
│ CDN / Edge │
├─────────────────────────────────────────┤
│ Shell Application │
│ ┌─────────┬──────────┬───────────┐ │
│ │ Header │ Router │ Footer │ │
│ │ (React) │ │ (React) │ │
│ └─────────┴──────────┴───────────┘ │
│ │ │
│ ┌─────────┼──────────┐ │
│ ▼ ▼ ▼ │
│ Dashboard Trading Settings │
│ (React) (React) (Vue) │
│ Team A Team B Team C │
└─────────────────────────────────────────┘Each team deploys independently. The shell loads remote entries at runtime. Shared auth state flows through custom events.
Conclusion
Micro frontends are a powerful pattern for scaling frontend development across multiple teams. The key is choosing the right integration approach for your context:
- ▹Module Federation for React-heavy teams wanting runtime independence
- ▹single-spa for multi-framework environments
- ▹NPM packages for simpler, build-time composition
Start simple. A monolith with clear boundaries is always the right first step. Migrate to micro frontends when team scaling demands it — not before.