Skip to content

Micro-Frontend Architecture

expert18 min read

The Problem Micro-frontends Actually Solve

Micro-frontends are not a technical solution. They are an organizational solution.

When you have 80+ frontend engineers split across 10+ teams, all shipping to the same product, the monolith becomes a bottleneck. Not because of code -- because of people. Merge conflicts, deployment queues, shared component debates, and "who broke the build" investigations consume more time than feature development.

Micro-frontends let teams own, deploy, and scale their piece of the frontend independently. Team A deploys the checkout flow without waiting for Team B's dashboard changes to pass CI.

But -- and this is the part most blog posts skip -- micro-frontends add massive operational complexity. If your team is under 30 engineers, you almost certainly do not need them.

Mental Model

Think of a shopping mall vs. a department store. A department store (monolith) has one owner, one building, one checkout system. Efficient, but if the shoe department wants to renovate, the whole store might be disrupted. A shopping mall (micro-frontends) has independent stores. Each store manages its own inventory, hours, and decor. But the mall needs shared infrastructure: common hallways, shared parking, a unified directory, and someone to handle the electricity bill. That shared infrastructure is the hidden cost.

Composition Patterns

There are four ways to compose micro-frontends into a unified experience.

Build-time Composition

{
  "name": "@acme/shell",
  "dependencies": {
    "@acme/checkout": "^2.1.0",
    "@acme/dashboard": "^3.0.0",
    "@acme/course-player": "^1.5.0"
  }
}

Each micro-frontend is an npm package. The shell app installs them and renders them as components. Simple, but you lose independent deployability -- updating @acme/checkout requires rebuilding and deploying the shell.

Server-side Composition

async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  const [headerHtml, productHtml, reviewsHtml] = await Promise.all([
    fetch("https://header-service.internal/render").then((r) => r.text()),
    fetch(`https://product-service.internal/render/${id}`).then((r) => r.text()),
    fetch(`https://reviews-service.internal/render/${id}`).then((r) => r.text()),
  ]);

  return (
    <html>
      <body>
        <div dangerouslySetInnerHTML={{ __html: headerHtml }} />
        <div dangerouslySetInnerHTML={{ __html: productHtml }} />
        <div dangerouslySetInnerHTML={{ __html: reviewsHtml }} />
      </body>
    </html>
  );
}

Each service renders its own HTML. The shell assembles fragments. This is how IKEA.com works -- each section of the page is an independently deployed service.

Client-side Composition

The most common pattern. The shell app loads micro-frontends at runtime via Module Federation or dynamic script loading.

import { lazy, Suspense } from "react";

const Checkout = lazy(() => import("checkout/CheckoutApp"));
const Dashboard = lazy(() => import("dashboard/DashboardApp"));

function App() {
  return (
    <Router>
      <Route path="/checkout/*" element={
        <Suspense fallback={<CheckoutSkeleton />}>
          <Checkout />
        </Suspense>
      } />
      <Route path="/dashboard/*" element={
        <Suspense fallback={<DashboardSkeleton />}>
          <Dashboard />
        </Suspense>
      } />
    </Router>
  );
}
Quiz
Which composition pattern preserves independent deployment while supporting server-side rendering?

Routing Approaches

Who owns the URL? This is the most contentious decision in micro-frontend architecture.

Route-based Splitting

Each micro-frontend owns a set of routes. The simplest and most common approach.

