Micro-Frontends: Architecture Patterns, Trade-offs, and Implementation Strategies

As frontend applications grow and teams scale, a common pain point emerges: the monolithic frontend becomes a bottleneck. Every team touches the same codebase, deployments are coupled, and a bug in one feature can block the entire release pipeline.
Micro-frontends extend the principles of microservices to the frontend. Instead of one large application, you compose multiple smaller, independently deployable frontends into a cohesive user experience.
In this post, we'll cover:
- What micro-frontends are and when they make sense
- Composition patterns: build-time vs runtime
- Module Federation deep dive
- Cross-micro-frontend communication
- Routing and navigation strategies
- Shared dependencies and versioning
- Deployment and CI/CD considerations
- Trade-offs and when NOT to use micro-frontends
- Repository strategies: monorepo vs polyrepo
1. What Are Micro-Frontends?
Micro-frontends are an architectural style where a frontend application is decomposed into smaller, semi-independent "micro" applications that work together.
Key Characteristics
| Characteristic | Description |
|---|---|
| Independent Deployment | Each micro-frontend can be deployed without coordinating with others |
| Team Autonomy | Different teams own different micro-frontends end-to-end |
| Technology Agnostic | Teams can use different frameworks (though this adds complexity) |
| Isolated Failures | A bug in one micro-frontend shouldn't crash the entire application |
| Vertical Slicing | Teams own a business domain, not a horizontal layer |
When Do Micro-Frontends Make Sense?
Micro-frontends solve organizational problems, not technical ones. Consider them when:
- You have multiple teams (3+) working on the same product
- Teams need to deploy independently without waiting for others
- Your monolith has become a bottleneck for velocity
- You're migrating from a legacy system incrementally
- Different parts of your app have different scaling needs
Interview Question: When would you recommend micro-frontends over a monolithic frontend?
Answer: Micro-frontends make sense when organizational scale becomes a bottleneck. If you have multiple teams (typically 3+) that need to deploy independently, or you're incrementally migrating a legacy system, micro-frontends provide team autonomy and deployment independence. However, for smaller teams or simpler applications, the added complexity isn't worth it—a well-structured monolith is simpler and faster to develop.
2. Composition Patterns
How do you combine multiple micro-frontends into one application? There are two primary approaches: build-time composition and runtime composition.
2.1 Build-Time Composition
Micro-frontends are combined during the build process, resulting in a single deployable bundle.
How it works:
- Each micro-frontend is published as an npm package
- The shell application imports them as dependencies
- Everything is bundled together at build time
{
"dependencies": {
"@acme/product-catalog": "^2.1.0",
"@acme/shopping-cart": "^1.4.0",
"@acme/user-profile": "^3.0.0"
}
}Pros:
- Simple mental model
- Single deployment artifact
- Optimized bundle (tree-shaking, deduplication)
- Works with existing monorepo patterns
Cons:
- Not independently deployable — the whole app must be rebuilt and redeployed
- Versions are locked at build time
- Doesn't solve the deployment coupling problem
When to use: Early-stage projects, internal tools, or when independent deployment isn't a priority.
2.2 Runtime Composition
Micro-frontends are loaded and composed at runtime in the browser.
How it works:
- Each micro-frontend is deployed independently to its own URL
- The shell loads them dynamically via
<script>tags, iframes, or Module Federation - Each micro-frontend can be updated without touching the shell
Pros:
- True independent deployment
- Teams can release at their own pace
- Enables A/B testing and canary releases per micro-frontend
- Supports incremental migration
Cons:
- More complex infrastructure
- Potential for version mismatches
- Performance overhead (multiple bundles, network requests)
- Shared dependency management is trickier
When to use: Larger organizations, multiple teams, when deployment independence is critical.
3. Runtime Composition Techniques
There are several ways to achieve runtime composition. Let's examine each.
3.1 iframes
The simplest (and oldest) approach: each micro-frontend runs in its own <iframe>.
<div id="shell">
<nav><!-- Shell navigation --></nav>
<main>
<iframe src="https://product-catalog.acme.com" title="Product Catalog"></iframe>
</main>
</div>Pros:
- Complete isolation (CSS, JS, globals)
- Different frameworks per iframe work naturally
- Security sandbox
Cons:
- Communication is clunky (postMessage only)
- Poor accessibility (screen readers struggle)
- Styling inconsistencies (no shared design system)
- Performance overhead
- SEO challenges
- Navigation/deep-linking is complex
When to use: Legacy integration, third-party widgets, or when complete isolation is required.
3.2 Web Components
Each micro-frontend exposes itself as a custom element.
// product-catalog.js
class ProductCatalog extends HTMLElement {
connectedCallback() {
this.innerHTML = `<div>Product Catalog loaded!</div>`;
// Or mount a React/Vue app here
}
}
customElements.define('product-catalog', ProductCatalog);<!-- Shell app -->
<script src="https://cdn.acme.com/product-catalog.js"></script>
<product-catalog data-category="electronics"></product-catalog>Pros:
- Framework-agnostic by design
- Native browser feature
- Good encapsulation (Shadow DOM optional)
- Works well with server-side rendering
Cons:
- Getting React/Vue to play nicely inside custom elements requires wrappers
- Shared state management is still manual
- Bundle size grows with each framework
When to use: When micro-frontends use different frameworks, or when you want strong encapsulation.
3.3 JavaScript Bundles with Lifecycle Hooks
Each micro-frontend is a JS bundle that exports lifecycle methods: bootstrap, mount, unmount.
// product-catalog.js
export function bootstrap() {
// One-time initialization
}
export function mount(container) {
ReactDOM.render(<App />, container);
}
export function unmount(container) {
ReactDOM.unmountComponentAtNode(container);
}The shell loads the bundle, gets these exports, and calls them at the right time.
Pros:
- Fine-grained control over lifecycle
- Each micro-frontend manages its own DOM subtree
- Works with single-spa and similar orchestrators
Cons:
- Manual orchestration (or use a framework like single-spa)
- Shared dependencies need careful handling
3.4 Gateway / Reverse Proxy Routing
A straightforward approach where a gateway or reverse proxy routes entire pages to different micro-frontends based on URL path prefixes. Each micro-frontend owns complete routes and serves full pages independently.
How It Works
- User navigates to
https://acme.com/products/shoes - Gateway matches the
/products/*prefix - Request is proxied to the Product Catalog micro-frontend
- Product Catalog returns a complete HTML page
- Browser renders the page (no client-side composition needed)
Each micro-frontend is a fully independent application with its own:
- Build and deployment pipeline
- Server (Node.js, static hosting, etc.)
- Routing (handles sub-routes internally)
Nginx Configuration
server {
listen 80;
server_name acme.com;
# Product Catalog MFE
location /products/ {
proxy_pass http://product-catalog-mfe:3001/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Shopping Cart MFE
location /cart/ {
proxy_pass http://shopping-cart-mfe:3002/;
proxy_set_header Host $host;
}
# User Account MFE
location /account/ {
proxy_pass http://user-account-mfe:3003/;
proxy_set_header Host $host;
}
# Checkout MFE
location /checkout/ {
proxy_pass http://checkout-mfe:3004/;
proxy_set_header Host $host;
}
# Default: Shell/Home
location / {
proxy_pass http://shell-mfe:3000/;
proxy_set_header Host $host;
}
}Traefik Configuration (Docker/Kubernetes)
# docker-compose.yml with Traefik labels
services:
traefik:
image: traefik:v2.10
command:
- "--providers.docker=true"
ports:
- "80:80"
shell:
image: acme/shell-mfe
labels:
- "traefik.http.routers.shell.rule=PathPrefix(`/`)"
- "traefik.http.routers.shell.priority=1"
product-catalog:
image: acme/product-catalog-mfe
labels:
- "traefik.http.routers.products.rule=PathPrefix(`/products`)"
- "traefik.http.routers.products.priority=10"
shopping-cart:
image: acme/shopping-cart-mfe
labels:
- "traefik.http.routers.cart.rule=PathPrefix(`/cart`)"
- "traefik.http.routers.cart.priority=10"
user-account:
image: acme/user-account-mfe
labels:
- "traefik.http.routers.account.rule=PathPrefix(`/account`)"
- "traefik.http.routers.account.priority=10"Handling Shared UI (Header/Footer)
The challenge: how do multiple independent apps share common navigation?
Option 1: Duplicate in each MFE
Each micro-frontend includes its own header/footer. Simple but risks inconsistency.
Option 2: Shared npm package
Publish @acme/shared-nav as an npm package. Each MFE imports and renders it.
// In each micro-frontend
import { Header, Footer } from '@acme/shared-nav';
function App() {
return (
<>
<Header />
<main>{/* MFE content */}</main>
<Footer />
</>
);
}Handling Client-Side Navigation
When a user clicks a link from /products/shoes to /cart, you have two options:
Hard navigation (full page reload):
<a href="/cart">View Cart</a>Simple, works everywhere, but loses client-side state.
Soft navigation (SPA feel):
Shared navigation component intercepts clicks and uses window.location or a shared router context.
// In shared nav component
function handleNavigation(path: string) {
// Check if destination is same MFE
if (belongsToCurrentMFE(path)) {
router.push(path); // Internal SPA navigation
} else {
window.location.href = path; // Cross-MFE: full reload
}
}Sharing State Across MFEs (The Hard Part)
Since each micro-frontend is a completely separate application with its own JavaScript context, in-memory state cannot be shared. When the user navigates from /products to /cart, the Product Catalog MFE is destroyed and Shopping Cart MFE loads fresh.
State must be shared via external mechanisms:
URL Parameters / Query Strings
Pass transient state through the URL:
// Product page adds to cart and navigates
window.location.href = `/cart?added=product-123&quantity=2`;
// Cart page reads from URL
const params = new URLSearchParams(window.location.search);
const addedProduct = params.get('added');LocalStorage / SessionStorage
Persist state in browser storage:
// Product page
localStorage.setItem('pendingCartItem', JSON.stringify({
productId: '123',
quantity: 2
}));
window.location.href = '/cart';
// Cart page
const pending = JSON.parse(localStorage.getItem('pendingCartItem') || 'null');
if (pending) {
addToCart(pending.productId, pending.quantity);
localStorage.removeItem('pendingCartItem');
}Cookies
For server-readable state (e.g., auth tokens, session IDs):
// Set a cookie accessible to all MFEs
document.cookie = 'cart_count=3; path=/; max-age=3600';Backend/API as Source of Truth
The most robust approach: persist state server-side and fetch it in each MFE.
// Any MFE can fetch current cart from API
const cart = await fetch('/api/cart').then(r => r.json());This ensures consistency but requires network requests on each page load.
Pros:
- Simple infrastructure — just a reverse proxy, no special tooling
- Complete team autonomy — each MFE is a standalone app
- Technology agnostic — different MFEs can use different frameworks
- Easy to understand — URL → MFE mapping is transparent
- Independent scaling — scale each MFE service independently
- Good for SEO — each MFE can SSR its own pages
Cons:
- Full page reloads — navigating between MFEs loses client state
- State sharing is difficult — must use URL params, storage, or backend APIs
- Shared UI is tricky — header/footer must be duplicated or shared via packages
- Slower cross-MFE navigation — no SPA-like instant transitions
- Session/auth coordination — need shared auth infrastructure (cookies, tokens)
When to use:
- Teams want complete independence with minimal runtime coupling
- Different MFEs use different tech stacks
- SEO and server-side rendering are priorities
- You're migrating legacy apps incrementally behind a gateway
- Simpler infrastructure is preferred over SPA-like UX
3.5 Module Federation (Recommended for Most Cases)
Module Federation is a Webpack 5 feature (also supported by Vite via plugins) that allows runtime sharing of code between independently deployed applications.
This is the most powerful and flexible option for modern micro-frontends.
4. Module Federation Deep Dive
Module Federation enables one application to dynamically load code from another application at runtime, while sharing common dependencies like React.
4.1 Core Concepts
| Term | Description |
|---|---|
| Host | The application that consumes remote modules (the shell) |
| Remote | The application that exposes modules for consumption |
| Exposed Module | Code that a remote makes available to hosts |
| Shared Module | Dependencies shared between host and remotes (e.g., React) |
4.2 Configuration Example
Remote: Product Catalog (apps/product-catalog/webpack.config.js)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
// ...
plugins: [
new ModuleFederationPlugin({
name: 'productCatalog',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/components/ProductList',
'./ProductDetail': './src/components/ProductDetail',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};Host: Shell App (apps/shell/webpack.config.js)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
// ...
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
productCatalog: 'productCatalog@https://product-catalog.acme.com/remoteEntry.js',
shoppingCart: 'shoppingCart@https://cart.acme.com/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};Using the remote in the shell:
import React, { Suspense, lazy } from 'react';
// Dynamic import from remote
const ProductList = lazy(() => import('productCatalog/ProductList'));
function App() {
return (
<div>
<h1>Acme Store</h1>
<Suspense fallback={<div>Loading products...</div>}>
<ProductList category="electronics" />
</Suspense>
</div>
);
}4.3 Shared Dependencies and Versioning
The shared configuration is critical. Without it, each micro-frontend bundles its own copy of React, leading to:
- Duplicate framework instances
- Broken hooks (React hooks require a single React instance)
- Bloated bundle sizes
Key shared options:
shared: {
react: {
singleton: true, // Only one version in the page
requiredVersion: '^18.0.0',
eager: false, // Load lazily (recommended)
strictVersion: false, // Allow compatible versions
},
}| Option | Description |
|---|---|
singleton | Ensures only one copy of the library is loaded |
requiredVersion | The version range this app needs |
eager | If true, loads immediately (increases initial bundle) |
strictVersion | If true, errors on version mismatch |
Interview Question: What happens if two micro-frontends use different React versions with Module Federation?
Answer: With
singleton: true, Module Federation ensures only one React instance is loaded. If versions are compatible (per semver range), the highest satisfying version is used. IfstrictVersion: trueand versions conflict, it throws an error. Without proper configuration, you could end up with multiple React instances, breaking hooks and causing subtle bugs.
4.4 Module Federation with Vite
Vite doesn't have native Module Federation, but @originjs/vite-plugin-federation provides similar functionality:
// vite.config.js
import federation from '@originjs/vite-plugin-federation';
export default {
plugins: [
federation({
name: 'productCatalog',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/components/ProductList.tsx',
},
shared: ['react', 'react-dom'],
}),
],
};5. Cross-Micro-Frontend Communication
Micro-frontends need to communicate: the cart needs to know when a product is added, the profile needs to update when the user logs in.
5.1 Principles
- Keep communication minimal — micro-frontends should be loosely coupled
- Use contracts — define clear interfaces for events and data
- Prefer events over direct calls — decouples sender and receiver
5.2 Custom Events
The simplest browser-native approach:
// Product catalog dispatches
window.dispatchEvent(new CustomEvent('product:add-to-cart', {
detail: { productId: '123', quantity: 1 }
}));
// Shopping cart listens
window.addEventListener('product:add-to-cart', (event: CustomEvent) => {
const { productId, quantity } = event.detail;
addToCart(productId, quantity);
});Pros:
- Native browser API
- Framework-agnostic
- Zero dependencies
Cons:
- No type safety without extra tooling
- Easy to create event soup if overused
- Global namespace pollution
5.3 Event Bus / Message Broker
A thin abstraction over custom events with better ergonomics:
// shared/event-bus.ts
type EventMap = {
'product:add-to-cart': { productId: string; quantity: number };
'user:logged-in': { userId: string; name: string };
'cart:updated': { itemCount: number };
};
class EventBus {
private listeners = new Map<string, Set<Function>>();
emit<K extends keyof EventMap>(event: K, payload: EventMap[K]) {
const handlers = this.listeners.get(event);
handlers?.forEach(handler => handler(payload));
}
on<K extends keyof EventMap>(event: K, handler: (payload: EventMap[K]) => void) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(handler);
// Return unsubscribe function
return () => this.listeners.get(event)?.delete(handler);
}
}
export const eventBus = new EventBus();Usage:
// Product catalog
import { eventBus } from '@acme/shared/event-bus';
eventBus.emit('product:add-to-cart', { productId: '123', quantity: 1 });
// Shopping cart
import { eventBus } from '@acme/shared/event-bus';
useEffect(() => {
const unsubscribe = eventBus.on('product:add-to-cart', ({ productId, quantity }) => {
addToCart(productId, quantity);
});
return unsubscribe;
}, []);5.4 Shared State (Use Sparingly)
For truly shared state (current user, feature flags), consider:
// shared/store.ts
import { create } from 'zustand';
interface SharedState {
user: { id: string; name: string } | null;
setUser: (user: SharedState['user']) => void;
}
export const useSharedStore = create<SharedState>((set) => ({
user: null,
setUser: (user) => set({ user }),
}));Warning: Shared state couples micro-frontends. Keep it minimal (auth state, feature flags) and prefer events for transient communication.
5.5 Props Drilling from Shell
The shell can pass data to micro-frontends as props:
// Shell
<ProductCatalog
user={currentUser}
onAddToCart={handleAddToCart}
/>This works well when the shell orchestrates everything, but breaks down when micro-frontends need to talk to each other.
6. Routing and Navigation
How do you handle URLs when multiple micro-frontends need their own routes?
6.1 Shell-Owned Routing
The shell owns the router and mounts different micro-frontends based on the URL:
// Shell app
import { BrowserRouter, Routes, Route } from 'react-router-dom';
function Shell() {
return (
<BrowserRouter>
<Layout>
<Routes>
<Route path="/products/*" element={<ProductCatalogMFE />} />
<Route path="/cart/*" element={<ShoppingCartMFE />} />
<Route path="/profile/*" element={<UserProfileMFE />} />
</Routes>
</Layout>
</BrowserRouter>
);
}Each micro-frontend handles its own sub-routes internally:
// Product catalog MFE
function ProductCatalogMFE() {
return (
<Routes>
<Route index element={<ProductList />} />
<Route path=":id" element={<ProductDetail />} />
</Routes>
);
}6.2 Nested Routing with Base Path
Pass the base path to each micro-frontend:
<ProductCatalogMFE basePath="/products" />Inside the micro-frontend:
function ProductCatalogMFE({ basePath }: { basePath: string }) {
return (
<Routes>
<Route path={`${basePath}`} element={<ProductList />} />
<Route path={`${basePath}/:id`} element={<ProductDetail />} />
</Routes>
);
}6.3 Cross-MFE Navigation
When one micro-frontend needs to navigate to another's route:
// Option 1: Use window.history directly
window.history.pushState({}, '', '/cart');
window.dispatchEvent(new PopStateEvent('popstate'));
// Option 2: Event-based
eventBus.emit('navigation:request', { path: '/cart' });
// Shell listens and navigates
eventBus.on('navigation:request', ({ path }) => {
navigate(path);
});7. Deployment and CI/CD
Each micro-frontend should have its own deployment pipeline.
7.1 Deployment Architecture
7.2 Versioning Strategies
Option 1: Latest Always
The shell always loads the latest deployed version:
remotes: {
productCatalog: 'productCatalog@https://product-catalog.acme.com/remoteEntry.js',
}Pros: Zero coordination, auto-updates Cons: Risk of breaking changes
Option 2: Pinned Versions
The shell references specific versions:
remotes: {
productCatalog: 'productCatalog@https://cdn.acme.com/product-catalog/v2.3.0/remoteEntry.js',
}Pros: Predictable, controlled rollouts Cons: Requires shell redeployment for updates
Option 3: Dynamic Version Resolution
Fetch versions from a config service:
// At runtime
const versions = await fetch('/api/mfe-versions').then(r => r.json());
// { productCatalog: "2.3.0", shoppingCart: "1.4.0" }
// Use dynamic import with resolved URL7.3 Canary and A/B Testing
With runtime composition, you can:
- Route 10% of users to a new micro-frontend version
- Test new features in specific micro-frontends without touching others
- Roll back instantly by changing the config
8. Trade-Offs and Anti-Patterns
8.1 When NOT to Use Micro-Frontends
| Scenario | Why Not |
|---|---|
| Small team (< 5 developers) | Overhead isn't worth it; communication is easy |
| Simple CRUD app | Over-engineering; a monolith is simpler |
| Tightly coupled features | If everything shares state heavily, you'll fight the architecture |
| Performance-critical apps | Multiple bundles add latency |
| No deployment pain | If deployments aren't a bottleneck, don't solve a non-problem |
8.2 Common Anti-Patterns
1. Nano-Frontends
Splitting too granularly (one MFE per component) creates:
- Massive communication overhead
- Network waterfall for every page
- Impossible dependency management
2. Shared Mutable State
If every micro-frontend reads/writes to the same global store, you've just built a distributed monolith with extra steps.
3. Different Frameworks "Because We Can"
Yes, micro-frontends can use different frameworks. But should you?
- Each framework adds bundle size
- Shared design systems become harder
- Developer context-switching increases
- You're not really a unified product anymore
4. Ignoring the Shell
The shell is critical. A poorly designed shell leads to:
- Inconsistent navigation
- Layout shifts when MFEs load
- Poor error handling when a remote fails
8.3 Performance Considerations
| Concern | Mitigation |
|---|---|
| Multiple network requests | Preload critical remotes, use HTTP/2 |
| Duplicate dependencies | Use Module Federation shared properly |
| Initial load time | Lazy-load non-critical MFEs |
| JavaScript execution | Keep MFEs lean, code-split internally |
9. Real-World Architecture Example
Let's design a micro-frontend architecture for an e-commerce platform.
9.1 Domain Decomposition
9.2 Directory Structure
acme-commerce/
├── apps/
│ ├── shell/ # Host application
│ ├── product-catalog/ # Team Catalog
│ ├── shopping-cart/ # Team Cart
│ ├── checkout/ # Team Payments
│ ├── user-account/ # Team Identity
│ └── order-history/ # Team Orders
├── packages/
│ ├── design-system/ # Shared UI components
│ ├── event-bus/ # Cross-MFE communication
│ ├── shared-types/ # TypeScript interfaces
│ └── config/ # Shared ESLint, TS configs
├── pnpm-workspace.yaml
└── turbo.json9.3 Repository Strategy: Monorepo vs Polyrepo
The directory structure above shows a monorepo — all micro-frontends in one repository. But is this always the right choice? Let's compare both approaches.
Monorepo Approach
All micro-frontends live in a single Git repository:
acme-commerce/ # Single repo
├── apps/
│ ├── shell/
│ ├── product-catalog/
│ ├── shopping-cart/
│ └── checkout/
├── packages/
│ └── design-system/
└── pnpm-workspace.yamlPros:
- Easy code sharing — direct imports from shared packages, no versioning ceremony
- Atomic changes — cross-cutting refactors in a single PR
- Unified tooling — consistent linting, testing, and build configs
- Simpler dependency management — keep React/design system versions aligned
- Better discoverability — all code in one place
Cons:
- Shared governance — teams must agree on tooling, CI patterns
- CI complexity — needs smart tooling (Turborepo/Nx) to avoid rebuilding everything
- Large clone size — new developers clone everything even if they only work on one MFE
- Single point of failure — CI/CD issues affect all teams
Polyrepo Approach
Each micro-frontend has its own repository:
acme-shell/ # Separate repo
acme-product-catalog/ # Separate repo
acme-shopping-cart/ # Separate repo
acme-checkout/ # Separate repo
acme-design-system/ # Separate repo (published to npm)Pros:
- Full team autonomy — teams own their repo entirely (CI/CD, linting, release cadence)
- Security isolation — teams can't see each other's code if needed
- Simpler per-repo CI — no need for advanced build orchestration
- Independent scaling — repos can have different branch strategies, review policies
- Smaller clones — developers only clone what they need
Cons:
- Code sharing is harder — shared code must be published as packages and versioned
- Version drift — each repo may use different React versions, causing runtime conflicts
- Cross-cutting changes are painful — updating the design system means PRs across N repos
- Tooling fragmentation — each repo may evolve different patterns over time
- Dependency hell — coordinating breaking changes across repos is complex
When to Choose Which?
| Scenario | Recommended | Why |
|---|---|---|
| Same tech stack (all React) | Monorepo | Shared tooling, easier code sharing |
| Strong design system integration | Monorepo | Direct imports, atomic updates |
| Teams need full independence | Polyrepo | No shared governance needed |
| Different frameworks per MFE | Polyrepo | Monorepo tooling struggles with mixed stacks |
| Security boundaries required | Polyrepo | Code isolation between teams |
| Migrating from legacy | Polyrepo (initially) | Easier to extract one piece at a time |
| < 5 micro-frontends | Monorepo | Overhead of polyrepo not worth it |
| > 10 micro-frontends, large org | Either | Depends more on team culture and tooling maturity |
Hybrid Approach
Many organizations land on a hybrid:
- Monorepo for shared packages (design system, event bus, types)
- Polyrepo for micro-frontends (each team owns their own repo)
- Shared packages are published to a private npm registry and consumed by MFE repos
This gives you the best of both worlds: shared code is managed centrally with proper versioning, while teams have full autonomy over their micro-frontend repos.
Interview Question: Would you recommend a monorepo or polyrepo for micro-frontends?
Answer: It depends on the organization. Monorepos excel when teams share a tech stack and need tight integration (shared design system, atomic refactors). Polyrepos are better when teams need full autonomy, have different tech stacks, or require security isolation. Many organizations use a hybrid: a monorepo for shared packages and polyrepos for each micro-frontend. The key is matching the repository strategy to your organization's communication patterns — Conway's Law applies here.
9.4 Communication Flow
10. Summary
Micro-frontends are a powerful architectural pattern for scaling frontend development across multiple teams. Here's what to remember:
| Aspect | Key Takeaway |
|---|---|
| When to use | Multiple teams, deployment independence needed, legacy migration |
| When NOT to use | Small teams, simple apps, no deployment bottleneck |
| Composition | Build-time for simplicity, runtime (Module Federation) for independence |
| Communication | Events and event buses; minimize shared state |
| Routing | Shell owns top-level routes; MFEs own their sub-routes |
| Shared deps | Use Module Federation shared with singleton: true |
| Deployment | Independent pipelines per MFE; consider version strategies |
| Repository | Monorepo for shared code; polyrepo for team autonomy; hybrid often best |
The goal isn't to use micro-frontends—it's to ship faster and more reliably. If a well-structured monolith achieves that, stick with it. But when organizational scale demands independent teams and deployments, micro-frontends give you the architecture to support it.
Choose wisely, start simple, and evolve as needed.
Have you implemented micro-frontends in your organization? What patterns worked (or didn't) for you? Drop a comment below — I'd love to hear about your experience!
Loading comments...