Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Sep 22, 2026·15 min read

Module Federation 2.0: Dynamic Remotes, Type Safety, and Runtime Plugins

Module Federation 2.0 shifts the paradigm from static remote URLs at build time to runtime manifest resolution — enabling zero-downtime rollbacks, canary deployments, and cross-boundary type safety without a full rebuild.

Module Federation 2.0: Dynamic Remotes, Type Safety, and Runtime Plugins

A micro-frontend is not a smaller app — it is a domain boundary enforced at the deployment layer. If you can't explain the business capability it owns, you haven't drawn the boundary yet.
In Part 3, the remote URL was hardcoded into the host's build config:
javascript
remotes: {
  checkout: 'checkout@https://cdn.example.com/checkout/remoteEntry.js',
}
This works. It also means that if the Checkout team deploys a bug and you need to roll it back, you must rebuild and redeploy the App Shell — a process that takes 8–15 minutes in most CI systems. During those 8–15 minutes, broken code runs in production for every user.
Module Federation 2.0 solves this. It shifts the paradigm from "static remote URLs baked into the host at build time" to "runtime manifest resolution" — where the host fetches a pointer file from a CDN at startup and the pointer file determines which remote version to load. Rolling back is a one-line CDN file update: 30 seconds, no rebuild.

1. The Manifest Architecture

1.1 The Problem With Hardcoded URLs

A hardcoded remote URL creates a build-time dependency between the host and the remote's deployment:
Timeline:
  T+0   Checkout deploys v1.2.3 → URL: cdn.example.com/checkout/1.2.3/remoteEntry.js
  T+0   Host config has: checkout@cdn.example.com/checkout/1.2.3/remoteEntry.js  ✅
  T+30m Checkout deploys v1.2.4 (bug fix)
  T+30m Host config STILL has: .../1.2.3/remoteEntry.js  ❌ — stale, manual update required
  T+30m Checkout realizes v1.2.4 introduced a regression, needs rollback
  T+30m Only option: rebuild host AND remote — 15 min downtime
The host's build config couples it to the remote's deployment. Independent deployability — the entire point of the architecture — is broken.

1.2 The Manifest Solution

Module Federation 2.0 introduces a manifest.json file as an indirection layer between the host and the remote's actual chunk URL:
Host config → manifest URL (stable, never changes)
                    ↓
             manifest.json (changes with every remote deploy)
                    ↓
             actual chunk URL (versioned, immutable)
