Skip to content
Back to portfolio
Micro Frontend
Architecture
React
Module Federation

Micro Frontend Architecture: Breaking Monoliths into Scalable UI

A deep dive into Micro Frontend architecture — how to split a monolithic frontend into independently deployable modules using Module Federation, single-spa, and modern patterns.

March 10, 202612 min read

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.

json
{
  "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.

javascript
// 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" },
  },
});
javascript
// 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 },
  },
});
tsx
// 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.

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

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

typescript
// 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)

typescript
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:

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

StrategyIsolation LevelTrade-off
CSS ModulesGoodRequires build tooling
Shadow DOMCompleteLimited React support
BEM / PrefixingBasicManual discipline needed
CSS-in-JSGoodRuntime cost
Tailwind with prefixGoodConfig 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:

  1. Shared dependencies — Use Module Federation's singleton sharing to avoid duplicate React/ReactDOM loads
  2. Lazy loading — Load micro frontends only when their route is active
  3. Prefetching — Preload critical micro frontends during idle time
  4. Bundle analysis — Monitor each micro frontend's bundle size independently
  5. 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.