React Micro-Frontend Architecture: A Practical Guide

A practical guide to building React micro-frontends with Module Federation, routing, shared state, deployment, testing, SSR, performance, and security.

ASOasis
7 min read
React Micro-Frontend Architecture: A Practical Guide

Image used for representation purposes only.

Why micro-frontends—and when to use them

Micro-frontends apply microservice principles to the browser. Instead of one large SPA, you compose multiple independently built, deployed, and owned frontends into a single experience.

Benefits:

  • Independent deployability and faster release cycles
  • Clear domain boundaries aligned to teams
  • Technology isolation (opt-in, not chaos)
  • Resilience: one part can fail without taking down the whole UI

Adopt them when your product, team count, and release cadence outgrow a single-repo SPA. If you have a small team, or most changes touch many pages at once, a well-structured monolith may be simpler.

Composition patterns

  • Client-side composition (most common): A shell app dynamically loads remote UIs at runtime. Great for SPAs and dynamic routing.
  • Server-side/edge composition: The server or edge gateway stitches HTML chunks. Ideal for SSR and first-paint performance.
  • Build-time composition: Remotes compiled together at CI. Simpler caching, but weaker independence.
  • Iframe isolation: Strong security and style isolation; heavier runtime and poor UX integration—use for untrusted or third-party content only.

Technology options for React micro-frontends

  • Webpack Module Federation (MF): Runtime loading of separately deployed bundles; share libraries like React as singletons; production-proven.
  • Import maps + native ESM: Framework-agnostic and simple, but lacks automatic shared dependency negotiation.
  • Single-spa/orchestrators: Route-based composition across frameworks.
  • Web Components: Interop-friendly leaf components; still need a composition strategy for code-splitting and shared deps.

Recommendation for most React SPAs: Module Federation for runtime composition plus conventional React tooling.

Reference architecture (React)

  • Shell (host):
    • Global routing and page frames (header, nav, auth boundary)
    • Runtime loading of remotes (e.g., /catalog, /cart, /account)
    • Cross-cutting concerns: auth tokens, feature flags, error boundary, telemetry
  • Remotes (feature apps):
    • Own build pipeline and repo
    • Expose route-level components and optional widgets
    • Share React/React DOM as singletons
  • Shared libraries:
    • Design system (tokens, components)
    • Utilities (analytics client, HTTP, i18n)
    • Avoid excessive sharing; keep versions compatible via semver

Walkthrough: React + Module Federation

The following shows a minimal host and two remotes (catalog and cart). Use React 18+, Webpack 5.

1) Host webpack config

// host/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;

module.exports = {
  // ...entry, output, loaders
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        // Local dev (static URL). For prod, see dynamic example below.
        catalog: 'catalog@http://localhost:3001/remoteEntry.js',
        cart: 'cart@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: deps.react },
        'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
        'react-router-dom': { singleton: true },
      },
    }),
  ],
};

2) Remote webpack config

// cart/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;

module.exports = {
  // ...entry, output, loaders
  plugins: [
    new ModuleFederationPlugin({
      name: 'cart',
      filename: 'remoteEntry.js',
      exposes: {
        './Routes': './src/routes',
        './MiniCart': './src/components/MiniCart',
      },
      shared: {
        react: { singleton: true, requiredVersion: deps.react },
        'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
      },
    }),
  ],
};

3) Dynamic remotes for production

Keep remote URLs out of the bundle so you can deploy new versions without rebuilding the host.

// host/webpack.config.js (dynamic remotes)
new ModuleFederationPlugin({
  name: 'host',
  remotes: {
    catalog: `promise new Promise(resolve => {
      const url = window.appConfig.remotes.catalog; // set at runtime
      const s = document.createElement('script');
      s.src = url;
      s.onload = () => {
        const proxy = {
          get: req => window.catalog.get(req),
          init: arg => { try { return window.catalog.init(arg); } catch(e) { /* already initialized */ } }
        };
        resolve(proxy);
      };
      document.head.appendChild(s);
    })`,
  },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
});
<!-- host/index.html: runtime config injected by your CDN or server -->
<script>
  window.appConfig = {
    remotes: {
      catalog: 'https://cdn.example.com/catalog/v2.4.1/remoteEntry.js',
      cart: 'https://cdn.example.com/cart/v3.1.0/remoteEntry.js'
    }
  };
</script>

4) Bootstrap and routing in the host

// host/src/App.tsx
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';

const CatalogRoutes = lazy(() => import('catalog/Routes'));
const CartRoutes = lazy(() => import('cart/Routes'));

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading</div>}>
        <Routes>
          <Route path="/catalog/*" element={<CatalogRoutes />} />
          <Route path="/cart/*" element={<CartRoutes />} />
          <Route path="/" element={<Navigate to="/catalog" replace />} />
          <Route path="*" element={<div>404</div>} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

5) TypeScript declarations

// host/src/types/federation.d.ts
declare module 'catalog/Routes';
declare module 'cart/Routes';
// If you reference window.cart, also declare it:
declare global { interface Window { cart: any; catalog: any } }

6) Cross-app communication

Prefer explicit interfaces. A few safe patterns:

  • URL and routing: encode state in URLs, not globals
  • Props/contracts: expose leaf widgets with typed props
  • Event bridge: use DOM CustomEvents for decoupled signaling