javascript
// shell/rspack.config.js — host uses a stable manifest URL, not a chunk URL
remotes: {
  checkout: {
    type: 'module',
    name: 'checkout',
    // This URL never changes — it always points to the latest manifest
    entry: 'https://cdn.example.com/checkout/mf-manifest.json',
  },
}
The manifest file is what changes with every deployment:
json
// https://cdn.example.com/checkout/mf-manifest.json
// Updated by Checkout CI on every deploy — 30-second CDN file update
{
  "id": "checkout",
  "name": "checkout",
  "version": "1.2.5",
  "remoteEntry": {
    "name": "remoteEntry",
    "path": "./",
    "type": "module"
  },
  "shared": [
    { "id": "checkout:react", "name": "react", "version": "18.2.0" }
  ],
  "exposes": [
    { "id": "checkout:./Cart", "name": "./Cart", "path": "./Cart" }
  ]
}
To roll back from v1.2.5 to v1.2.3: update mf-manifest.json to point to the v1.2.3 remoteEntry.js. The host loads the new manifest on next page load. No rebuild. No host redeployment.
Flow trace of manifest-based remote resolution lifecycle. Eight stages shown left to right with network and decision nodes. Stage 1 (dim): 'User navigates to /checkout'. Stage 2 (cyan): 'React.lazy() triggers import("checkout/Cart")'. Stage 3 (cyan): 'Federation 2.0 runtime reads remotes config — finds manifest URL'. Stage 4 (amber, network): 'GET https://cdn.example.com/checkout/mf-manifest.json — CDN-cached, ~5ms'. Stage 5 (amber): 'Runtime parses manifest — extracts remoteEntry path and version'. Stage 6 (green): 'Shared dep negotiation using manifest.shared array'. Stage 7 (green, network): 'GET checkout/Cart chunk — URL resolved from manifest'. Stage 8 (green): 'Cart component available — React renders'. Red annotation on Stage 4: 'Rollback = update this file, 30 seconds, no rebuild'. Red annotation on Stage 7: 'Can fail — Error Boundary in Part 5 catches this'. Cyan annotation between Stages 5 and 6: 'Manifest controls version — host never hardcodes remote version'. Caption: 'Manifest-driven resolution decouples the host build from the remote deployment — rollback is a CDN file update, not a rebuild.'
Manifest-driven resolution decouples the host build from the remote deployment — rollback is a CDN file update, not a rebuild.
Before/After comparison of hardcoded remote URLs vs. manifest-driven dynamic resolution. Left panel (Hardcoded URLs) shows host config locked to fixed remote version on CDN; rolling back requires full host rebuild and redeploy taking 15 minutes. Right panel (Manifest-Driven Resolution) shows host referencing stable manifest.json pointer; rolling back takes 30 seconds by updating the CDN manifest pointer file with zero host rebuild. Caption: 'Manifest-driven resolution decouples the host build from remote releases — enabling instant rollbacks without redeploying the host application.'
Manifest-driven resolution decouples the host build from remote releases — enabling instant rollbacks without redeploying the host application.

2. Type Safety Across Boundaries

2.1 The Type Boundary Problem

In a monolith, importing a component gives you full TypeScript safety:
typescript
// ✅ In a monolith — full type information available
import { Cart, CartProps } from './checkout/Cart'
// TypeScript knows CartProps, autocomplete works, refactoring is safe
In a Module Federation setup, the import is dynamic and crosses a deployment boundary:
typescript
// ❌ In MFEs without type sharing — types are unknown
const { Cart } = await import('checkout/Cart')
// TypeScript infers: { Cart: any }
// No autocomplete, no prop validation, no compile-time safety
A developer changes the Cart component's props in the Checkout remote — renames itemCount to cartCount. The host still compiles. CI passes. Production breaks at runtime when itemCount is undefined.

2.2 @module-federation/dts-plugin

The solution is generating and distributing TypeScript declaration files (.d.ts) as part of the remote's build:
javascript
// checkout/rspack.config.js — remote side
const { ModuleFederationPlugin } = require('@module-federation/enhanced/rspack')
const { DtsPlugin } = require('@module-federation/dts-plugin')

module.exports = {
  plugins: [
    new ModuleFederationPlugin({ name: 'checkout', /* ... */ }),
    new DtsPlugin({
      generateTypes: {
        // Generates .d.ts files for all exposed modules
        // Uploads them to a CDN path alongside the remoteEntry
        generateAPITypes: true,
        extractThirdParty: true,
      },
    }),
  ],
}
javascript
// shell/rspack.config.js — host side
const { DtsPlugin } = require('@module-federation/dts-plugin')

module.exports = {
  plugins: [
    new ModuleFederationPlugin({ /* ... */ }),
    new DtsPlugin({
      consumeTypes: {
        // Downloads .d.ts files from each remote during host build
        remoteTypesFolder: './@mf-types',
      },
    }),
  ],
}
After running the host build, TypeScript declarations are available:
typescript
// Now type-safe — .d.ts downloaded from checkout remote's CDN
import Cart from 'checkout/Cart'
// TypeScript knows: Cart: React.FC<{ cartCount: number; onCheckout: () => void }>
// If Checkout renames cartCount → itemCount without updating host: build error ✅
Crucial Requirement
@module-federation/dts-plugin type bundles are build-time integration tests. A prop rename in a remote that breaks a host consumer will fail the host's tsc compilation — catching the contract violation before deployment, not after. Treat dts errors as CI blockers, not warnings.
bash
# Add to shell CI pipeline — runs after consumeTypes downloads remote .d.ts files
npx tsc --noEmit
# If any remote changed its exposed API incompatibly: compile error → CI blocked

