Steven's Knowledge
Architecture

Micro-Frontends

Breaking monolithic frontends into independent, deployable applications

Micro-Frontends

Micro-frontends extend microservices concepts to the frontend, allowing teams to build and deploy features independently.

When to Use

Good Fit

  • Large teams working on different features
  • Features with different release cycles
  • Gradual migration from legacy systems
  • Different tech stack requirements per team
  • Small teams or simple applications
  • Tight coupling between features
  • Shared state requirements
  • Performance-critical applications

Architecture Approaches

Build-Time Integration

// Package-based micro-frontends
// Each micro-frontend is an npm package

// package.json
{
  "dependencies": {
    "@myorg/header": "^1.0.0",
    "@myorg/dashboard": "^2.0.0",
    "@myorg/settings": "^1.5.0"
  }
}

// App.tsx
import { Header } from '@myorg/header';
import { Dashboard } from '@myorg/dashboard';
import { Settings } from '@myorg/settings';

function App() {
  return (
    <>
      <Header />
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </>
  );
}

Runtime Integration (Module Federation)

// webpack.config.js (Host Application)
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        dashboard: 'dashboard@http://localhost:3001/remoteEntry.js',
        settings: 'settings@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};

// webpack.config.js (Dashboard Micro-frontend)
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'dashboard',
      filename: 'remoteEntry.js',
      exposes: {
        './Dashboard': './src/Dashboard',
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true },
      },
    }),
  ],
};

