InkdownInkdown
Start writing

Frontend Concepts Low Level

2 files·0 subfolders

Shared Workspace

Frontend Concepts Low Level
micro-frontend-architecture.md

micro-frontend-architecture

Shared from "Frontend Concepts Low Level" on Inkdown

Micro Frontend Architecture — Complete Deep Dive

Table of Contents

  1. What Micro Frontends Are
  2. The Mental Model
  3. Why Micro Frontends Exist
  4. The Six Integration Strategies
  5. Module Federation — Deep Dive
  6. Communication Between Micro Frontends
  7. Routing Strategies
  8. Styling Isolation
pagination.md
  • Shared Dependencies — The Version Problem
  • Frameworks and Tools
  • Deployment Architecture
  • Build and CI/CD Pipeline
  • Testing Strategies
  • Trade-offs — When to Use, When NOT to Use
  • Real-World Examples
  • Complete Comparison of All Approaches
  • Key Takeaways

  • 1. What Micro Frontends Are

    Micro frontends extend the microservices philosophy to the browser. Instead of one monolithic frontend app, you have multiple independent frontend apps composed into a single user experience.

    Simple Analogy

    Think of a newspaper:

    • Monolith = One giant article written by one person. Everyone waits for that person.
    • Micro frontends = Different sections (sports, politics, weather) written by different teams, assembled into one paper.

    Each section is independent. The sports team doesn't wait for the weather team. They publish on their own schedule. But the reader sees one cohesive newspaper.

    The Technical Definition

    A micro frontend architecture consists of:

    • A shell (host/container) — the main app that provides the page skeleton
    • Multiple micro frontends (remotes/fragments) — independent apps that own specific features
    • An integration mechanism — how the shell loads and composes the micro frontends

    2. The Mental Model

    Plain text
    ┌─────────────────────────────────────────────────────────┐
    │                    Shell / Host App                      │
    │                                                         │
    │  ┌──────────┐  ┌───────────┐  ┌──────────────────────┐ │
    │  │  Header   │  │   Nav     │  │     Footer           │ │
    │  │  (Team A) │  │  (Team A) │  │     (Team A)         │ │
    │  └──────────┘  └───────────┘  └──────────────────────┘ │
    │                                                         │
    │  ┌─────────────────────┐  ┌──────────────────────────┐ │
    │  │                     │  │                          │ │
    │  │   Product Listing   │  │    Recommendations       │ │
    │  │     (Team B)        │  │       (Team C)           │ │
    │  │                     │  │                          │ │
    │  └─────────────────────┘  └──────────────────────────┘ │
    │                                                         │
    │  ┌─────────────────────────────────────────────────────┐│
    │  │                  Shopping Cart                       ││
    │  │                    (Team D)                          ││
    │  └─────────────────────────────────────────────────────┘│
    └─────────────────────────────────────────────────────────┘

    Each box is a separate app, built, tested, and deployed independently. The shell orchestrates loading and composing them. The user sees a single, unified page.

    Another Way to Visualize
    Plain text
    User sees:          What's actually happening:
    
    ┌──────────────┐    ┌──────────────────────────────────┐
    │ Unified Page │    │ Shell                            │
    │              │    │  ├─ Header App     (Team A, v2.3)│
    │ Header       │    │  ├─ Product App    (Team B, v1.7)│
    │ Products     │ =  │  ├─ Recommend App  (Team C, v3.1)│
    │ Recommend    │    │  └─ Cart App       (Team D, v2.0)│
    │ Cart         │    │                                  │
    └──────────────┘    └──────────────────────────────────┘

    3. Why Micro Frontends Exist

    The Monolith Pain

    A large frontend monolith becomes painful when:

    ProblemWhat Happens
    Multiple teams, one codebaseMerge conflicts, blocked deploys, "who owns this component?"
    One tech stack locks everyoneTeams can't choose what works best for their domain
    Single deploy bottleneckOne team's change blocks everyone else's release
    Growing build times10-minute builds kill productivity
    Fear of breaking thingsChanging a shared component might break 50 pages
    Big-bang rewrites"Let's rewrite the whole app" → 2-year project that fails
    The Promise of Micro Frontends
    • Independent deployment — Team A deploys their piece without affecting Team B
    • Tech stack freedom — Team A uses React, Team B uses Vue (controversial, more on this later)
    • Team autonomy — Each team owns their feature end-to-end (frontend + backend + deploy)
    • Incremental upgrades — Migrate piece by piece instead of big-bang rewrites
    • Fault isolation — If Team B's widget crashes, the rest of the page still works
    • Smaller bundles per team — Each team only thinks about their own code
    When the Pain Justifies the Complexity

    Micro frontends are NOT for every project. They solve organizational scaling problems, not technical ones. If you have 2-3 frontend developers on one product, a monolith is fine. If you have 10+ teams building one user-facing product, micro frontends start making sense.


    4. The Six Integration Strategies

    There are fundamentally different ways to compose micro frontends. They range from dead-simple to architecturally complex.


    Strategy 1: Iframe (The OG Approach)

    The simplest — just embed apps in iframes.

    Html
    <iframe src="https://team-b-app.internal.com/product-listing" />
    How It Works
    • Each micro frontend is a full page served from its own domain/subdomain
    • The shell positions iframes where each piece should appear
    • Browser naturally isolates JS, CSS, cookies, everything
    Plain text
    ┌─────────────────────────────────────┐
    │ Shell (main-app.com)                │
    │                                     │
    │  ┌─────────────────────────────┐    │
    │  │ iframe: team-b.com/listing  │    │
    │  │ (completely isolated world) │    │
    │  └─────────────────────────────┘    │
    │                                     │
    │  ┌─────────────────────────────┐    │
    │  │ iframe: team-c.com/recommend│    │
    │  │ (another isolated world)    │    │
    │  └─────────────────────────────┘    │
    └─────────────────────────────────────┘
    Pros
    • Perfect isolation — JS, CSS, globals can't leak between iframes
    • Zero coupling between apps
    • Trivially simple to implement
    • Each team can use anything — doesn't even need to be JavaScript
    Cons
    • iframe has its own document → no shared DOM, no shared events easily
    • Nested scrollbars, sizing issues (iframes don't auto-resize to content)
    • URL/routing is painful — each iframe has its own URL, browser back button breaks
    • SEO is garbage — search engines don't crawl iframes well
    • Performance overhead — each iframe = separate rendering context, separate JS engine context
    • postMessage needed for communication (clunky, string-only)
    • Mobile UX problems — scroll hijacking, touch event issues
    When to Use

    Legacy integration, embedding third-party content (payment widgets, maps), quick prototypes. Not for serious micro frontend architecture.

    Real-World Example

    Stripe's payment elements used to be iframes. So do many ad embeds. This is fine for isolated widgets but not for building a full page.


    Strategy 2: Build-Time Integration (NPM Packages)

    Each team publishes their components as npm packages. The host app installs and imports them.

    Plain text
    Team B publishes:  @team-b/product-listing  →  npm package
    Team C publishes:  @team-c/recommendations  →  npm package
    Team D publishes:  @team-d/shopping-cart    →  npm package
    JavaScript
    // Host app's package.json
    {
      "dependencies": {
        "@team-b/product-listing": "^1.7.0",
        "@team-c/recommendations": "^3.1.0",
        "@team-d/shopping-cart": "^2.0.0"
      }
    }
    
    // Host app's code
    import { ProductListing } from '@team-b/product-listing';
    import { Recommendations } from '@team-c/recommendations';
    import { ShoppingCart } from '@team-d/shopping-cart';
    
    function ShopPage() {
      return (
        <div>
          <ProductListing categoryId="shoes" />
          <Recommendations userId={currentUser.id} />
          <ShoppingCart />
        </div>
      );
    }
    How It Works
    Plain text
    Team B repo          Team C repo          Team D repo
         │                    │                    │
         ▼                    ▼                    ▼
      npm publish         npm publish         npm publish
         │                    │                    │
         └────────────────────┼────────────────────┘
                              │
                              ▼
                       Host app's package.json
                              │
                         npm install
                              │
                              ▼
                       Single webpack build
                              │
                              ▼
                       One deployable bundle
    Pros
    • Simple mental model — just imports, like any other code
    • Full TypeScript support across boundaries
    • No runtime overhead — everything is bundled together
    • Teams can iterate independently on their packages
    • Easy to test in isolation (just install the package)
    Cons
    • Not truly independent deployment — host must rebuild and redeploy when any package updates
    • Shared dependencies can cause version conflicts (React 18 vs 17)
    • Bundle size grows as you add more micro frontends
    • Build times grow monotonically (it's still one big build)
    • Coordination needed for shared dependencies and design system changes
    The Version Update Problem
    Plain text
    Team B updates @team-b/product-listing from 1.7.0 → 1.8.0
      → Host app must:
        1. Update package.json
        2. Run tests
        3. Rebuild
        4. Redeploy
      
      This means Team B's "independent" update still requires host app involvement.
    When to Use

    Small-to-medium orgs that want code separation and team ownership but don't need truly independent deployment. This is the most common "starter" approach and honestly the right choice for many teams.


    Strategy 3: Script Tag / Runtime Integration

    Each micro frontend is loaded at runtime via <script> tags. The shell dynamically loads scripts and mounts apps.

    Html
    <!-- Shell HTML -->
    <div id="header"></div>
    <div id="product-listing"></div>
    <div id="recommendations"></div>
    
    <!-- Each team deploys their JS to their own CDN -->
    <script src="https://cdn.team-a.com/header.js"></script>
    <script src="https://cdn.team-b.com/product-listing.js"></script>
    <script src="https://cdn.team-c.com/recommendations.js"></script>

    Each micro frontend exposes a global mount/unmount API:

    JavaScript
    // Team B's product-listing.js (deployed to cdn.team-b.com)
    window.ProductListing = {
      mount(container, props) {
        ReactDOM.render(<Listing {...props} />, container);
      },
      unmount(container) {
        ReactDOM.unmountComponentAtNode(container);
      }
    };

    The shell calls these:

    JavaScript
    // Shell
    window.ProductListing.mount(
      document.getElementById('product-listing'),
      { categoryId: 'shoes', userId: currentUser.id }
    );
    
    // When navigating away:
    window.ProductListing.unmount(
      document.getElementById('product-listing')
    );
    How It Works
    Plain text
    Team B deploys:   cdn.team-b.com/product-listing.js
    Team C deploys:   cdn.team-c.com/recommendations.js
    
    Shell (at runtime):
      1. Fetches scripts from CDNs
      2. Scripts register globals (window.ProductListing, etc.)
      3. Shell calls mount() with props
      4. Shell calls unmount() when navigating away
    The Contract

    Every micro frontend must implement:

    JavaScript
    {
      mount(container: HTMLElement, props: Object) => void,
      unmount(container: HTMLElement) => void,
      // Optional:
      update?(props: Object) => void,  // for prop updates without unmount/remount
    }
    Pros
    • True independent deployment — each team deploys to their own CDN
    • Shell doesn't need to rebuild when a team updates
    • Natural code splitting (each app is a separate file)
    • Simple to understand
    Cons
    • Global namespace pollution (window.XXX)
    • No TypeScript types across boundaries (or very limited)
    • Need a contract/agreement on the mount/unmount API
    • CSS can leak between apps (no isolation)
    • Shared dependencies must be carefully managed (who loads React?)
    • No native import/export between apps
    • Error in one script can block loading of others
    Handling Shared Dependencies
    Html
    <!-- Shell loads React ONCE -->
    <script src="https://cdn.reactjs.org/react@18.2.0/umd/react.production.min.js"></script>
    <script src="https://cdn.reactjs.org/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
    
    <!-- Each MFE uses window.React (externals in webpack) -->
    <!-- Team B's webpack config: -->
    <!-- externals: { react: 'React', 'react-dom': 'ReactDOM' } -->
    When to Use

    Medium orgs that need independent deployment but don't want the complexity of Module Federation. Also used in legacy modernization where you're gradually migrating from monolith.


    Strategy 4: Web Components

    Each micro frontend is a custom element (Web Component). The shell just uses HTML tags.

    JavaScript
    // Team B registers a custom element
    class ProductListing extends HTMLElement {
      connectedCallback() {
        // Read attributes (props)
        const categoryId = this.getAttribute('category-id');
        const userId = this.getAttribute('user-id');
        
        // Option A: Render with Shadow DOM (CSS isolation)
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `<div class="listing">...</div>`;
        
        // Option B: Mount React/Vue into shadow DOM
        ReactDOM.render(
          <Listing categoryId={categoryId} userId={userId} />,
          this.shadowRoot
        );
      }
      
      disconnectedCallback() {
        // Cleanup
        ReactDOM.unmountComponentAtNode(this.shadowRoot);
      }
      
      // Optional: react to attribute changes
      attributeChangedCallback(name, oldValue, newValue) {
        if (name === 'category-id') {
          // Re-render with new category
        }
      }
      
      static get observedAttributes() {
        return ['category-id', 'user-id'];
      }
    }
    
    customElements.define('product-listing', ProductListing);
    Html
    <!-- Shell just uses HTML — framework agnostic! -->
    <product-listing category-id="shoes" user-id="123"></product-listing>
    <recommendations-widget user-id="123"></recommendations-widget>
    <shopping-cart user-id="123"></shopping-cart>
    How It Works
    Plain text
    Each MFE registers a custom element:
      customElements.define('product-listing', ProductListing)
    
    Shell uses them like native HTML:
      <product-listing category-id="shoes"></product-listing>
    
    Browser handles lifecycle:
      - Element appears in DOM → connectedCallback() fires → MFE mounts
      - Element removed from DOM → disconnectedCallback() fires → MFE unmounts
      - Attribute changes → attributeChangedCallback() fires → MFE updates
    Props via Attributes and Events
    JavaScript
    // Passing data IN (attributes — strings only!)
    <product-listing category-id="shoes" user-id="123"></product-listing>
    
    // For complex data, use properties:
    const el = document.querySelector('product-listing');
    el.filters = { priceRange: [0, 100], brands: ['Nike', 'Adidas'] };
    
    // Emitting data OUT (custom events)
    class ProductListing extends HTMLElement {
      handleAddToCart(product) {
        this.dispatchEvent(new CustomEvent('add-to-cart', {
          detail: { product },
          bubbles: true,  // bubble up through DOM
          composed: true,  // cross shadow DOM boundary
        }));
      }
    }
    
    // Shell or other MFes listen:
    document.querySelector('product-listing')
      .addEventListener('add-to-cart', (e) => {
        cart.addItem(e.detail.product);
      });
    Pros
    • Framework agnostic — custom elements are a browser standard
    • Shadow DOM provides CSS isolation — styles don't leak in or out
    • Works with any framework or no framework
    • Natural HTML composition
    • Can pass data via attributes and listen to custom events
    • Progressive enhancement — works even if JS is slow to load
    Cons
    • Shadow DOM is leaky — modals, portals, tooltips break out of shadow root
    • React doesn't play well with Web Components (event system mismatch, synthetic events don't bubble through shadow DOM)
    • Shadow DOM has performance overhead — separate style calculation, layout
    • No SSR story for many frameworks + Web Components (React SSR with shadow DOM is painful)
    • Attributes can only be strings — need serialization for complex data
    • Accessibility issues — screen readers have trouble with shadow DOM
    • Still need a way to load the scripts that define the custom elements
    • Date inputs, form integration — native form elements don't work inside shadow DOM
    The React + Web Components Problem

    React's event system uses synthetic events attached to the root. Shadow DOM creates a separate DOM tree. React's events don't naturally cross the shadow boundary. This means:

    • onChange, onInput don't fire for shadow DOM inputs
    • Event bubbling stops at shadow boundary (unless composed: true)
    • React's ref doesn't work across shadow boundaries
    • Portals render outside shadow DOM

    You can work around these, but it's friction.

    When to Use

    When you need maximum framework flexibility and teams use different stacks. Works best when:

    • You can tolerate DX friction
    • You don't need React-specific features crossing boundaries
    • CSS isolation is a high priority
    • You're building a design system that must work across frameworks

    Strategy 5: Module Federation (Webpack 5 / Rspack)

    This is the most sophisticated and widely adopted modern approach. Webpack 5 introduced Module Federation — it lets you configure webpack to expose and consume modules across separate builds at runtime.

    This deserves its own deep dive section below.


    Strategy 6: Server-Side Composition

    The composition happens on the server before sending HTML to the browser.

    Technique A: Edge Side Includes (ESI)
    Html
    <!-- Main page template served by CDN/edge -->
    <html>
      <body>
        <esi:include src="https://team-a.com/header" />
        <esi:include src="https://team-b.com/product-listing?category=shoes" />
        <esi:include src="https://team-c.com/recommendations" />
      </body>
    </html>

    The CDN/edge server (Akamai, Fastly, Cloudflare Workers) fetches each fragment, assembles the page, and sends complete HTML to the browser.

    Plain text
    Browser → CDN/Edge Server
                  │
                  ├── fetch team-a.com/header → HTML fragment
                  ├── fetch team-b.com/listing → HTML fragment
                  └── fetch team-c.com/recommend → HTML fragment
                  │
                  ▼
             Assemble complete HTML
                  │
                  ▼
             Send to browser (fully rendered)
    Technique B: Node.js Server Composition
    JavaScript
    // Express server acts as composition layer
    app.get('/shop', async (req, res) => {
      const [header, listing, recommendations] = await Promise.all([
        fetch('http://team-a-service/header'),
        fetch('http://team-b-service/product-listing'),
        fetch('http://team-c-service/recommendations'),
      ]);
    
      res.send(`
        <html>
          <head>
            <link rel="stylesheet" href="/shared-styles.css" />
          </head>
          <body>
            ${await header.text()}
            ${await listing.text()}
            ${await recommendations.text()}
            <script src="/hydration.js"></script>
          </body>
        </html>
      `);
    });
    Technique C: Streaming SSR (Modern Approach)
    JavaScript
    // Using React's streaming render + Suspense
    app.get('/shop', async (req, res) => {
      const { pipe } = renderToPipeableStream(
        <ShopPage />,
        {
          onShellReady() {
            res.statusCode = 200;
            pipe(res);  // Start streaming immediately
          }
        }
      );
    });
    
    // Each section is a Suspense boundary that resolves independently
    function ShopPage() {
      return (
        <div>
          <Suspense fallback={<HeaderSkeleton />}>
            <Header />       {/* Resolves fast */}
          </Suspense>
          <Suspense fallback={<ListingSkeleton />}>
            <ProductListing /> {/* May take longer */}
          </Suspense>
          <Suspense fallback={<RecSkeleton />}>
            <Recommendations /> {/* Can be slow */}
          </Suspense>
        </div>
      );
    }

    This streams HTML as each section becomes ready. The browser starts rendering immediately.

    Pros
    • Complete HTML on first paint — great for SEO
    • Fast initial load — no client-side JS needed for composition
    • Works for users with JS disabled
    • Each team deploys their server independently
    • Streaming SSR gives best possible Time to First Byte (TTFB)
    Cons
    • Server-side complexity — need a composition layer (CDN config, Node server, etc.)
    • Latency: server must wait for all fragments (unless streaming)
    • Interactivity still needs client-side hydration — it's not purely server-side
    • Harder to do client-side routing/navigation — full page reloads or complex hydration
    • Infrastructure overhead — multiple services, service mesh, health checks, etc.
    • If one fragment is slow, it can block the entire page (without streaming)
    When to Use

    Content-heavy sites where SEO and initial load performance are critical. E-commerce product pages are the classic use case. Zalando, Amazon, and most large e-commerce sites use server-side composition for their product pages.


    5. Module Federation — Deep Dive

    Module Federation is the most powerful and widely adopted approach for modern micro frontends. It deserves special attention.

    The Core Idea

    Module Federation lets you configure webpack (or Rspack) so that:

    • App A can expose some of its modules for other apps to consume
    • App B can consume those modules as if they were local imports
    • The modules are loaded at runtime from App A's CDN
    • Shared dependencies (like React) are negotiated and deduplicated
    The Setup
    Remote App (Team B — provides a module)
    JavaScript
    // team-b/webpack.config.js
    const { ModuleFederationPlugin } = require('webpack').container;
    
    module.exports = {
      // ... other webpack config
      plugins: [
        new ModuleFederationPlugin({
          name: 'productListing',           // unique name for this remote
          filename: 'remoteEntry.js',       // the generated entry file
          exposes: {
            // Map of exposed module names to local file paths
            './ProductListing': './src/ProductListing',
            './ProductDetail': './src/ProductDetail',
            './useProduct': './src/hooks/useProduct',
          },
          shared: {
            // Dependencies that can be shared with the host
            react: {
              singleton: true,              // only ONE copy in the page
              requiredVersion: '^18.0.0',   // version constraint
              eager: false,                 // lazy load (remote)
            },
            'react-dom': {
              singleton: true,
              requiredVersion: '^18.0.0',
              eager: false,
            },
            '@org/design-system': {
              singleton: true,
              requiredVersion: '^2.0.0',
            },
          },
        }),
      ],
    };
    Host App (Shell — consumes remote modules)
    JavaScript
    // shell/webpack.config.js
    const { ModuleFederationPlugin } = require('webpack').container;
    
    module.exports = {
      plugins: [
        new ModuleFederationPlugin({
          name: 'shell',
          remotes: {
            // Map of remote names to their entry points
            productListing: 'productListing@https://cdn.team-b.com/remoteEntry.js',
            recommendations: 'recommendations@https://cdn.team-c.com/remoteEntry.js',
            cart: 'cart@https://cdn.team-d.com/remoteEntry.js',
          },
          shared: {
            react: {
              singleton: true,
              requiredVersion: '^18.0.0',
              eager: true,   // HOST loads React eagerly (provides it)
            },
            'react-dom': {
              singleton: true,
              requiredVersion: '^18.0.0',
              eager: true,
            },
          },
        }),
      ],
    };
    Consuming Remote Modules in Code
    JavaScript
    // shell/src/App.js
    import React, { lazy, Suspense } from 'react';
    
    // Import remote modules — looks like a normal import!
    const ProductListing = lazy(() => import('productListing/ProductListing'));
    const ProductDetail = lazy(() => import('productListing/ProductDetail'));
    const Recommendations = lazy(() => import('recommendations/Recommendations'));
    const ShoppingCart = lazy(() => import('cart/ShoppingCart'));
    
    function App() {
      return (
        <div>
          <Header />
          <Suspense fallback={<PageSkeleton />}>
            <Routes>
              <Route path="/shop" element={<ProductListing />} />
              <Route path="/shop/:id" element={<ProductDetail />} />
              <Route path="/cart" element={<ShoppingCart />} />
            </Routes>
            <Recommendations />
          </Suspense>
        </div>
      );
    }
    What Happens Under the Hood

    This is the most important part to understand deeply.

    Plain text
    Step 1: Shell loads in browser
             │
             ▼
    Step 2: Shell's webpack runtime fetches remote entry files:
             - GET https://cdn.team-b.com/remoteEntry.js
             - GET https://cdn.team-c.com/remoteEntry.js
             │
             ▼
    Step 3: Each remoteEntry.js tells the shell:
             "I am 'productListing'. I expose:
              - ./ProductListing (chunk: 123.js, 45KB)
              - ./ProductDetail (chunk: 456.js, 32KB)
              I need: react (^18), react-dom (^18)"
             │
             ▼
    Step 4: Version negotiation happens:
             Shell has: react 18.2.0
             Remote wants: react ^18.0.0
             → Compatible! Use shell's react (singleton)
             │
             ▼
    Step 5: User navigates to /shop
             Shell calls: import('productListing/ProductListing')
             │
             ▼
    Step 6: Webpack runtime fetches chunk 123.js from cdn.team-b.com
             │
             ▼
    Step 7: Component renders normally — as if it was a local import
    The shared Configuration — Critical Detail

    The shared config is where most bugs happen. Let's break it down:

    JavaScript
    shared: {
      react: {
        singleton: true,          // CRITICAL: React breaks with multiple instances
        requiredVersion: '^18',   // Semver range for compatibility check
        eager: true,              // true on host, false on remote
      },
    }
    singleton: true

    Ensures only one copy of the module exists in the page. If host has React 18.2 and remote wants React 18.x, they share the host's copy. If singleton: false, each app might load its own copy → two React instances → hooks break.

    requiredVersion: '^18'

    Semver range. If the version available doesn't satisfy this range, webpack loads a separate copy (fallback). This is the safety net.

    eager: true vs eager: false
    Plain text
    Host (shell):
      eager: true → React is included in the host's initial bundle
                    The host "provides" React to all remotes
    
    Remote:
      eager: false → React is NOT in the remote's initial bundle
                     The remote tries to use the host's React first
                     If host doesn't have it, falls back to its own
    Version Negotiation Scenarios
    Plain text
    Scenario 1: Compatible versions
      Host: React 18.2.0
      Remote: requiredVersion: '^18.0.0'
      → 18.2.0 satisfies ^18.0.0 → SHARE (one instance)
    
    Scenario 2: Incompatible versions
      Host: React 18.2.0
      Remote: requiredVersion: '^17.0.0'
      → 18.2.0 does NOT satisfy ^17.0.0 → LOAD SEPARATE (two instances!)
      → This will likely break React
    
    Scenario 3: No version specified
      Host: React 18.2.0
      Remote: (no requiredVersion)
      → Always share, regardless of version (dangerous!)
    Bi-Directional Sharing

    Module Federation isn't just host→remote. Remotes can also consume from the host:

    JavaScript
    // Host exposes a shared component
    new ModuleFederationPlugin({
      name: 'shell',
      exposes: {
        './Header': './src/components/Header',
        './useAuth': './src/hooks/useAuth',
        './Button': './src/components/Button',
      },
      // ...
    });
    
    // Remote consumes host's component
    // In Team B's code:
    import Header from 'shell/Header';
    import { useAuth } from 'shell/useAuth';
    
    function ProductListing() {
      const user = useAuth();
      return (
        <div>
          <Header />
          <Listing user={user} />
        </div>
      );
    }
    Dynamic Remotes (Runtime Configuration)

    Instead of hardcoding remote URLs at build time, you can load them dynamically:

    JavaScript
    // Load remote URL from config API at runtime
    async function loadRemote(remoteName, remoteUrl) {
      // Initialize the remote container
      await __federation_method_loadRemote(`${remoteName}@${remoteUrl}`);
      
      // Now you can import from it
      return import(`${remoteName}/Component`);
    }
    
    // Usage
    const Component = await loadRemote(
      'productListing',
      getConfig().teamBUrl  // fetched from config API at runtime
    );

    This lets you change remote URLs without rebuilding the shell — useful for:

    • Environment-specific URLs (staging vs production)
    • A/B testing different versions of a micro frontend
    • Feature flags for micro frontend versions
    Error Handling — When a Remote Fails
    JavaScript
    // React.lazy + Suspense handles loading errors
    const ProductListing = lazy(() => 
      import('productListing/ProductListing')
        .catch(() => {
          // Remote failed to load — show fallback
          return { default: () => <ErrorFallback message="Product listing unavailable" /> };
        })
    );
    
    // Or use an Error Boundary for runtime errors
    class MFEErrorBoundary extends React.Component {
      state = { hasError: false };
      
      static getDerivedStateFromError() {
        return { hasError: true };
      }
      
      render() {
        if (this.state.hasError) {
          return <div>This section is temporarily unavailable.</div>;
        }
        return this.props.children;
      }
    }
    
    // Wrap each micro frontend
    <MFEErrorBoundary>
      <Suspense fallback={<Skeleton />}>
        <ProductListing />
      </Suspense>
    </MFEErrorBoundary>
    Module Federation v2 (Enhanced)

    Webpack's Module Federation has evolved. The enhanced version adds:

    • Type sharing — TypeScript types are shared across boundaries
    • Lifetime management — better control over when remotes load/unload
    • Dev toolbar — visualize loaded remotes and shared modules
    • Bridge — better integration between different bundlers (webpack + Vite)
    Rspack and Vite Support
    • Rspack (Rust-based webpack alternative): Native Module Federation support, much faster builds
    • Vite: Community plugin @originjs/vite-plugin-federation — works but not as battle-tested
    • Rsbuild (build tool by ByteDance): Built-in Module Federation support

    6. Communication Between Micro Frontends

    This is one of the hardest parts. Independent apps need to talk to each other. How?

    Pattern 1: Custom Events (Pub/Sub via DOM)
    JavaScript
    // Team B's product listing emits an event
    window.dispatchEvent(new CustomEvent('product:add-to-cart', {
      detail: { productId: '123', quantity: 1 }
    }));
    
    // Team D's cart listens
    window.addEventListener('product:add-to-cart', (e) => {
      const { productId, quantity } = e.detail;
      addToCart(productId, quantity);
    });
    Analysis

    Pros: Simple, no shared code, browser native, zero dependencies Cons: Hard to debug (events are invisible in component tree), no type safety, event names become implicit contracts, memory leaks if you forget to unsubscribe

    Best Practice — Create a Typed Event Bus Wrapper
    JavaScript
    // @org/event-bus (shared library)
    type EventMap = {
      'product:add-to-cart': { productId: string; quantity: number };
      'cart:updated': { items: CartItem[]; total: number };
      'user:logged-in': { userId: string; name: string };
      'navigation:route-change': { path: string; params: Record<string, string> };
    };
    
    class TypedEventBus {
      emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {
        window.dispatchEvent(new CustomEvent(event, { detail: data }));
      }
    
      on<K extends keyof EventMap>(
        event: K,
        handler: (data: EventMap[K]) => void
      ): () => void {
        const listener = (e: CustomEvent) => handler(e.detail);
        window.addEventListener(event, listener as EventListener);
        return () => window.removeEventListener(event, listener as EventListener);
      }
    }
    
    export const eventBus = new TypedEventBus();
    
    // Usage (fully typed!)
    eventBus.emit('product:add-to-cart', { productId: '123', quantity: 1 });
    eventBus.on('product:add-to-cart', (data) => {
      // data is typed as { productId: string; quantity: number }
    });
    Pattern 2: Shared State Store

    All micro frontends read/write to a common store.

    JavaScript
    // @org/shared-state (shared library)
    import { create } from 'zustand';
    
    export const useCartStore = create((set) => ({
      items: [],
      addItem: (item) => set(state => ({ items: [...state.items, item] })),
      removeItem: (id) => set(state => ({ items: state.items.filter(i => i.id !== id) })),
      total: () => useCartStore.getState().items.reduce((sum, i) => sum + i.price, 0),
    }));
    
    // Team B uses it
    import { useCartStore } from '@org/shared-state';
    function AddToButton({ product }) {
      const addItem = useCartStore(s => s.addItem);
      return <button onClick={() => addItem(product)}>Add to Cart</button>;
    }
    
    // Team D uses it
    function CartBadge() {
      const items = useCartStore(s => s.items);
      return <span>{items.length}</span>;
    }
    The Critical Decision — How Is the Store Shared?
    MethodHowTrade-off
    NPM packageEach MFE installs @org/shared-stateBuild-time coupling, version management
    Module Federation sharedHost provides the store instanceRuntime, requires MF config
    Shell provides via mount propsShell passes store instance to mount()Explicit but verbose
    Global singletonStore on window.__STORE__Quick and dirty, no type safety
    The Singleton Store Problem

    If each MFE bundles its own copy of Zustand/Redux, they each have their own store instance. Updates in one aren't visible in another. You MUST ensure there's exactly one store instance.

    JavaScript
    // Module Federation approach — host provides the store
    // shell/webpack.config.js
    shared: {
      '@org/shared-state': {
        singleton: true,
        eager: true,
      },
    }
    
    // Now all remotes use the same store instance as the host
    Pattern 3: URL as Communication

    Apps communicate through the URL. This is underrated and often the simplest solution.

    Plain text
    /shop/shoes?cart=product_123:1,product_456:2
    /products?sort=price&filter=brand:nike&color=red
    • Product listing updates the URL when user filters
    • Cart widget reads the URL and renders accordingly
    • No shared state library needed

    Pros: Shareable state, works with browser back/forward, SEO-friendly, zero coupling Cons: Limited data capacity (URL length ~2000 chars), serialization overhead, not great for large/complex state

    Pattern 4: Callback Props (Parent-Child)

    The shell passes callbacks to micro frontends:

    JavaScript
    // Shell
    <ProductListing
      onAddToCart={(product) => cartWidget.addItem(product)}
      onProductSelect={(id) => navigate(`/product/${id}`)}
      onFilterChange={(filters) => updateFilters(filters)}
    />

    Pros: Explicit, type-safe, easy to reason about Cons: Only works for direct parent-child relationships, shell must know about all events, doesn't scale for cross-cutting concerns

    Pattern 5: Shared BroadcastChannel (Cross-Tab Communication)

    For communication between tabs/windows of the same app:

    JavaScript
    const channel = new BroadcastChannel('micro-frontend-channel');
    
    // Team B's MFE in Tab 1
    channel.postMessage({ type: 'cart:updated', items: [...] });
    
    // Team D's MFE in Tab 2
    channel.onmessage = (event) => {
      if (event.data.type === 'cart:updated') {
        syncCart(event.data.items);
      }
    };
    Summary — Which Pattern When?
    PatternBest ForScale
    Custom Events / Event BusDecoupled communication, any-to-anySmall to large
    Shared State StoreComplex shared state (cart, auth, user)Medium to large
    URLFilter/search state, bookmarkable stateAny
    Callback PropsSimple parent-child communicationSmall
    BroadcastChannelCross-tab syncSpecialized

    Real-world apps use a combination. URL for navigation/filter state, shared store for cart/auth, event bus for cross-cutting events.


    7. Routing Strategies

    Routing in micro frontends is tricky because you have multiple apps that each might want to control the URL.

    Strategy A: Shell Owns All Routes
    JavaScript
    // Shell — single source of truth for routing
    const routes = [
      { path: '/', element: <HomePage /> },
      { path: '/shop', element: <ProductListing /> },        // Team B
      { path: '/shop/:id', element: <ProductDetail /> },     // Team B
      { path: '/cart', element: <ShoppingCart /> },          // Team D
      { path: '/account', element: <AccountPage /> },        // Team E
      { path: '/account/orders', element: <OrderHistory /> },// Team E
    ];

    Each micro frontend doesn't know about routing — the shell decides what to render where.

    Pros: Simple, centralized control, easy to reason about Cons: Shell becomes a bottleneck, must be updated for every new route, teams can't add routes autonomously

    Strategy B: Each Micro Frontend Owns Its Routes
    JavaScript
    // Shell delegates entire subtrees
    const routes = [
      { path: '/', element: <HomePage /> },
      { path: '/shop/*', element: <ShopMicroFrontend /> },    // Team B handles all /shop/*
      { path: '/cart', element: <ShoppingCart /> },           // Team D
      { path: '/account/*', element: <AccountMicroFrontend /> }, // Team E handles all /account/*
    ];
    
    // Inside Team B's ShopMicroFrontend:
    // They have their own router for sub-routes:
    // /shop → listing
    // /shop/:id → product detail
    // /shop/:id/reviews → reviews
    // /shop/compare → comparison page

    Pros: Teams control their own routes, shell is thinner, true autonomy Cons: URL structure must be pre-agreed, deep linking needs coordination, debugging routing issues is harder

    Strategy C: Route Registry (Dynamic)

    Micro frontends register their routes with the shell at runtime:

    JavaScript
    // Team B's MFE registers its routes on load
    window.__ROUTE_REGISTRY__.register({
      basePath: '/shop',
      routes: [
        { path: '/', component: 'ProductListing', title: 'Shop' },
        { path: '/:id', component: 'ProductDetail', title: 'Product' },
        { path: '/:id/reviews', component: 'Reviews', title: 'Reviews' },
      ],
      loadComponent: (name) => import(`./pages/${name}`),
    });
    
    // Shell reads all registrations and builds router dynamically
    function buildRouter() {
      const allRoutes = window.__ROUTE_REGISTRY__.getAllRoutes();
      return allRoutes.map(route => ({
        path: route.path,
        element: <Suspense fallback={<Skeleton />}>
          <route.component />
        </Suspense>,
      }));
    }

    Pros: Fully dynamic, no shell redeployment needed for new routes, true plug-and-play Cons: Complex, hard to debug routing issues, no static analysis, route conflicts possible

    Strategy D: Path Prefix per Team

    Each team gets a URL prefix. No coordination needed.

    Plain text
    team-a (layout):  / → handles header, nav, footer for ALL routes
    team-b (shop):    /shop/* → owns everything under /shop
    team-c (account): /account/* → owns everything under /account
    team-d (cart):    /cart → owns the cart page

    The shell is thin — it just matches prefixes and delegates:

    JavaScript
    function App() {
      const path = usePathname();
      
      if (path.startsWith('/shop')) return <ShopMFE />;
      if (path.startsWith('/account')) return <AccountMFE />;
      if (path.startsWith('/cart')) return <CartMFE />;
      return <HomePage />;
    }

    Pros: Zero routing conflicts, simple, teams are fully autonomous Cons: URL structure is rigid, can't have routes that span multiple MFes naturally

    Handling the "Active App" Problem

    When using Strategy B or D, only one MFE is "active" at a time. The active MFE's router handles the URL. The inactive ones should not try to read the URL.

    JavaScript
    // In single-spa, this is handled automatically:
    // - Active MFE gets the URL
    // - Inactive MFes are unmounted
    
    // In Module Federation, you need to handle it:
    function ShopMicroFrontend() {
      const path = usePathname();
      
      // Only render if this MFE is active for the current route
      if (!path.startsWith('/shop')) return null;
      
      return <ShopRouter />;  // Shop's internal router
    }

    8. Styling Isolation

    One of the biggest practical challenges. If Team A uses Tailwind and Team B uses Bootstrap, styles will collide.

    The Problem
    Css
    /* Team A's code */
    .btn { background: blue; }
    
    /* Team B's code */
    .btn { background: red; }
    
    /* Whichever loads last wins → unpredictable styling */
    Approach 1: CSS Modules (Recommended Default)
    Css
    /* Team B's ProductCard.module.css */
    .card { padding: 16px; border: 1px solid #ccc; }
    .title { font-size: 18px; font-weight: bold; }
    JavaScript
    import styles from './ProductCard.module.css';
    
    <div className={styles.card}>
      <h2 className={styles.title}>Product Name</h2>
    </div>
    
    // Compiles to:
    // <div class="ProductCard_card__x7f2a">
    //   <h2 class="ProductCard_title__a3b9c">Product Name</h2>
    // </div>

    Class names are hashed to unique strings. No collisions possible.

    Pros: Simple, zero runtime overhead, great TypeScript support Cons: No style sharing between MFes (each has its own hashed classes)

    Approach 2: CSS-in-JS with Scoped Selectors
    JavaScript
    // Team B uses styled-components
    const Card = styled.div`
      padding: 16px;
      border: 1px solid #ccc;
      
      .title {
        font-size: 18px;
      }
    `;
    
    // Generates: <div class="sc-xyz123"> with scoped CSS

    Pros: Dynamic styles, theming, no class name management Cons: Runtime overhead, larger bundle, SSR complexity, multiple CSS-in-JS libraries = multiple runtime instances

    Approach 3: Shadow DOM

    Web Components with Shadow DOM isolate CSS completely:

    JavaScript
    this.attachShadow({ mode: 'open' });
    // CSS inside shadow DOM doesn't leak out or get affected by outside CSS

    Trade-offs already covered in the Web Components section.

    Approach 4: BEM / Naming Convention Agreement
    Css
    /* Team B always prefixes with their team namespace */
    .team-b-product-card { ... }
    .team-b-product-card__title { ... }
    .team-b-product-card--featured { ... }
    
    /* Team C uses their own namespace */
    .team-c-recommendation-card { ... }
    .team-c-recommendation-card__title { ... }

    Pros: Simple, no build tool needed, works everywhere Cons: Relies on discipline, no enforcement, verbose

    Approach 5: Design Tokens + Shared Component Library

    Instead of fighting CSS, agree on a design system:

    Css
    /* @org/design-tokens (shared package) */
    :root {
      /* Colors */
      --color-primary: #0066ff;
      --color-primary-hover: #0052cc;
      --color-secondary: #6c757d;
      --color-success: #28a745;
      --color-danger: #dc3545;
      
      /* Spacing */
      --spacing-xs: 4px;
      --spacing-sm: 8px;
      --spacing-md: 16px;
      --spacing-lg: 24px;
      --spacing-xl: 32px;
      
      /* Typography */
      --font-body: 'Inter', sans-serif;
      --font-heading: 'Cal Sans', sans-serif;
      --font-size-sm: 14px;
      --font-size-md: 16px;
      --font-size-lg: 20px;
      --font-size-xl: 24px;
      
      /* Border radius */
      --radius-sm: 4px;
      --radius-md: 8px;
      --radius-lg: 16px;
      
      /* Shadows */
      --shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
      --shadow-md: 0 4px 6px rgba(0,0,0,0.1);
    }
    JavaScript
    // @org/ui-components (shared component library)
    export function Button({ variant = 'primary', children, ...props }) {
      return <button className={`btn btn-${variant}`} {...props}>{children}</button>;
    }
    
    // Every team uses the same Button, Card, Input, etc.
    import { Button, Card } from '@org/ui-components';

    Pros: Visual consistency, no CSS collisions, shared language Cons: Design system must be maintained, changes require coordination, some teams may need customizations beyond what the system offers

    The Recommended Approach

    Combine CSS Modules (for isolation) + Design Tokens (for consistency):

    Plain text
    @org/design-tokens  → CSS variables for colors, spacing, typography
    @org/ui-components  → Base components (Button, Input, Modal) built on tokens
    
    Each MFE:
      - Uses CSS Modules for component-specific styles
      - Uses design tokens for consistency
      - Uses shared UI components for common patterns
      - Custom CSS only for unique, team-specific UI

    9. Shared Dependencies — The Version Problem

    The #1 technical challenge in micro frontends.

    The Scenario
    Plain text
    Shell:     React 18.2.0
    Team B:    React 18.3.0
    Team C:    React 17.0.2
    Team D:    React 18.2.0
    What Happens with Each Strategy
    StrategyBehavior
    Build-time (npm)npm/yarn dedupes to one version (usually highest). Team C's React 17 code might break.
    Module FederationShared config negotiates. Team C's requiredVersion: '^17' won't match 18.2 → loads its own copy.
    Script tag / externalsEach app bundles its own. Multiple instances guaranteed.
    IframeComplete isolation. No conflict possible.
    The Singleton Problem

    React, Vue, and most frameworks break with multiple instances.

    Plain text
    If two copies of React exist:
      - Hooks (useState, useEffect) throw errors
      - Context doesn't propagate between instances
      - State gets lost
      - Event handlers break
    Solutions
    Solution 1: Enforce Single Version (Organizational)
    Plain text
    // Organization-level policy
    "All micro frontends must use React ^18.2.0"
    
    // Enforced via:
    // 1. CI checks that scan package.json across all MFE repos
    // 2. Shared package.json template / scaffold
    // 3. Dependency version bot (Dependabot/Renovate) keeps all repos in sync
    Solution 2: Module Federation Shared Config
    JavaScript
    shared: {
      react: {
        singleton: true,         // FORCE one copy
        requiredVersion: '^18.2.0',
        eager: true,             // Host provides it
      },
    }
    // If a remote needs React 17, it's forced to use the host's React 18.2
    // This may break that remote — but at least there's only one instance
    Solution 3: Externals + CDN
    Html
    <!-- Shell loads React once globally -->
    <script src="https://cdn.reactjs.org/react@18.2.0/umd/react.production.min.js"></script>
    <script src="https://cdn.reactjs.org/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
    
    <!-- All MFes use window.React (no bundling) -->
    // In each MFE's webpack config:
    externals: { react: 'React', 'react-dom': 'ReactDOM' }
    Solution 4: Iframe Isolation (Nuclear Option)

    Each MFE has its own document → its own React instance. No conflict. But you lose all benefits of sharing.

    Dependency Compatibility Matrix
    DependencySingleton Required?Why?
    ReactYESHooks and Context break with multiple instances
    React DOMYESTied to React version
    React RouterYESMultiple routers fight over URL
    ReduxDependsMultiple stores = separate state (might be intentional)
    ZustandDependsSame as Redux
    styled-componentsNoEach instance generates its own classes (but duplicates CSS)
    TailwindNoCSS-only, no runtime
    LodashNoPure functions, stateless
    date-fnsNoPure functions, stateless
    AxiosNoEach instance works independently

    10. Frameworks and Tools

    single-spa

    The original micro frontend framework. Framework-agnostic router that orchestrates mounting/unmounting.

    JavaScript
    import { registerApplication, start } from 'single-spa';
    
    // Register a micro frontend
    registerApplication({
      name: 'product-listing',
      // Function that loads the MFE (returns a module with mount/unmount)
      app: () => System.import('//cdn.team-b.com/product-listing.js'),
      // When should this MFE be active?
      activeWhen: ['/shop', '/products'],
      // Props to pass to the MFE
      customProps: { authToken: getToken() },
    });
    
    registerApplication({
      name: 'shopping-cart',
      app: () => System.import('//cdn.team-d.com/cart.js'),
      activeWhen: '/cart',
    });
    
    start();  // Begin routing

    Each MFE must export lifecycle functions:

    JavaScript
    // Team B's product-listing.js
    export async function mount(props) {
      ReactDOM.render(<Listing {...props} />, document.getElementById('container'));
    }
    
    export async function unmount(props) {
      ReactDOM.unmountComponentAtNode(document.getElementById('container'));
    }
    
    export async function bootstrap(props) {
      // One-time initialization (called once before first mount)
    }
    
    export async function update(props) {
      // Called when props change (optional)
    }

    Pros: Mature, framework-agnostic, handles routing and lifecycle, large ecosystem Cons: Doesn't handle shared dependencies, no built-in module sharing, requires SystemJS for script loading, older architecture

    Piral

    Built on top of single-spa. Adds a feed service for discovering micro frontends at runtime.

    Plain text
    ┌───────────────────────────────────────┐
    │   Piral Shell (App Shell)             │
    │   ├── Piral Instance                  │
    │   ├── Feed Service (discovery)        │ ← discovers available MFEs at runtime
    │   ├── Extension System               │ ← MFes can extend shell "slots"
    │   ├── Auth / Notifications / State   │
    │   └── Layout / Routing               │
    └───────────────────────────────────────┘

    How discovery works:

    1. Shell fetches list of available MFEs from a feed service
    2. Feed service returns metadata (name, version, URL, dependencies)
    3. Shell loads and registers them
    4. MFes can also register extensions in named "slots" (e.g., header extensions, dashboard widgets)

    Pros: Full platform with auth, notifications, extensions, tile-based dashboard Cons: Opinionated, heavy, steep learning curve, Piral-specific concepts

    Luigi (by SAP)

    Micro frontend framework with web component support and a built-in development console.

    JavaScript
    // Luigi config
    Luigi.setConfig({
      navigation: {
        nodes: () => [
          {
            pathSegment: 'shop',
            label: 'Shop',
            viewUrl: 'https://team-b.com/shop.html',
          },
        ],
      },
    });

    Pros: Good for enterprise, iframe-based isolation, SAP ecosystem integration Cons: iframe-based (inherits iframe limitations), SAP-centric

    Open Components (oc)

    Server-side rendering focused micro frontend framework. Each component is a small Node.js service that renders HTML.

    Bit

    Component-driven development platform. Each component is independently versioned and shared.

    Bash
    # Team B creates and exports a component
    bit create react product-listing
    bit tag --message "updated filters"
    bit export team-b.components

    Pros: Granular (component-level, not app-level), great DX Cons: More suited for component libraries than full micro frontends

    Comparison Table
    Featuresingle-spaPiralLuigiModule Federation
    Runtime routingYesYesYesNo (host handles)
    Shared depsNoYesNoYes (built-in)
    Framework agnosticYesPartialYesNo (bundler-specific)
    DiscoveryStatic configFeed serviceConfig fileStatic config
    SSR supportLimitedYesIframe onlyWith setup
    ComplexityMediumHighMediumMedium-High
    MaturityHighMediumMediumHigh
    Learning curveMediumSteepLow-MediumSteep
    Best forAny stackFull platformEnterprise/SAPReact/Vue shops

    11. Deployment Architecture

    The Ideal Deployment Flow
    Plain text
    ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
    │  Team A      │     │  Team B      │     │  Team C      │
    │  (Shell)     │     │  (Products)  │     │  (Cart)      │
    │  CI/CD       │     │  CI/CD       │     │  CI/CD       │
    └──────┬──────┘     └──────┬──────┘     └──────┬──────┘
           │                    │                    │
           ▼                    ▼                    ▼
    ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
    │  CDN:        │     │  CDN:        │     │  CDN:        │
    │  shell/      │     │  remoteEntry │     │  remoteEntry │
    │  index.html  │     │  .js + chunks│     │  .js + chunks│
    │  assets/     │     │              │     │              │
    └─────────────┘     └─────────────┘     └─────────────┘
           │
           ▼
      User's browser
    Key Principles
    1. Each team deploys independently — Team B can deploy 5x/day while Team A deploys weekly
    2. CDN-based — all assets served from CDN for performance
    3. Versioned URLs — remoteEntry.v3.js or hash-based (remoteEntry.a1b2c3.js) for cache busting
    4. Shell references remotes by URL — shell knows WHERE to find each remote
    The Version Discovery Problem

    How does the shell know which version of each remote to load?

    Plain text
    Option A: Hardcoded URLs in shell build
      → Shell must rebuild when remote URL changes (defeats independent deployment)
    
    Option B: Config API (recommended)
      → Shell fetches config at runtime:
        GET /api/mfe-config
        → { "productListing": "https://cdn.team-b.com/v3/remoteEntry.js" }
      → Shell loads remotes dynamically
    
    Option C: Well-known URLs
      → Each remote always lives at the same URL
      → CDN cache headers handle versioning
      → remoteEntry.js always returns the latest version
    Rollback Strategy
    Plain text
    Scenario: Team B deploys v4 of product listing, it has a bug.
    
    With independent deployment:
      1. Team B rolls back to v3 on their CDN
      2. Shell doesn't need to change
      3. Users get v3 automatically (CDN serves the rolled-back version)
      4. No other team is affected
    
    This is the power of independent deployment.
    CDN Cache Strategy
    Plain text
    remoteEntry.js     → Cache-Control: no-cache (always check for new version)
    chunk.abc123.js    → Cache-Control: max-age=31536000, immutable (hash = never changes)
    
    The entry file is the "manifest" — it's always fresh.
    The chunks are immutable — once deployed, they never change.

    12. Build and CI/CD Pipeline

    Per-Team Pipeline

    Each team has their own CI/CD pipeline:

    YAML
    # Team B's .github/workflows/deploy.yml
    name: Deploy Product Listing MFE
    
    on:
      push:
        branches: [main]
    
    jobs:
      build-and-deploy:
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
          
          - run: npm ci
          - run: npm run lint
          - run: npm run test
          - run: npm run typecheck
          - run: npm run build
          
          # Deploy to CDN
          - run: aws s3 sync dist/ s3://team-b-cdn/v${{ github.sha }}/
          - run: aws s3 cp s3://team-b-cdn/v${{ github.sha }}/remoteEntry.js s3://team-b-cdn/latest/remoteEntry.js
          
          # Update version registry
          - run: |
              curl -X POST https://config-api.internal/mfe/product-listing \
                -d '{"version": "${{ github.sha }}", "url": "https://cdn.team-b.com/v${{ github.sha }}/remoteEntry.js"}'
    Shared CI Checks

    Organization-level checks that run on ALL MFE repos:

    YAML
    # .github/workflows/shared-checks.yml (reusable workflow)
    # Triggered on every MFE repo
    - Check: React version matches org policy
    - Check: No prohibited dependencies
    - Check: Bundle size within budget
    - Check: Linting passes (shared ESLint config)
    - Check: TypeScript strict mode enabled
    - Check: Accessibility audit passes
    Dependency Update Strategy
    Plain text
    When org updates React from 18.2 → 18.3:
    
    1. Update @org/eslint-config to require React ^18.3
    2. Renovate/Dependabot opens PRs on ALL MFE repos
    3. Each team reviews and merges in their own timeline
    4. Once all MFes are on 18.3, update the shared Module Federation config
    5. Old MFes on 18.2 will still work (version negotiation loads separate copy)
       but it's suboptimal → creates urgency to update

    13. Testing Strategies

    Testing Levels
    Plain text
    ┌─────────────────────────────────────────┐
    │  E2E Tests (across all MFes)            │  ← Test complete user journeys
    │  Cypress / Playwright                   │
    ├─────────────────────────────────────────┤
    │  Integration Tests (MFE + Shell)        │  ← Test MFE works in shell context
    │  Testing Library + MF harness           │
    ├─────────────────────────────────────────┤
    │  Unit Tests (per MFE)                   │  ← Test MFE logic in isolation
    │  Jest / Vitest                          │
    └─────────────────────────────────────────┘
    Unit Testing (Per MFE)

    Each team tests their MFE in isolation:

    JavaScript
    // Team B tests ProductListing independently
    import { render, screen } from '@testing-library/react';
    import ProductListing from './ProductListing';
    
    test('shows products for given category', async () => {
      render(<ProductListing categoryId="shoes" />);
      
      expect(await screen.findByText('Nike Air Max')).toBeInTheDocument();
      expect(screen.getByText('Adidas Ultra Boost')).toBeInTheDocument();
    });
    Integration Testing (MFE + Shell)

    Test that the MFE works correctly when loaded by the shell:

    JavaScript
    // Test harness that simulates the shell
    import { mount } from './lifecycle';
    
    test('ProductListing mounts correctly with shell props', async () => {
      const container = document.createElement('div');
      
      await mount({
        container,
        props: {
          categoryId: 'shoes',
          authToken: 'test-token',
          onAddToCart: jest.fn(),
        },
      });
      
      expect(container.querySelector('.product-listing')).toBeTruthy();
    });
    E2E Testing (Cross-MFE)

    Test complete user journeys that span multiple micro frontends:

    JavaScript
    // Playwright E2E test
    test('user can add product to cart', async ({ page }) => {
      await page.goto('/shop/shoes');
      
      // This exercises Team B's ProductListing MFE
      await page.click('text=Nike Air Max');
      
      // This exercises Team B's ProductDetail MFE
      await page.click('text=Add to Cart');
      
      // This exercises Team D's Cart MFE
      await page.goto('/cart');
      await expect(page.locator('.cart-item')).toContainText('Nike Air Max');
      await expect(page.locator('.cart-total')).toContainText('$129.99');
    });
    Contract Testing

    Test that MFes honor their contracts (mount/unmount API, event bus events, shared state):

    JavaScript
    // Contract test for the event bus
    test('product:add-to-cart event has correct shape', () => {
      const handler = jest.fn();
      eventBus.on('product:add-to-cart', handler);
      
      eventBus.emit('product:add-to-cart', { productId: '123', quantity: 1 });
      
      expect(handler).toHaveBeenCalledWith({
        productId: expect.any(String),
        quantity: expect.any(Number),
      });
    });
    Testing Gotchas
    • Mock remote modules in unit tests (don't try to load real remotes)
    • Use a test harness for integration tests that simulates the shell
    • E2E tests should run against deployed staging environment (not local)
    • Version pin remotes in E2E tests to avoid flaky tests from remote updates
    • Test with remote offline — what happens when Team B's CDN is down?

    14. Trade-offs — When to Use, When NOT to Use

    When Micro Frontends Make Sense
    SignalDescription
    10+ frontend developersMultiple teams stepping on each other's code
    Independent team deploymentTeam A can't wait for Team B's release cycle
    Distinct business domainsShop, Account, Checkout are clearly separable
    Long-lived productYou'll maintain this for years, worth the investment
    Incremental migration neededMoving from monolith to microservices gradually
    Different tech needs per domainCheckout needs high security, Shop needs rich interactivity
    When Micro Frontends Are Overkill
    SignalDescription
    < 5 frontend developersCommunication is easy, monolith is fine
    Single product, single teamNo organizational scaling problem
    Tight coupling between featuresIf everything depends on everything, separation is artificial
    Short-lived projectNot worth the infrastructure investment
    Team doesn't have DevOps maturityMicro frontends need CI/CD, CDN, monitoring
    "We want to use different frameworks"Usually not a good enough reason alone
    The Honest Trade-offs
    BenefitCost
    Independent deploymentMore infrastructure (CDNs, config APIs, monitoring)
    Team autonomyCoordination overhead (contracts, shared libs, design system)
    Tech stack freedomShared dependency hell, inconsistent UX
    Fault isolationMore complex error handling, more failure modes
    Smaller per-team bundlesMore HTTP requests, more complex loading
    Incremental upgradesVersion management across N repos
    The 80/20 Rule

    Most teams get 80% of the benefit from build-time integration (npm packages) with clear team ownership. Only go to runtime integration (Module Federation) when you truly need independent deployment.


    15. Real-World Examples

    Amazon
    • One of the original micro frontend adopters
    • Product page = composed from 100+ server-side rendered fragments
    • Each team (pricing, reviews, recommendations, buy box) owns their fragment
    • Server-side composition (similar to ESI)
    • Each fragment is a separate service
    Zalando
    • Pioneer of the micro frontend concept
    • Published extensively about their approach
    • Uses iframe-based approach for team isolation
    • Each team owns their page section end-to-end
    • Published the "Micro Frontends" book on the topic
    Spotify
    • Desktop app uses micro frontends
    • Different features (player, library, search) are separate modules
    • Uses a custom module system (not Module Federation)
    • Teams deploy independently
    TikTok / ByteDance
    • Uses Module Federation at massive scale
    • Created Rsbuild/Rspack (Rust-based webpack alternative) partly to improve MF build times
    • Multiple consumer apps composed from micro frontends
    Upwork
    • Uses single-spa for their freelancer platform
    • Migrated from AngularJS monolith to micro frontends
    • Each product area (search, proposals, messaging) is a separate MFE
    • Published their migration story
    Shopify
    • Admin panel uses micro frontends (Polaris)
    • Extension system allows third-party developers to add UI to admin
    • Uses Web Components for extensions (framework-agnostic)
    IKEA
    • Uses micro frontends for their e-commerce platform
    • Different markets can customize their storefronts
    • Shared components with market-specific customization

    16. Complete Comparison of All Approaches

    CriteriaIframeNPM PackagesScript TagWeb ComponentsModule FederationServer-Side
    Independent DeployYesNoYesYesYesYes
    JS IsolationPerfectNoneNoneShadow DOMShared scopeN/A
    CSS IsolationPerfectNoneNoneShadow DOMManualManual
    Shared DepsNoYes (deduped)ManualNoAutomaticN/A
    TypeScriptNoFullNoLimitedGoodN/A
    SSRNoYesNoPartialComplexYes (native)
    SEOBadGoodGoodGoodGoodBest
    PerformanceWorstBest (single bundle)GoodGoodGoodBest (SSR)
    DXBadBestMediumMedium-HardMedium-HardMedium
    ComplexityLowLowMediumMediumHighHigh
    Framework LockNoneSame frameworkSame frameworkNoneSame frameworkAny
    MaturityAncientStandardStandardStandardModernStandard
    Best For3rd party embedsSmall orgsMedium orgsMixed stacksLarge orgsContent sites

    17. Key Takeaways

    1. Micro frontends solve organizational problems, not technical ones. If your team is small, use a monolith.

    2. Start with build-time integration (npm packages). Only move to runtime integration when you truly need independent deployment.

    3. Module Federation is the modern standard for runtime micro frontends in React/Vue ecosystems. Understand the shared config deeply.

    4. Communication is the hardest part. Use a typed event bus for decoupled communication and a shared store for complex shared state.

    5. CSS isolation matters. CSS Modules + Design Tokens is the pragmatic sweet spot.

    6. Shared dependencies must be managed carefully. Singleton frameworks (React, Vue) MUST have only one instance.

    7. Deployment independence is the whole point. If you can't deploy independently, you don't have micro frontends — you have a distributed monolith.

    8. Don't mix frameworks unless you have a very good reason. The "framework freedom" promise sounds great but creates enormous operational overhead.

    9. Server-side composition is best for SEO/performance. Client-side composition is best for interactivity. Many production apps use both.

    10. The shell should be thin. If the shell grows large, you've just rebuilt the monolith with extra steps.


    Quick Decision Guide

    Plain text
    "How many frontend teams do you have?"
    ├── 1-3 → Monolith with clear module boundaries
    ├── 4-8 → Build-time micro frontends (npm packages)
    └── 8+  → Runtime micro frontends (Module Federation)
    
    "Do you need independent deployment?"
    ├── No → Build-time integration is enough
    └── Yes → Module Federation or Script Tag approach
    
    "Is SEO critical?"
    ├── Yes → Server-side composition + client hydration
    └── No → Client-side composition is fine
    
    "Do teams use different frameworks?"
    ├── Yes → Web Components or Iframe
    └── No → Module Federation (simpler, better DX)