3. Runtime Plugins: Lifecycle Interception

3.1 The Plugin API

Module Federation 2.0 introduces a runtime plugin API that lets you intercept federation lifecycle events. This is the correct injection point for production concerns like circuit-breaking, telemetry, and A/B routing:
typescript
// shell/src/federation-plugins.ts
import { createRuntimePlugin } from '@module-federation/runtime'

export const CircuitBreakerPlugin = createRuntimePlugin(() => ({
  name: 'circuit-breaker',

  // Fires before the federation runtime fetches a remote module
  beforeRequest(args) {
    const { id, options } = args
    const remote = id.split('/')[0]  // e.g., 'checkout' from 'checkout/Cart'

    if (circuitBreaker.isOpen(remote)) {
      // Return null to signal the consumer to use the fallback
      console.warn(`[CircuitBreaker] ${remote} is unavailable — using fallback`)
      return null
    }
    return args
  },

  // Fires after the remote module resolves — successful or not
  afterResolve(args) {
    const { id, error } = args
    const remote = id.split('/')[0]

    if (error) {
      circuitBreaker.recordFailure(remote)
    } else {
      circuitBreaker.recordSuccess(remote)
    }
    return args
  },
}))
javascript
// shell/rspack.config.js — register the plugin in the federation config
new ModuleFederationPlugin({
  name: 'shell',
  remotes: { /* ... */ },
  runtimePlugins: ['./src/federation-plugins.ts'],
})

3.2 Telemetry Plugin

typescript
export const TelemetryPlugin = createRuntimePlugin(() => ({
  name: 'telemetry',

  beforeRequest(args) {
    const startTime = performance.now()
    // Store on args for afterResolve to access
    args._startTime = startTime
    return args
  },

  afterResolve(args) {
    const duration = performance.now() - args._startTime
    const remote = args.id.split('/')[0]
    const version = getRemoteVersion(remote)  // read from manifest

    // Report to your observability platform
    analytics.track('remote_load', {
      remote,
      version,
      duration_ms: Math.round(duration),
      success: !args.error,
    })
    return args
  },
}))
Pro Tip & Optimization
Never put circuit-breaking logic inside try/catch blocks in your React components. Circuit-breaking is a cross-cutting concern that belongs in the federation runtime, not in application code. Runtime plugins are architecturally correct injection point — they intercept all remote loads uniformly, regardless of which component triggered the import.

3.3 A/B Routing Plugin

typescript
export const ABRoutingPlugin = createRuntimePlugin(() => ({
  name: 'ab-routing',

  beforeRequest(args) {
    // Route 10% of users to checkout-v2 remote
    if (args.id.startsWith('checkout/') && getABGroup() === 'treatment') {
      return {
        ...args,
        // Override the manifest URL for this request
        options: {
          ...args.options,
          entry: 'https://cdn.example.com/checkout-v2/mf-manifest.json',
        },
      }
    }
    return args
  },
}))

4. Decentralized Routing

4.1 The Routing Ownership Problem

In a standard Module Federation setup, the App Shell owns all top-level routes and maps them to remotes. This creates a coupling: when the Checkout team adds a new sub-route (/checkout/gift-cards), they must submit a PR to the shell's routing config and wait for a shell deployment.
Module Federation 2.0's @module-federation/router allows each remote to declare its own route segments:
typescript
// checkout/src/routes.ts — Checkout remote owns its own routes
export const checkoutRoutes = [
  { path: '/checkout',            component: lazy(() => import('./CheckoutPage')) },
  { path: '/checkout/cart',       component: lazy(() => import('./CartPage')) },
  { path: '/checkout/gift-cards', component: lazy(() => import('./GiftCardsPage')) },
  { path: '/checkout/confirm',    component: lazy(() => import('./ConfirmPage')) },
]