// Host App - Dynamic import
const Dashboard = lazy(() => import('dashboard/Dashboard'));
const Settings = lazy(() => import('settings/Settings'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

Vite Module Federation

// vite.config.ts (Host)
import federation from '@originjs/vite-plugin-federation';

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: 'host',
      remotes: {
        dashboard: 'http://localhost:3001/assets/remoteEntry.js',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
});

// vite.config.ts (Remote)
export default defineConfig({
  plugins: [
    react(),
    federation({
      name: 'dashboard',
      filename: 'remoteEntry.js',
      exposes: {
        './Dashboard': './src/Dashboard.tsx',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
});

iframe Integration

// Simple but isolated approach
function MicroFrontendIframe({ url, title }: Props) {
  const [height, setHeight] = useState(600);

  useEffect(() => {
    const handleMessage = (event: MessageEvent) => {
      if (event.data.type === 'resize') {
        setHeight(event.data.height);
      }
    };
    window.addEventListener('message', handleMessage);
    return () => window.removeEventListener('message', handleMessage);
  }, []);

  return (
    <iframe
      src={url}
      title={title}
      style={{ width: '100%', height, border: 'none' }}
    />
  );
}

// Communication via postMessage
// Child (micro-frontend)
window.parent.postMessage({ type: 'resize', height: document.body.scrollHeight }, '*');
window.parent.postMessage({ type: 'navigate', path: '/dashboard' }, '*');

// Parent (shell)
window.addEventListener('message', (event) => {
  if (event.data.type === 'navigate') {
    router.push(event.data.path);
  }
});

Web Components

// Micro-frontend as Web Component
class DashboardElement extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    const root = document.createElement('div');
    shadow.appendChild(root);

    // Mount React app
    ReactDOM.createRoot(root).render(<Dashboard />);
  }

  disconnectedCallback() {
    // Cleanup
  }
}

customElements.define('mf-dashboard', DashboardElement);

// Usage in any framework
<mf-dashboard user-id="123"></mf-dashboard>

Communication Patterns

Custom Events

// Dispatch from micro-frontend
function dispatchMicroFrontendEvent(type: string, detail: any) {
  window.dispatchEvent(new CustomEvent(`mf:${type}`, { detail }));
}

dispatchMicroFrontendEvent('user:updated', { userId: '123', name: 'John' });

// Listen in shell or other micro-frontend
window.addEventListener('mf:user:updated', ((event: CustomEvent) => {
  console.log('User updated:', event.detail);
}) as EventListener);

// React hook
function useMicroFrontendEvent<T>(type: string, handler: (data: T) => void) {
  useEffect(() => {
    const listener = ((event: CustomEvent<T>) => {
      handler(event.detail);
    }) as EventListener;

    window.addEventListener(`mf:${type}`, listener);
    return () => window.removeEventListener(`mf:${type}`, listener);
  }, [type, handler]);
}

Shared State

// Shared state container
class SharedState {
  private state: Record<string, any> = {};
  private listeners = new Map<string, Set<(value: any) => void>>();

  get<T>(key: string): T | undefined {
    return this.state[key];
  }

  set<T>(key: string, value: T): void {
    this.state[key] = value;
    this.listeners.get(key)?.forEach(listener => listener(value));
  }

  subscribe<T>(key: string, listener: (value: T) => void): () => void {
    if (!this.listeners.has(key)) {
      this.listeners.set(key, new Set());
    }
    this.listeners.get(key)!.add(listener);
    return () => this.listeners.get(key)?.delete(listener);
  }
}

// Expose globally
window.__SHARED_STATE__ = new SharedState();

// React hook
function useSharedState<T>(key: string): [T | undefined, (value: T) => void] {
  const [value, setValue] = useState<T | undefined>(() =>
    window.__SHARED_STATE__.get(key)
  );

  useEffect(() => {
    return window.__SHARED_STATE__.subscribe(key, setValue);
  }, [key]);

  const setSharedValue = useCallback((newValue: T) => {
    window.__SHARED_STATE__.set(key, newValue);
  }, [key]);

  return [value, setSharedValue];
}

Shared Dependencies

Dependency Strategy

// Module Federation shared config
{
  shared: {
    // Singleton - only one version loaded
    react: {
      singleton: true,
      requiredVersion: '^18.0.0',
    },
    'react-dom': {
      singleton: true,
      requiredVersion: '^18.0.0',
    },

    // Shared but allow multiple versions
    lodash: {
      singleton: false,
    },

    // Eager loading for shell dependencies
    '@myorg/design-system': {
      singleton: true,
      eager: true,
    },
  },
}

Externals CDN

<!-- Shell HTML -->
<script src="https://cdn.example.com/react@18.2.0/umd/react.production.min.js"></script>
<script src="https://cdn.example.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
// webpack.config.js
module.exports = {
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
};

Routing

Shell-Based Routing

// Shell handles top-level routing
function Shell() {
  return (
    <BrowserRouter>
      <Header />
      <Suspense fallback={<Loading />}>
        <Routes>
          <Route path="/dashboard/*" element={<DashboardMicroFrontend />} />
          <Route path="/settings/*" element={<SettingsMicroFrontend />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

// Micro-frontend handles its own sub-routes
function DashboardMicroFrontend() {
  return (
    <Routes>
      <Route path="/" element={<DashboardHome />} />
      <Route path="/analytics" element={<Analytics />} />
      <Route path="/reports" element={<Reports />} />
    </Routes>
  );
}

Memory Router for Micro-frontends

// Micro-frontend receives basePath from shell
function MicroFrontendApp({ basePath }: { basePath: string }) {
  return (
    <MemoryRouter initialEntries={[basePath]}>
      <Routes>
        <Route path="/feature/*" element={<FeatureRoutes />} />
      </Routes>
    </MemoryRouter>
  );
}

Deployment

Independent Deployment Pipeline

# .github/workflows/deploy-dashboard.yml
name: Deploy Dashboard Micro-frontend

on:
  push:
    paths:
      - 'apps/dashboard/**'
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build
        run: |
          cd apps/dashboard
          npm ci
          npm run build

      - name: Deploy to CDN
        run: |
          aws s3 sync dist/ s3://mf-dashboard/
          aws cloudfront create-invalidation --distribution-id ${{ secrets.CF_DIST_ID }} --paths "/*"

Version Management

// Version manifest
{
  "dashboard": {
    "url": "https://cdn.example.com/dashboard/v2.1.0/remoteEntry.js",
    "integrity": "sha384-...",
    "version": "2.1.0"
  },
  "settings": {
    "url": "https://cdn.example.com/settings/v1.5.0/remoteEntry.js",
    "integrity": "sha384-...",
    "version": "1.5.0"
  }
}

// Shell loads from manifest
async function loadMicroFrontends() {
  const manifest = await fetch('/manifest.json').then(r => r.json());

  for (const [name, config] of Object.entries(manifest)) {
    await loadRemote(config.url, { integrity: config.integrity });
  }
}

single-spa (~13.8k stars)

The pioneer of micro-frontend frameworks. single-spa acts as a meta-framework router — it doesn't render anything itself but orchestrates when child applications are loaded, mounted, and unmounted based on URL routes.

Implementation Principle:

single-spa works like an operating system for SPAs. It manages registered applications through a lifecycle-based architecture: each child app must export bootstrap, mount, and unmount functions. single-spa listens for URL changes, evaluates each app's activeWhen function, and triggers the appropriate lifecycle transitions.

// single-spa root config
import { registerApplication, start } from 'single-spa';

registerApplication({
  name: 'dashboard',
  // Lazy-load the app code
  app: () => import('./dashboard/main.js'),
  // Activate when URL matches
  activeWhen: '/dashboard',
  // Pass props to the app
  customProps: { authToken: 'abc123' },
});

start(); // Actually mount active apps

// Child app must export lifecycle functions
// dashboard/main.js
export function bootstrap(props) {
  // One-time initialization (called once)
  return Promise.resolve();
}

export function mount(props) {
  // Create DOM, render UI (called on each activation)
  ReactDOM.render(<App />, document.getElementById('dashboard-container'));
  return Promise.resolve();
}

export function unmount(props) {
  // Cleanup DOM, event listeners, memory
  ReactDOM.unmountComponentAtNode(document.getElementById('dashboard-container'));
  return Promise.resolve();
}

Key characteristics:

  • No sandbox isolation — single-spa does not provide JS or CSS isolation; apps share the global window
  • Framework-agnostic — helper libraries exist for React, Vue, Angular, Svelte, etc.
  • Parcels — reusable components that can be mounted imperatively across apps
  • Layout enginesingle-spa-layout provides declarative, HTML-based routing configuration

qiankun (~16.5k stars)

Built on top of single-spa by Ant Financial (蚂蚁金服). qiankun adds JS sandbox, CSS isolation, and HTML Entry on top of single-spa's lifecycle management, making it production-ready out of the box.

Implementation Principle:

┌─────────────────────────────────────────────────────┐
│                   qiankun Architecture              │
│                                                     │
│  ┌─────────────┐   HTML Entry    ┌───────────────┐  │
│  │  Main App   │ ──── fetch ───> │  Sub App HTML │  │
│  │             │                 │  (parse & get  │  │
│  │ single-spa  │                 │   JS/CSS)     │  │
│  │  (routing)  │                 └───────────────┘  │
│  └──────┬──────┘                                    │
│         │                                           │
│  ┌──────▼──────────────────────────────────────┐    │
│  │            Sandbox Container                │    │
│  │  ┌──────────────┐  ┌─────────────────────┐  │    │
│  │  │  JS Sandbox   │  │   CSS Isolation    │  │    │
│  │  │  (Proxy-based │  │  (Shadow DOM or    │  │    │
│  │  │   window)     │  │   scoped selectors)│  │    │
│  │  └──────────────┘  └─────────────────────┘  │    │
│  └─────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────┘

Core mechanisms:

  1. HTML Entry — Uses import-html-entry to fetch the sub-app's HTML via fetch(), then parses and extracts inline/external JS and CSS. <link> tags are converted to <style>, <script> tags are extracted for sandboxed execution. This makes loading sub-apps as simple as an iframe — just provide a URL.

  2. JS Sandbox — Two modes:

    • Proxy Sandbox (default): creates a Proxy-wrapped fakeWindow for each sub-app. Property writes go to the proxy, reads fall through to the real window if not found. Supports multiple instances.
    • Snapshot Sandbox (IE fallback): saves the window state before mounting, restores it after unmounting. Only supports single-instance.
  3. CSS Isolation — Two strategies:

    • strictStyleIsolation: true — wraps the sub-app DOM in a Shadow DOM, providing native style encapsulation
    • experimentalStyleIsolation: true — rewrites CSS selectors by prepending div[data-qiankun-appname], similar to Vue scoped
import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'dashboard',
    entry: '//localhost:3001', // HTML Entry — just a URL
    container: '#subapp-container',
    activeRule: '/dashboard',
  },
]);

start({
  sandbox: {
    strictStyleIsolation: true, // Shadow DOM CSS isolation
    // experimentalStyleIsolation: true, // Alternative: scoped CSS
  },
  prefetch: 'all', // Prefetch all sub-apps after first app mounted
});

Module Federation (~2.3k stars for core)

A Webpack 5 built-in feature (now also supported by Rspack) invented by Zack Jackson. It enables runtime code sharing between independently built and deployed applications at the module level.

Implementation Principle:

Module Federation does not use lifecycle hooks or HTML entry. Instead, it extends the bundler's module system to work across application boundaries at runtime:

  1. Container Architecture — Each app can be both a Host (consumer) and a Remote (provider). The app loaded first becomes the host and initializes the shareScope.

  2. Remote Entry — Each remote generates a remoteEntry.js file containing a module map. The host loads this file at runtime to discover what modules the remote exposes.

  3. Shared Dependencies — A negotiation protocol at runtime determines which version of a shared dependency to use. singleton: true ensures only one instance (critical for React). The shareScope acts as a global registry.

  4. Async Loading — Remote modules are loaded asynchronously via dynamic import(). The bundler generates async chunks, and the federation runtime resolves them across application boundaries.

┌───────────────────────────────────────────────┐
│              Runtime Flow                     │
│                                               │
│  Host App                    Remote App       │
│  ┌─────────┐                ┌─────────┐      │
│  │ webpack │  load script   │ webpack │      │
│  │ runtime │ ────────────>  │ runtime │      │
│  │         │                │         │      │
│  │         │  remoteEntry   │ exposes │      │
│  │         │ <────────────  │ modules │      │
│  │         │                │         │      │
│  │ share   │  negotiate     │ share   │      │
│  │ scope   │ <───────────>  │ scope   │      │
│  └─────────┘                └─────────┘      │
└───────────────────────────────────────────────┘

Module Federation 2.0 (@module-federation/enhanced) adds:

  • TypeScript type synchronization across remotes
  • mf-manifest.json for better module discovery
  • Chrome DevTools for debugging federation
  • Runtime plugins for customization
  • Rspack support with ~67% reduced overhead via hoisted runtime

micro-app (~5.7k stars)

Developed by JD.com (京东). micro-app takes a WebComponent-like approach, rendering micro-frontends through custom HTML elements. The goal is the lowest migration cost possible.

Implementation Principle:

micro-app uses CustomElement to create a custom <micro-app> tag. When the element is connected to the DOM, it fetches the sub-app's HTML, parses JS/CSS resources, and renders them inside the custom element with sandbox isolation.

  1. CustomElement Entry — A <micro-app> tag acts as the container. Just set name and url to load a sub-app. No lifecycle exports required from the sub-app (zero-cost migration).

  2. JS Sandbox — Uses Proxy to create an isolated window scope per sub-app. Sub-app global variable modifications are captured by the proxy and don't pollute the real window.

  3. Style Isolation — CSS is scoped by prepending the micro-app name as a prefix to selectors, preventing style leakage between apps.

  4. Element Isolation — DOM queries (like document.querySelector) within sub-apps are intercepted and scoped to the sub-app's container, preventing cross-app DOM access.

// Main app — that's it, no complex config
import microApp from '@micro-zoe/micro-app';

microApp.start();

// In template (React/Vue/Angular — framework agnostic)
<micro-app name="dashboard" url="http://localhost:3001/"></micro-app>

// Sub-app — almost no changes needed
// Just ensure webpack publicPath is set correctly
// and listen for mount/unmount events if needed
window.addEventListener('mount', () => { /* optional */ });
window.addEventListener('unmount', () => { /* optional cleanup */ });

Key characteristics:

  • Near-zero migration cost — sub-apps don't need to export lifecycle functions
  • Preloading — built-in resource preloading for sub-apps
  • Plugin system — customize resource loading and processing
  • Data communication — built-in data passing between main app and sub-apps via dispatch/addDataListener

Wujie 无界 (~4.8k stars)

Developed by Tencent (腾讯). Wujie combines WebComponent + iframe to achieve native-level isolation while keeping sub-apps rendered in the main document flow.

Implementation Principle:

Wujie's core innovation is decoupling the JS runtime from the DOM rendering:

┌───────────────────────────────────────────────┐
│              Wujie Architecture               │
│                                               │
│  Main App Document                            │
│  ┌─────────────────────────────────────────┐  │
│  │  WebComponent (Shadow DOM)              │  │
│  │  ┌───────────────────────────────────┐  │  │
│  │  │  Sub-app DOM lives here          │  │  │
│  │  │  (native CSS isolation)          │  │  │
│  │  └──────────────▲────────────────────┘  │  │
│  └─────────────────┼───────────────────────┘  │
│                    │ Proxy redirects           │
│                    │ document operations       │
│  ┌─────────────────┼───────────────────────┐  │
│  │  iframe (hidden)│                       │  │
│  │  ┌──────────────┴────────────────────┐  │  │
│  │  │  Sub-app JS executes here        │  │  │
│  │  │  (isolated window/document/      │  │  │
│  │  │   history/location)              │  │  │
│  │  └───────────────────────────────────┘  │  │
│  └─────────────────────────────────────────┘  │
└───────────────────────────────────────────────┘
  1. iframe for JS Isolation — Sub-app JavaScript runs inside a hidden iframe, giving it a completely independent window, document, history, and location. This is the strongest possible JS isolation — equivalent to a separate browsing context.

  2. WebComponent for DOM Rendering — The sub-app's DOM is rendered inside a Shadow DOM (WebComponent) in the main document, providing native CSS isolation while keeping the UI in the normal page flow (unlike a real iframe).

  3. Proxy BridgeProxy intercepts the iframe's document operations (querySelector, getElementById, head, body, etc.) and redirects them to the Shadow DOM container. This connects the separated JS runtime to the visible DOM.

  4. Running Modes:

    • Keep-alive — preserves sub-app state (both WebComponent and iframe) across route switches
    • Singleton (umd) — multiple routes share one Wujie instance, sub-app re-renders on switch
    • Rebuild (alive: false) — destroys everything on unmount, rebuilds on next mount
import { startApp } from 'wujie';

startApp({
  name: 'dashboard',
  url: 'http://localhost:3001/',
  el: '#sub-app-container',
  alive: true, // Keep-alive mode
  sync: true,  // Sync sub-app URL to main app
  props: { token: 'abc123' },
});

Garfish (~2.7k stars)

Developed by ByteDance (字节跳动). Garfish is the micro-frontend solution used across ByteDance's internal products, battle-tested at enterprise scale.

Implementation Principle:

Garfish follows a classic base app + sub-app architecture with a plugin-based design:

  1. Loader Module — Supports both HTML Entry and JS Entry. Fetches and parses sub-app resources, extracts scripts and styles.

  2. Sandbox — Two Modes:

    • VM Sandbox (default): Uses Proxy + with statement to create an isolated execution context. Code runs inside with(proxyWindow) { ... }, so all global variable references are intercepted by the proxy. Supports multi-instance concurrent execution.
    • Snapshot Sandbox (fallback): Saves the entire window state before mount, restores after unmount. Only works for single-instance linear execution — breaks when multiple apps run simultaneously.
  3. Plugin System — Garfish's core is highly pluggable. Sandbox, router, and other capabilities are all implemented as plugins, allowing customization at every stage of the lifecycle.

  4. Integration with Modern.js — ByteDance's Modern.js framework provides first-class Garfish support, offering an out-of-the-box development experience with dev tools and debugging capabilities.

import Garfish from 'garfish';

Garfish.run({
  basename: '/',
  domGetter: '#sub-app-container',
  apps: [
    {
      name: 'dashboard',
      activeWhen: '/dashboard',
      entry: 'http://localhost:3001',
    },
  ],
  // Sandbox is enabled by default (VM mode)
  sandbox: {
    snapshot: false, // false = VM sandbox (default), true = snapshot sandbox
  },
});

icestark (~2.1k stars)

Developed by Alibaba's ICE team (飞冰). A micro-frontend solution designed for large-scale applications, serving 300+ apps within Alibaba.

Implementation Principle:

icestark provides two integration patterns: a React component-based approach (AppRouter/AppRoute) and an imperative API (registerMicroApps). It supports multiple entry types — JS/CSS entries, HTML entry, and HTML content string.

// Pattern 1: React component-based
import { AppRouter, AppRoute } from '@ice/stark';

function Layout() {
  return (
    <AppRouter>
      <AppRoute path="/dashboard" url={['//cdn.example.com/dashboard.js']} />
      <AppRoute path="/settings" entry="//localhost:3002" />
    </AppRouter>
  );
}

// Pattern 2: API-based (framework-agnostic)
import { registerMicroApps, start } from '@ice/stark';

registerMicroApps([
  { name: 'dashboard', activePath: '/dashboard', url: ['//cdn.example.com/dashboard.js'] },
]);
start();

Key characteristics:

  • Compatible with single-spa lifecycles — existing single-spa sub-apps work with minimal changes
  • Multiple entry types — JS entry, HTML entry, and inline HTML content
  • Framework-agnostic — supports React, Vue, Angular, and others
  • Lightweight — focused on routing and loading without opinionated sandbox (sandbox is optional)

Piral

Developed by smapiot. Piral is a framework-first micro-frontend solution built around the concept of an app shell + pilets (micro-frontend modules).

Implementation Principle:

Piral takes a different approach from the Chinese ecosystem solutions. Instead of runtime HTML loading, Piral uses an npm-based development workflow:

  1. App Shell — The main application that defines the layout, shared components, and extension points. It gets published as an npm emulator package for pilet development.

  2. Pilets — Independent npm packages that register functionality (pages, components, menu items) via an API object. They are discovered at runtime through a Pilet Feed Service.

  3. Feed Service — A backend that stores and serves pilet metadata. The app shell fetches the pilet list at runtime and dynamically loads them.

// Pilet — registers its contributions
export function setup(api) {
  api.registerPage('/dashboard', DashboardPage);
  api.registerMenu(() => <a href="/dashboard">Dashboard</a>);
  api.registerExtension('header-items', HeaderWidget);
}

// App Shell — defines extension slots
<ExtensionSlot name="header-items" />

Key characteristics:

  • Type-safe development — pilets develop against the emulator, getting full TypeScript support
  • Tooling-firstpiral-cli for scaffolding, debugging, validating, and publishing pilets
  • Supports multiple bundlers — Webpack, Vite, esbuild, Parcel
  • Offline-first capable

Luigi (~870 stars)

Developed by SAP. Luigi is an enterprise-grade micro-frontend orchestrator designed for large-scale business applications, providing consistent navigation and Fiori-compliant UI out of the box.

Implementation Principle:

Luigi uses an iframe-based architecture with a strong communication contract between the core app and micro-frontends:

  1. Luigi Core — The main application (shell) that manages navigation, routing, and layout. Configuration-driven — navigation structure, authorization rules, and context are all defined in a config object.

  2. Luigi Client — A lightweight library embedded in each micro-frontend (iframe) that establishes secure communication with Luigi Core via the postMessage API.

  3. iframe Isolation — Each micro-frontend runs in its own iframe, providing full JS and CSS isolation. Luigi manages the iframe lifecycle and passes context data through its communication layer.

// Luigi Core configuration (shell)
Luigi.setConfig({
  navigation: {
    nodes: [
      { pathSegment: 'dashboard', label: 'Dashboard', viewUrl: '/micro-frontends/dashboard' },
      { pathSegment: 'settings', label: 'Settings', viewUrl: '/micro-frontends/settings' },
    ],
  },
  auth: { use: 'openIdConnect', ... },
});

// Luigi Client (inside micro-frontend iframe)
import LuigiClient from '@luigi-project/client';

LuigiClient.addInitListener((context) => {
  console.log('Received context:', context);
});
LuigiClient.linkManager().navigate('/dashboard');

Key characteristics:

  • Configuration-driven — navigation, auth, and context passing defined declaratively
  • Enterprise-focused — SAP Fiori navigation, localization, responsive mobile support
  • Technology-agnostic — Angular, React, Vue, UI5 all supported as micro-frontends
  • Built-in security — CSP headers, HTTPS enforcement, restrictive CORS policies

Podium

Developed by FINN.no (Norway). Podium takes a fundamentally different approach — server-side composition of micro-frontends, assembling page fragments on the server before sending a complete HTML page to the browser.

Implementation Principle:

┌──────────────────────────────────────────────────┐
│              Podium Architecture                 │
│                                                  │
│  Browser Request                                 │
│       │                                          │
│  ┌────▼─────────────────────────────────────┐    │
│  │  Layout Server (@podium/layout)          │    │
│  │  ┌─────────┐ ┌─────────┐ ┌─────────┐    │    │
│  │  │fetch    │ │fetch    │ │fetch    │    │    │
│  │  │Podlet A │ │Podlet B │ │Podlet C │    │    │
│  │  └────┬────┘ └────┬────┘ └────┬────┘    │    │
│  │       │           │           │          │    │
│  │  Compose into single HTML page           │    │
│  └──────────────────────────────────────────┘    │
│       │                                          │
│  Complete HTML Page → Browser                    │
└──────────────────────────────────────────────────┘
  1. Podlets (page fragments) — Each micro-frontend is an independent HTTP server that returns an HTML fragment. Podlets expose a manifest describing their assets (CSS, JS) and content endpoint.

  2. Layout — A composition server (@podium/layout) fetches all required podlets over HTTP, assembles them into a complete page with shared assets, and returns it to the browser.

  3. Server-side rendering — The page is fully assembled before reaching the client, meaning faster first contentful paint and better SEO compared to client-side composition.

// Podlet server (micro-frontend fragment)
import Podlet from '@podium/podlet';
import express from 'express';

const podlet = new Podlet({ name: 'header', version: '1.0.0', pathname: '/' });
const app = express();

app.use(podlet.middleware());
app.get(podlet.content(), (req, res) => {
  res.podiumSend('<nav>Header content here</nav>');
});
app.get(podlet.manifest(), (req, res) => {
  res.send(podlet);
});
app.listen(7100);

// Layout server (composition layer)
import Layout from '@podium/layout';

const layout = new Layout({ name: 'myLayout', pathname: '/' });
const header = layout.client.register({ name: 'header', uri: 'http://localhost:7100/manifest.json' });

app.get('/', async (req, res) => {
  const incoming = res.locals.podium;
  const [headerHtml] = await Promise.all([header.fetch(incoming)]);
  res.podiumSend(`<html><body>${headerHtml}</body></html>`);
});

Key characteristics:

  • Server-side composition — no client-side framework overhead for loading micro-frontends
  • Language-agnostic podlets — any backend that serves HTML can be a podlet
  • Best for SEO — fully rendered HTML reaches the browser
  • Shared asset management — Eik (companion library) deduplicates shared frontend dependencies across podlets

Ecosystem Landscape

Why Chinese vs. International solutions differ:

The Chinese ecosystem (qiankun, micro-app, Wujie, Garfish, icestark) focuses on client-side runtime composition with built-in sandbox isolation — driven by massive enterprise portals (thousands of internal apps at BAT/ByteDance/JD).

The international ecosystem tends toward:

  • Module Federation — bundler-native module sharing without a runtime framework
  • Server-side composition (Podium) — assembling pages on the server
  • Monorepo + shared packages (Nx, Turborepo) — compile-time integration
  • iframe orchestrators (Luigi) — configuration-driven enterprise shells
  • Component platforms (Piral) — npm-based pilet architecture

Neither approach is superior — the choice depends on isolation requirements, team structure, and deployment model.

Solution Comparison

Comparison Table

Featuresingle-spaqiankunModule Federationmicro-appWujieGarfish
GitHub Stars~13.8k~16.5k~2.3k (core)~5.7k~4.8k~2.7k
MaintainerCommunityAnt FinancialZack Jackson + ByteDanceJD.comTencentByteDance
Base TechnologyLifecycle routersingle-spa + sandboxWebpack/Rspack pluginCustomElementWebComponent + iframePlugin architecture
JS IsolationNoneProxy sandboxN/A (separate bundles)Proxy sandboxiframe (native)Proxy/Snapshot sandbox
CSS IsolationNoneShadow DOM / ScopedN/A (separate bundles)Scoped selectorsShadow DOM (native)Scoped selectors
Sub-app EntryJS exportsHTML URLremoteEntry.jsHTML URLHTML URLHTML/JS entry
Migration CostMediumLowMedium-HighVery LowLowLow
Multi-instanceYesYes (Proxy mode)YesYesYesYes (VM mode)
Keep-aliveNoNoN/AYesYesNo
Framework AgnosticYesYesYesYesYesYes
TypeScript SupportGoodGoodExcellent (v2.0)GoodGoodExcellent

Key Architectural Distinctions

Many solutions appear similar on the surface (fetch HTML, Proxy sandbox, load sub-apps), but they differ fundamentally in three dimensions:

Driving Model

How sub-apps are activated and managed:

Route-Driven (single-spa, qiankun, Garfish, icestark)
  URL change → router matches activeRule → trigger lifecycle → mount/unmount
  Sub-apps are "registered" upfront with activation rules

Component-Driven (micro-app)
  <micro-app> inserted into DOM → connectedCallback → auto load & render
  Sub-apps are declarative HTML elements — appear in DOM = active, removed = inactive

Bundler-Native (Module Federation)
  import('remote/Component') → webpack runtime resolves → async load module
  No "sub-app" concept — just shared modules across independently built apps

iframe-Orchestrated (Wujie, Luigi)
  Shell creates iframe/WebComponent container → loads sub-app URL
  Each sub-app gets a real or simulated browsing context

Key implication: route-driven solutions require sub-apps to export lifecycle functions (bootstrap/mount/unmount). Component-driven and iframe approaches do not — the framework handles the full lifecycle.

Rendering Ownership

Who is responsible for putting pixels on screen:

PatternSolutionsHow it works
Sub-app self-renderssingle-spa, qiankun, GarfishSub-app's mount() calls ReactDOM.render() or equivalent. The framework just tells the sub-app when to render.
Framework takes overmicro-app, WujieFramework fetches HTML, injects DOM into the container, and executes JS. Sub-app code runs but doesn't need to explicitly mount.
No rendering conceptModule FederationJust loads JS modules — rendering is the consumer app's responsibility via normal import().

This is why qiankun requires sub-app code changes (export lifecycles) but micro-app does not — qiankun delegates rendering to the sub-app, while micro-app handles it.

Isolation Spectrum

From weakest to strongest, the isolation approaches form a clear spectrum:

No Isolation          Proxy Sandbox         Proxy + DOM Isolation    iframe Isolation
─────────────────────────────────────────────────────────────────────────────────────
single-spa            qiankun               micro-app                Wujie
Module Federation     Garfish                                        Luigi
                      icestark

window: shared        window: Proxy         window: Proxy            window: separate
CSS: shared           CSS: scoped/Shadow    CSS: scoped              CSS: Shadow DOM
DOM: shared           DOM: shared           DOM: intercepted         DOM: separate
history: shared       history: shared       history: shared          history: separate
  • No isolation: apps must be careful not to conflict. Simplest but most fragile.
  • Proxy sandbox: window writes are captured, but document operations (DOM queries, body mutations) are not intercepted. Edge cases can leak (e.g., document.body.addEventListener, direct <style> injection).
  • Proxy + DOM isolation: additionally intercepts document.querySelector, getElementById, head, body etc., scoping them to the sub-app container. More complete but can break libraries that expect real document access.
  • iframe isolation: real browser-level separation. Strongest guarantee but highest overhead (iframe creation, cross-context communication via postMessage).

DOM Isolation Implementation

Two fundamentally different approaches exist for isolating DOM access between sub-apps:

Approach 1: Proxy Interception (micro-app)

micro-app creates a Proxy for document that redirects DOM queries to the sub-app's container element. Sub-app JS runs with this proxy injected via with statement, so all document.* calls are intercepted transparently.

// Simplified principle — what micro-app does internally
function patchDocument(appContainer: HTMLElement) {
  return new Proxy(document, {
    get(target, key) {
      // DOM queries scoped to container
      if (key === 'querySelector')
        return (sel: string) => appContainer.querySelector(sel);
      if (key === 'querySelectorAll')
        return (sel: string) => appContainer.querySelectorAll(sel);
      if (key === 'getElementById')
        return (id: string) => appContainer.querySelector(`#${id}`);
      if (key === 'getElementsByClassName')
        return (cls: string) => appContainer.getElementsByClassName(cls);
      if (key === 'getElementsByTagName')
        return (tag: string) => appContainer.getElementsByTagName(tag);

      // head/body redirected to virtual elements in container
      if (key === 'head' || key === 'body') return appContainer;

      const value = Reflect.get(target, key);
      return typeof value === 'function' ? value.bind(target) : value;
    },
  });
}

// Sub-app code executes in sandboxed scope
const code = new Function('window', 'document', `with(window) { ${subAppCode} }`);
code(proxyWindow, proxyDocument);

// Effect: sub-app's document.querySelector('#btn')
// → actually calls appContainer.querySelector('#btn')

Limitations of Proxy-based DOM isolation:

// 1. globalThis bypasses the with scope
const doc = globalThis.document; // real document, not proxy

// 2. Third-party libs cache document.body at init time
// antd Modal/Popover internally does:
//   const container = document.body; // cached at module load
//   container.appendChild(popoverEl); // appends to real body, not sub-app

// 3. MutationObserver targets real DOM
new MutationObserver(cb).observe(document.body, { childList: true });

// 4. Event targets are real DOM nodes
document.addEventListener('click', (e) => {
  e.target; // real DOM node, not proxied
});

Approach 2: iframe + Proxy Bridge (Wujie)

Wujie inverts the pattern — instead of intercepting document to narrow scope, it gives each sub-app an isolated iframe document and uses Proxy to bridge DOM operations to the visible Shadow DOM:

// Simplified principle — what Wujie does internally

// 1. Hidden iframe — sub-app JS executes here (isolated context)
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
document.body.appendChild(iframe);
// iframe.contentWindow has its own window, document, history, location

// 2. Shadow DOM — sub-app UI renders here (visible in page flow)
const shadowRoot = appContainer.attachShadow({ mode: 'open' });

// 3. Proxy bridges iframe's document → Shadow DOM
const proxyDocument = new Proxy(iframe.contentDocument, {
  get(target, key) {
    // DOM queries redirected to Shadow DOM
    if (key === 'querySelector')
      return (sel: string) => shadowRoot.querySelector(sel);
    if (key === 'head') return shadowRoot.head;
    if (key === 'body') return shadowRoot.body;

    // createElement stays in iframe (preserves JS context)
    if (key === 'createElement')
      return iframe.contentDocument.createElement.bind(iframe.contentDocument);

    return Reflect.get(target, key);
  },
});

Key difference in design:

micro-app (Proxy narrows scope):
  JS runs in main document → Proxy restricts document access to container
  Same window, same history, same location — just DOM queries scoped
  Weakness: anything that bypasses Proxy (globalThis, cached refs) leaks

Wujie (iframe provides isolation, Proxy connects):
  JS runs in iframe → naturally isolated window/document/history/location
  Proxy bridges iframe's document ops → Shadow DOM (for rendering)
  Weakness: cross-context overhead, iframe creation cost

Strengths and Weaknesses

single-spa

Strengths:

  • Most mature and widely adopted in the international community
  • True framework-agnostic — each app can use any framework independently
  • Rich ecosystem of helper libraries and community plugins
  • Lightweight core (~4kb) with minimal opinions

Weaknesses:

  • No sandbox — apps share global window, CSS conflicts are your problem
  • Requires sub-apps to export lifecycle functions (migration cost)
  • No built-in communication mechanism between apps
  • CSS/JS isolation must be implemented manually

Best for: Teams who want full control and are willing to build their own isolation layer; international teams with diverse tech stacks.


qiankun

Strengths:

  • Complete out-of-the-box solution: sandbox + CSS isolation + HTML Entry
  • HTML Entry makes loading sub-apps as simple as iframes
  • Battle-tested at massive scale (2000+ apps at Ant Financial)
  • Prefetching and global state management built in

Weaknesses:

  • Proxy sandbox can still leak in edge cases (e.g., document.body.addEventListener)
  • Shadow DOM mode breaks some UI libraries (popovers, modals that mount to document.body)
  • Scoped CSS mode may not catch all selectors (e.g., @keyframes, @font-face)
  • Performance overhead from sandbox proxy interception
  • qiankun 3.0 has been in planning for a long time with slow progress

Best for: Enterprise applications in the Chinese ecosystem; teams migrating from monoliths who want batteries-included setup.


Module Federation

Strengths:

  • Bundler-native — no extra runtime framework, just build configuration
  • True module-level sharing — share individual components, not entire apps
  • Efficient dependency deduplication via shared scope negotiation
  • v2.0 offers TypeScript type hints and Chrome DevTools
  • Works with Webpack, Rspack, and Vite (via plugins)

Weaknesses:

  • No application-level isolation — shared window, no sandbox
  • Tightly coupled to the bundler ecosystem (Webpack/Rspack)
  • Complex shared dependency version management
  • Debugging remote module loading issues can be challenging
  • Not a "framework" — only handles module loading, not routing or lifecycle

Best for: Teams already using Webpack/Rspack who want to share components/libraries across apps; scenarios where apps trust each other (same team, same org).


micro-app

Strengths:

  • Lowest migration cost — sub-apps don't need to export lifecycles or modify code
  • WebComponent-based API (<micro-app>) is intuitive and declarative
  • Complete isolation: JS sandbox + CSS scope + element isolation
  • Preloading and plugin system built in

Weaknesses:

  • Relies on CustomElement and Proxy (no IE support)
  • WebComponent approach means less control over sub-app lifecycle
  • Smaller community compared to qiankun/single-spa
  • Element isolation (intercepting DOM queries) can cause subtle bugs with some libraries

Best for: Teams who need rapid micro-frontend adoption with minimal code changes to existing apps; projects that value simplicity over control.


Wujie 无界

Strengths:

  • Strongest JS isolation — iframe provides a real separate browsing context (window, history, location)
  • Native CSS isolation via Shadow DOM — no selector rewriting hacks
  • Keep-alive mode preserves sub-app state across route switches
  • No Proxy-based sandbox limitations (no edge cases leaking)

Weaknesses:

  • iframe communication overhead is higher than Proxy-based solutions
  • Sub-app initial load is slower (iframe creation + resource re-fetch)
  • iframe-based routing sync between main and sub-app requires careful handling
  • Newer project with a smaller ecosystem and community
  • Shadow DOM can still break libraries that attach elements to document.body

Best for: Scenarios requiring the strongest isolation (e.g., embedding third-party apps, multi-tenant platforms); apps where sub-app state preservation (keep-alive) is important.


Garfish

Strengths:

  • Battle-tested across ByteDance's massive product ecosystem
  • Plugin-based architecture is highly extensible
  • Two sandbox modes (VM + Snapshot) with automatic fallback
  • First-class Modern.js integration for seamless DX
  • Good debugging tools and developer experience

Weaknesses:

  • Smaller community outside ByteDance's ecosystem
  • Documentation is primarily in Chinese
  • Snapshot sandbox is limited to single-instance scenarios
  • Less adoption compared to qiankun in the broader Chinese ecosystem

Best for: ByteDance/Modern.js ecosystem users; teams who need a pluggable, extensible micro-frontend runtime.

Use Case Decision Guide

Choosing the Right Solution

  • Need full isolation with embedded third-party apps? → Wujie (iframe-level isolation)
  • Migrating a legacy monolith with minimal code changes? → micro-app (near-zero migration)
  • Enterprise Chinese ecosystem with batteries-included? → qiankun (most complete)
  • Sharing components between trusted internal apps? → Module Federation (module-level sharing)
  • Diverse international team, want maximum flexibility? → single-spa (bring your own isolation)
  • ByteDance/Modern.js stack? → Garfish (native integration)
  • Need sub-app state preservation (keep-alive)? → Wujie or micro-app

Best Practices

Micro-Frontend Guidelines

  1. Define clear boundaries between micro-frontends
  2. Minimize shared state - prefer events
  3. Use consistent design system across apps
  4. Version shared dependencies carefully
  5. Implement health checks and fallbacks
  6. Monitor performance of remote loading
  7. Test integration between micro-frontends
  8. Document communication contracts

On this page