/checkout/*        → Checkout micro-frontend
/dashboard/*       → Dashboard micro-frontend
/courses/*         → Course Player micro-frontend
/admin/*           → Admin micro-frontend

The shell handles top-level routing and mounts the correct micro-frontend. Each micro-frontend handles its own sub-routes internally.

Component-based Splitting

Multiple micro-frontends render on the same page. The header is one micro-frontend, the sidebar is another, and the main content is a third.

This is harder because micro-frontends on the same page need to coordinate layout, share responsive breakpoints, and communicate state changes.

Avoid mixing frameworks on one page

If the header is React, the sidebar is Vue, and the content is Angular, the user downloads three framework runtimes. That is 200KB+ of JavaScript before your actual app loads. Framework mixing is technically possible but almost never justified. Pick one framework for client-side rendering.

Shared Dependencies

The biggest performance trap: if each micro-frontend bundles its own React, the user downloads React five times.

Module Federation Shared Config

new ModuleFederationPlugin({
  shared: {
    react: { singleton: true, requiredVersion: "^19.0.0" },
    "react-dom": { singleton: true, requiredVersion: "^19.0.0" },
  },
});

singleton: true ensures only one copy of React is loaded, regardless of how many micro-frontends request it. The first one to load wins.

Version Negotiation

What happens when the checkout micro-frontend needs React 19.0.0 but the dashboard still uses React 18.3.0? Module Federation supports version ranges and fallback loading, but in practice, pin major framework versions across all micro-frontends. React version mismatches cause the same "Invalid hook call" errors as in monorepos.

Quiz
Three micro-frontends each bundle their own copy of React (190KB gzipped each). What is the total React JavaScript downloaded by the user?

Communication Between Micro-frontends

Micro-frontends need to share some state: authentication status, user preferences, navigation events.

Custom Events

The simplest pattern. Zero coupling.

function dispatchMicroFrontendEvent(type: string, detail: unknown) {
  window.dispatchEvent(new CustomEvent(`mfe:${type}`, { detail }));
}

function onMicroFrontendEvent(type: string, handler: (detail: unknown) => void) {
  const listener = (e: Event) => handler((e as CustomEvent).detail);
  window.addEventListener(`mfe:${type}`, listener);
  return () => window.removeEventListener(`mfe:${type}`, listener);
}
dispatchMicroFrontendEvent("cart:updated", { itemCount: 3 });

onMicroFrontendEvent("cart:updated", (detail) => {
  updateCartBadge((detail as { itemCount: number }).itemCount);
});

Shared State Store

For more complex communication, a shared state store (exposed by the shell) works well:

interface SharedState {
  user: User | null;
  theme: "light" | "dark";
  locale: string;
}

const sharedStore = createSharedStore<SharedState>({
  user: null,
  theme: "light",
  locale: "en",
});

window.__SHARED_STORE__ = sharedStore;

Each micro-frontend subscribes to the parts of shared state it needs.

Common Trap

Keep shared state minimal. If micro-frontends share a lot of state, you do not have micro-frontends -- you have a distributed monolith. The whole point is independence. Shared state should be limited to cross-cutting concerns: auth, theme, locale. Feature-specific state stays inside the micro-frontend.

When Micro-frontends Make Sense

SignalMicro-frontendsModular Monolith
Team size30+ engineers, 5+ frontend teamsUnder 30 engineers
Deployment cadenceTeams need to deploy independently multiple times per dayCoordinated releases are acceptable
Tech diversityTeams have legitimate reasons for different frameworksOne framework works for everyone
Organizational structureAutonomous teams with clear domain boundariesCross-functional teams that collaborate closely
Product surfaceDistinct user journeys (checkout vs dashboard vs admin)Highly interconnected features
Acceptable trade-offOperational complexity in exchange for team autonomySimplicity over autonomy
Quiz
A 15-person startup with one React app wants to adopt micro-frontends to prepare for growth. What is the best advice?

When Micro-frontends Are Overkill

  • Small teams (under 30 engineers) -- the coordination overhead exceeds the benefit
  • Highly coupled features -- if features share lots of state and behavior, splitting them adds communication overhead without reducing complexity
  • Rapid iteration -- micro-frontends add deployment and testing complexity that slows iteration velocity
  • Performance-critical apps -- the overhead of loading multiple bundles, shared dependency negotiation, and cross-app communication can hurt Core Web Vitals
  • SEO-critical apps -- coordinating server-side rendering across micro-frontends adds significant complexity
Real-World Micro-Frontend Adoption

IKEA uses server-side composition with fragment-based assembly. Each product page section (header, product details, recommendations, reviews) is an independently deployed service that renders HTML. The shell assembles fragments before sending to the client.

Spotify uses iframe-based composition for their desktop app (each section is an iframe). This provides the strongest isolation but limits shared styling and cross-section interaction.

Zalando uses server-side composition with custom tooling called Interface Framework. Each team owns a "fragment" that is a Node.js service rendering HTML.

Amazon uses a mix of server-side and client-side composition. Different teams own different parts of the product page, assembled at the edge.

Notice a pattern: these are all massive organizations (1000+ engineers) with strong platform teams to manage the infrastructure. The micro-frontend pattern works because they have dedicated teams to build and maintain the composition layer, shared dependency management, and deployment pipeline. Without that investment, micro-frontends become a maintenance nightmare.

Key Rules
  1. 1Micro-frontends solve organizational problems (team independence), not technical ones
  2. 2Use route-based splitting as the default -- it is the simplest and most effective pattern
  3. 3Shared dependencies MUST be deduplicated -- React loaded five times is 500KB+ wasted
  4. 4Keep cross-micro-frontend communication minimal -- shared state should be limited to auth, theme, locale
  5. 5Do not adopt micro-frontends unless you have 30+ frontend engineers and distinct team boundaries
What developers doWhat they should do
Each micro-frontend uses a different framework (React, Vue, Svelte) because teams prefer different tools
Multiple frameworks mean multiple runtimes downloaded by the user, incompatible shared components, and engineers who cannot move between teams. Framework diversity is a bug, not a feature.
Standardize on one framework with micro-frontends providing team autonomy within that framework
Micro-frontends sharing large amounts of state through a global store
Heavy state sharing defeats the purpose of micro-frontends. If everything depends on everything, you have a distributed monolith with network calls instead of function calls.
Minimal shared state (auth, theme) with feature state kept inside each micro-frontend
Adopting micro-frontends because it sounds like the modern approach
Most teams that adopt micro-frontends prematurely spend more time on infrastructure than features. A well-structured monolith serves teams up to 30+ engineers effectively.
Starting with a modular monolith and migrating only when team size and deployment friction demand it