// In catalog when a product is added to cart
window.dispatchEvent(new CustomEvent('cart:add', { detail: { sku: 'SKU123', qty: 1 } }));

// In cart remote (mounted once)
useEffect(() => {
  const onAdd = (e: any) => cartApi.add(e.detail.sku, e.detail.qty);
  window.addEventListener('cart:add', onAdd as any);
  return () => window.removeEventListener('cart:add', onAdd as any);
}, []);

For shared state libraries (e.g., Redux, Zustand), avoid coupling: either scope each remote’s store or expose a thin API from the shell.

7) Shared design system

Publish your design system as a versioned package. Make React a peerDependency of the design system to avoid multiple Reacts. Example package.json extract:

{
  "name": "@acme/design-system",
  "version": "1.8.0",
  "peerDependencies": { "react": ">=18", "react-dom": ">=18" }
}

Deployment and versioning

  • Independent pipelines: each remote builds, tests, and publishes its assets to a CDN.
  • Filenames: content-hashed assets for long-term caching; remoteEntry.js can be versioned by path (e.g., /v3.1.0/remoteEntry.js) to enable safe rollbacks.
  • Manifest: the shell reads a runtime manifest (window.appConfig or JSON endpoint) to resolve current remote URLs per environment.
  • Backward compatibility: treat exposed modules as public APIs; follow semver and add deprecation windows.

Rollback strategy:

  • Update the manifest to point to the previous known-good remote version; the shell and other remotes remain untouched.

Testing strategy

  • Unit tests per remote
  • Contract tests for exposed modules and widget props
  • Visual regression on key routes and shared components
  • Integration smoke tests in the shell against production-like remotes
  • Synthetic monitoring: load each remoteEntry periodically and check basic render paths

Observability and error handling

  • Tag telemetry with app and version (e.g., cart@3.1.0 )
  • Propagate correlation IDs (traceparent) in fetch/XHR across remotes
  • Global error boundary in the shell; local error boundaries in remotes
  • Soft-fail UI: if a remote fails to load, show a fallback card with retry and status info
// Example: resilient loader
const RemoteMiniCart = lazy(() => import('cart/MiniCart'));

<ErrorBoundary fallback={<MiniCartFallback />}>
  <Suspense fallback={<MiniCartSkeleton />}>
    <RemoteMiniCart />
  </Suspense>
</ErrorBoundary>

Performance and UX

  • Keep React singleton: prevents duplicate React copies and reduces memory
  • Preload critical remotes with for first-route remotes
  • Code-split within remotes, not only at the remote boundary
  • Share judiciously: oversharing inflates initial vendor bundles; prefer per-remote deps unless there’s a clear win
  • CSS isolation: use CSS Modules or CSS-in-JS with hashed class names; avoid global leakage
  • Image and font strategy remains per-remote but follow shared guidelines (formats, cache headers)

Security considerations

  • Content Security Policy (CSP): restrict script and connect sources to expected CDNs
  • Subresource Integrity (SRI): where feasible, pin integrity for remoteEntry or protect via signed URLs
  • Escape and sanitize: treat cross-remote data like external input
  • Iframe sandboxing: if you host untrusted remotes, sandbox them in iframes and communicate via postMessage
  • Dependency hygiene: track vulnerabilities per remote; small remotes still carry supply-chain risk

SSR and streaming

  • Server/edge composition improves first paint and SEO. Two approaches:
    1. Shell SSR that preloads remoteEntry and renders remote routes via MF on the server
    2. Fragment composition: each remote renders HTML on the server; the shell stitches and streams
  • Keep React versions aligned across SSR boundaries; ensure consistent design-system CSS order
  • Hydration: expose stable component IDs and avoid time-based randomness during SSR

Migration plan (from a monolith)

  1. Identify seams by domain (e.g., Catalog, Cart, Account)
  2. Extract the simplest route as the first remote to validate infra
  3. Introduce the shell and a single dynamic remote with a feature flag
  4. Move shared concerns into a design system and utilities package
  5. Migrate routes gradually (strangler pattern); keep URLs stable
  6. Establish versioning, manifest, and rollback routine before scaling to more teams

Team topology and governance

  • One team per domain/remote; the shell team acts as platform
  • A small cross-team council maintains contracts, design tokens, and release guidelines
  • Define SLAs for remote availability and load budget (kb, async chunks, TTI targets)

Common anti-patterns

  • Over-fragmentation: dozens of tiny remotes increase latency and cognitive load
  • Hidden coupling: sharing mutable singletons for cross-remote state
  • Version drift: React or design-system versions diverge and break SSR/hydration
  • Orchestrator fatigue: reinventing deployment/manifest logic in each team—centralize it once

Production readiness checklist

  • React and React DOM shared as singletons with compatible versions
  • Remote URLs resolved at runtime via environment manifest
  • Error boundaries + graceful fallbacks for each remote zone
  • Telemetry includes app name and version; correlation IDs propagate
  • Contract tests for exposed modules and public props
  • Cache strategy defined (hashing, TTLs, purge process)
  • Security: CSP, dependency scanning, and sanitized inputs

Final thoughts

Micro-frontends are an organizational architecture as much as a technical one. Start with strong domain boundaries, keep interfaces small and explicit, and build robust platform primitives—runtime manifests, observability, and quality gates. With these in place, React micro-frontends can scale teams and codebases without sacrificing user experience.

Related Posts