Vikram D.

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

2026-01-0818 min read
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:

  1. What micro-frontends are and when they make sense
  2. Composition patterns: build-time vs runtime
  3. Module Federation deep dive
  4. Cross-micro-frontend communication
  5. Routing and navigation strategies
  6. Shared dependencies and versioning
  7. Deployment and CI/CD considerations
  8. Trade-offs and when NOT to use micro-frontends
  9. 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

CharacteristicDescription
Independent DeploymentEach micro-frontend can be deployed without coordinating with others
Team AutonomyDifferent teams own different micro-frontends end-to-end
Technology AgnosticTeams can use different frameworks (though this adds complexity)
Isolated FailuresA bug in one micro-frontend shouldn't crash the entire application
Vertical SlicingTeams 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

  1. User navigates to https://acme.com/products/shoes
  2. Gateway matches the /products/* prefix
  3. Request is proxied to the Product Catalog micro-frontend
  4. Product Catalog returns a complete HTML page
  5. 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

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

TermDescription
HostThe application that consumes remote modules (the shell)
RemoteThe application that exposes modules for consumption
Exposed ModuleCode that a remote makes available to hosts
Shared ModuleDependencies 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
  },
}
OptionDescription
singletonEnsures only one copy of the library is loaded
requiredVersionThe version range this app needs
eagerIf true, loads immediately (increases initial bundle)
strictVersionIf 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. If strictVersion: true and 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

  1. Keep communication minimal — micro-frontends should be loosely coupled
  2. Use contracts — define clear interfaces for events and data
  3. 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 URL

7.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

ScenarioWhy Not
Small team (< 5 developers)Overhead isn't worth it; communication is easy
Simple CRUD appOver-engineering; a monolith is simpler
Tightly coupled featuresIf everything shares state heavily, you'll fight the architecture
Performance-critical appsMultiple bundles add latency
No deployment painIf 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

ConcernMitigation
Multiple network requestsPreload critical remotes, use HTTP/2
Duplicate dependenciesUse Module Federation shared properly
Initial load timeLazy-load non-critical MFEs
JavaScript executionKeep 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.json

9.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.yaml

Pros:

  • 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?

ScenarioRecommendedWhy
Same tech stack (all React)MonorepoShared tooling, easier code sharing
Strong design system integrationMonorepoDirect imports, atomic updates
Teams need full independencePolyrepoNo shared governance needed
Different frameworks per MFEPolyrepoMonorepo tooling struggles with mixed stacks
Security boundaries requiredPolyrepoCode isolation between teams
Migrating from legacyPolyrepo (initially)Easier to extract one piece at a time
< 5 micro-frontendsMonorepoOverhead of polyrepo not worth it
> 10 micro-frontends, large orgEitherDepends 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:

AspectKey Takeaway
When to useMultiple teams, deployment independence needed, legacy migration
When NOT to useSmall teams, simple apps, no deployment bottleneck
CompositionBuild-time for simplicity, runtime (Module Federation) for independence
CommunicationEvents and event buses; minimize shared state
RoutingShell owns top-level routes; MFEs own their sub-routes
Shared depsUse Module Federation shared with singleton: true
DeploymentIndependent pipelines per MFE; consider version strategies
RepositoryMonorepo 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...