The Promise

Module Federation (Webpack 5) promised the dream of micro-frontends:

  • Independent deployment – each team ships on their own schedule
  • Technology agnosticism – use whatever framework you want
  • Runtime composition – load remote components dynamically at runtime
  • Shared dependencies – avoid duplicate React, UI kits, state management

The pitch: “Build once, deploy independently, compose at runtime.”

The Reality

In practice, with 40+ MFEs federated into a single shell application:

  • All MFEs must use the same framework version (locked to the shell’s version)
  • All MFEs must use the same React version (runtime singleton)
  • Shared libraries must be version-compatible across all MFEs
  • Deploying one MFE can break every other MFE due to shared dependency mutations
  • Independent deployment is theoretically possible, practically terrifying

What you actually have is a distributed monolith – all the operational complexity of microservices with all the coupling of a monolith.

The Version Coupling Evidence

Across 40+ MFEs in a real production system, version drift is both inevitable and catastrophic:

DependencyShellMFE AMFE BMFE CMFE D
Framework (Next.js)^12.3.512.3.4^12.3.4“latest”12.3.4
MF Plugin5.12.75.11.55.12.75.12.75.12.7
HTTP Client (axios)^1.12.21.11.01.11.0^0.24.0^0.23.0
UI Kit^6.0.41^6.0.41^6.0.286.0.416.0.41

Notice: one MFE has next: "latest" in its package.json. Any npm install can pull a completely different framework version. This drift happens because 40+ teams cannot coordinate dependency updates simultaneously. They shouldn’t have to. But Module Federation forces them to.

Stuck on an Old Framework

The entire platform is locked to Next.js 12 (released October 2022). That’s three major versions behind. Why?

  1. All 40+ MFEs must upgrade simultaneously. If the shell runs Next.js 14 and an MFE runs Next.js 12, the runtime behavior is undefined.
  2. The MF plugin lags behind the framework. @module-federation/nextjs-mf must be updated to support each new Next.js version.
  3. The framework is dropping MF support. Next.js 15 moved to Turbopack, which does not support Webpack plugins.

The “independent deployment” promise becomes ironic: you can independently deploy MFEs, but you cannot independently upgrade them.

The Shared Singleton Trap

When singleton: true, Module Federation ensures only one copy of a library is loaded at runtime. The “winning” version is determined by negotiation (typically the highest compatible version).

Problem: If MFE A ships with [email protected] and MFE B ships with [email protected], the runtime picks one. If there are breaking changes between 2.0.2 and 2.1.0, one MFE crashes. You don’t find out until production.

Problem: The winning version can change based on MFE load order. Deploy MFE B first? Version 2.1.0 wins. Deploy MFE A first? Version 2.0.2 wins. Same code, different behavior, depending on deployment timing.

The UI Kit SSR Style Collision (Production Incident)

This is the most insidious problem. It is intermittent, route-dependent, and extremely difficult to diagnose.

How SSR Works in This Architecture

The MFEs do not return HTML. They expose JavaScript components via remoteEntry.js. All compute happens in the shell:

  1. The shell’s Node.js process loads each MFE’s remoteEntry.js via Module Federation’s runtime.
  2. The shell imports the MFE components.
  3. The shell calls ReactDOM.renderToString() on all MFE components in its own process.
  4. All MFEs render in the same Node.js process, the same webpack runtime, and the same React instance.

What Happens with the UI Kit

When the shell loads multiple remoteEntry.js files with different UI Kit versions, multiple versions emit CSS with the same class names but different styles:

/* UI Kit 6.0.28 (loaded by Header MFE) */
.btn-primary { padding: 8px 16px; border-radius: 4px; background: #0066cc; }

/* UI Kit 6.0.41 (loaded by Footer MFE) */
.btn-primary { padding: 10px 20px; border-radius: 8px; background: #0052a3; }

Both versions’ CSS ends up in the same HTML document. CSS cascade: last one wins. Whichever version’s CSS is emitted last overrides all other components on the page.

Why It’s Route-Dependent

Different routes load different MFE combinations. Each route loads a different set of remoteEntry.js files. The webpack runtime negotiation produces a different “winning” UI Kit version per route.

Same application, different routes, different UI Kit version wins, different visual output.

Module Federation explicitly does not handle CSS isolation:

“Module Federation does not directly handle CSS style isolation because shared dependencies can conflict.”

This is not a bug. It is a fundamental architectural limitation.

Configuration Explosion

With 40+ MFEs, the shell must know the URL of every remote. Each MFE has two endpoints (SSR server + client-side chunk URL), often per environment:

  • 80+ env vars that must be correct per environment
  • Zero type safety across MFE boundaries – every prop is unknown
  • One wrong URL = silent failure or runtime crash
  • Adding an MFE requires updating the shell’s config, env vars, and typings

The Alternative: Vertical Split

Instead of runtime composition (Module Federation), use build-time isolation with route-level splitting.

                         ┌──────────────┐
                         │ Reverse Proxy│
            Browser ────>│ (Nginx/CDN)  │
                         └──────┬───────┘
                    Route-based routing
              ┌─────────────────┼──────────────────┐
              │                 │                   │
              ▼                 ▼                   ▼
     ┌──────────────┐  ┌──────────────┐   ┌──────────────┐
     │  /orders/*   │  │  /catalog/*  │   │  /account/*  │
     │  Next.js 14  │  │  Next.js 15  │   │  Next.js 14  │
     │  React 18    │  │  React 19    │   │  React 18    │
     │  UI Kit 7.0  │  │  UI Kit 6.5  │   │  UI Kit 7.0  │
     │  Own deploy  │  │  Own deploy  │   │  Own deploy  │
     └──────────────┘  └──────────────┘   └──────────────┘
  1. Each MFE owns a route prefix. The reverse proxy routes by path – no shell application.
  2. Each MFE is a complete, independent application – own framework version, own React, own UI kit, own deployment.
  3. Shared UI is distributed via npm packages (build-time), not runtime sharing.
  4. Navigation between MFEs is standard HTML links – full page transitions.

Comparison Table

DimensionModule FederationVertical Split
Deployment independenceTheoretical; blocked by shared depsReal; each app deploys independently
Framework version freedomAll MFEs must match the shellEach MFE chooses its own
CSS isolationNone; styles leak across boundariesComplete; each page is separate
Type safety across boundariesNone (unknown props)N/A; no cross-MFE imports
Configuration overhead2N env vars, N type declarationsOne proxy rule per MFE
Failure blast radiusOne bad deploy can break entire shellOnly affects its routes
Framework upgrade pathAll MFEs simultaneouslyOne MFE at a time
User experience (navigation)SPA-like transitionsFull page load on MFE boundary

The One Trade-Off

Vertical split means full page loads when navigating between MFEs. But users navigate between major sections far less frequently than they interact within a section. Modern browsers make full page transitions fast (< 300ms with prefetching). The operational cost of maintaining seamless transitions across 40+ federated MFEs vastly outweighs the UX benefit.

Migration Path

  1. Phase 1: Identify standalone MFEs (full-page features) and redirect their routes through the proxy to independent deployments.
  2. Phase 2: Extract shared components (header, footer, nav) into an npm package.
  3. Phase 3: Retire the shell. It becomes a thin proxy with routing rules.

Key Takeaway

Module Federation was adopted to enable team independence, but it created a system where no team can deploy, upgrade, or even change a UI kit version without coordinating with every other team. That is the opposite of independence.

Vertical split is less technically elegant. Full page loads between sections feel less “modern.” But it delivers the thing that actually matters: each team ships on their own schedule, with their own framework version, without breaking anyone else. That is what micro-frontends were supposed to be.