// Exposed to the host
// checkout/rspack.config.js
exposes: {
  './routes': './src/routes',
}
typescript
// shell/src/App.tsx — Shell aggregates routes from all remotes
import { lazy } from 'react'

const checkoutRoutes = lazy(() => import('checkout/routes'))
const catalogRoutes  = lazy(() => import('catalog/routes'))

// Shell only knows the top-level prefix — each remote manages its children
<Routes>
  <Route path="/checkout/*" element={<RemoteRoutes routes={checkoutRoutes} />} />
  <Route path="/catalog/*"  element={<RemoteRoutes routes={catalogRoutes} />} />
</Routes>
Performance / Safety Warning
Decentralized routing requires a formal route prefix ownership contract. If Checkout claims /checkout/* and later Catalog tries to claim /checkout/products, you have a conflict with no compile-time detection. Document route prefixes as a formal interface in your architecture decision record and enforce them in code review.

5. Import Maps as an Alternative

5.1 When Import Maps Are Sufficient

Browser-native <script type="importmap"> allows you to remap bare module specifiers to URLs without any bundler plugin:
html
<!-- index.html -->
<script type="importmap">
{
  "imports": {
    "react":     "https://cdn.skypack.dev/react@18.2.0",
    "react-dom": "https://cdn.skypack.dev/react-dom@18.2.0",
    "checkout":  "https://cdn.example.com/checkout/checkout.js"
  }
}
</script>
Import Maps are baseline-available across all modern browsers as of 2024 and require zero tooling configuration. They are the correct choice when:
  • Your team count is small (2–3 remotes)
  • Your remotes are stable and infrequently updated
  • You do not need shared dependency negotiation

5.2 Where Import Maps Fall Short

CapabilityModule Federation 2.0Import Maps
Shared dependency negotiation (singleton)✅ Runtime scope negotiation❌ Every consumer loads its own copy
TypeScript type sharingdts-plugin❌ Not supported
Runtime plugins (circuit-breaking, telemetry)✅ Plugin API❌ Not supported
Dynamic manifest resolutionmf-manifest.json❌ Map is static in HTML
Lazy loading on demand
Zero tooling requirement❌ Requires MF plugin✅ Pure HTML
Import Maps cannot share a singleton React instance across multiple ESM modules. If both your shell and your checkout remote import react from the CDN, they get two separate instances — the bug from Part 2 returns. For any system where remotes consume React context or hooks from the host, Import Maps are not viable.

Summary

ConceptRule
Hardcoded remote URLsCouple host build to remote deployment — use manifests instead
mf-manifest.jsonStable pointer file; update it to deploy, update it to rollback
@module-federation/dts-pluginType-safe cross-remote imports; dts errors are CI blockers
Runtime pluginsCircuit-breaking, telemetry, A/B routing — inject here, not in application code
Decentralized routingEach remote owns its route prefix; document ownership formally
Import MapsViable for small, stable remote sets; cannot share singleton deps

What's Next

In Part 5, we build the App Shell — the only component in the system with global authority. We define its responsibilities precisely (layout, routing, error containment), draw the exact Error Boundary topology that prevents a remote crash from taking down the shell, and implement the circuit-breaker pattern using the runtime plugins from this article. Part 5 → The App Shell: Container Architecture, Routing, and Error Boundaries

References

  1. @module-federation/enhanced — GitHub
  2. @module-federation/dts-plugin — Documentation
  3. Module Federation 2.0 — Runtime Plugins
  4. Import Maps — MDN
  5. Import Maps — Browser Compatibility
  6. @module-federation/manifest — Documentation
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Module Federation 2.0#Rspack#Dynamic Remotes#TypeScript#CDN#Micro-Frontends
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.