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
Not Recommended
- 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 });
}
}Popular Open-Source Solutions
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 engine —
single-spa-layoutprovides 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:
-
HTML Entry — Uses
import-html-entryto fetch the sub-app's HTML viafetch(), 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. -
JS Sandbox — Two modes:
- Proxy Sandbox (default): creates a
Proxy-wrappedfakeWindowfor each sub-app. Property writes go to the proxy, reads fall through to the realwindowif not found. Supports multiple instances. - Snapshot Sandbox (IE fallback): saves the
windowstate before mounting, restores it after unmounting. Only supports single-instance.
- Proxy Sandbox (default): creates a
-
CSS Isolation — Two strategies:
strictStyleIsolation: true— wraps the sub-app DOM in a Shadow DOM, providing native style encapsulationexperimentalStyleIsolation: true— rewrites CSS selectors by prependingdiv[data-qiankun-appname], similar to Vuescoped
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:
-
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. -
Remote Entry — Each remote generates a
remoteEntry.jsfile containing a module map. The host loads this file at runtime to discover what modules the remote exposes. -
Shared Dependencies — A negotiation protocol at runtime determines which version of a shared dependency to use.
singleton: trueensures only one instance (critical for React). TheshareScopeacts as a global registry. -
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.jsonfor 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.
-
CustomElement Entry — A
<micro-app>tag acts as the container. Just setnameandurlto load a sub-app. No lifecycle exports required from the sub-app (zero-cost migration). -
JS Sandbox — Uses
Proxyto create an isolatedwindowscope per sub-app. Sub-app global variable modifications are captured by the proxy and don't pollute the realwindow. -
Style Isolation — CSS is scoped by prepending the micro-app name as a prefix to selectors, preventing style leakage between apps.
-
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) │ │ │
│ │ └───────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└───────────────────────────────────────────────┘-
iframe for JS Isolation — Sub-app JavaScript runs inside a hidden iframe, giving it a completely independent
window,document,history, andlocation. This is the strongest possible JS isolation — equivalent to a separate browsing context. -
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).
-
Proxy Bridge —
Proxyintercepts the iframe'sdocumentoperations (querySelector,getElementById,head,body, etc.) and redirects them to the Shadow DOM container. This connects the separated JS runtime to the visible DOM. -
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:
-
Loader Module — Supports both HTML Entry and JS Entry. Fetches and parses sub-app resources, extracts scripts and styles.
-
Sandbox — Two Modes:
- VM Sandbox (default): Uses
Proxy+withstatement to create an isolated execution context. Code runs insidewith(proxyWindow) { ... }, so all global variable references are intercepted by the proxy. Supports multi-instance concurrent execution. - Snapshot Sandbox (fallback): Saves the entire
windowstate before mount, restores after unmount. Only works for single-instance linear execution — breaks when multiple apps run simultaneously.
- VM Sandbox (default): Uses
-
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.
-
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:
-
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.
-
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.
-
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-first —
piral-clifor 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:
-
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.
-
Luigi Client — A lightweight library embedded in each micro-frontend (iframe) that establishes secure communication with Luigi Core via the
postMessageAPI. -
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 │
└──────────────────────────────────────────────────┘-
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.
-
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. -
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
| Feature | single-spa | qiankun | Module Federation | micro-app | Wujie | Garfish |
|---|---|---|---|---|---|---|
| GitHub Stars | ~13.8k | ~16.5k | ~2.3k (core) | ~5.7k | ~4.8k | ~2.7k |
| Maintainer | Community | Ant Financial | Zack Jackson + ByteDance | JD.com | Tencent | ByteDance |
| Base Technology | Lifecycle router | single-spa + sandbox | Webpack/Rspack plugin | CustomElement | WebComponent + iframe | Plugin architecture |
| JS Isolation | None | Proxy sandbox | N/A (separate bundles) | Proxy sandbox | iframe (native) | Proxy/Snapshot sandbox |
| CSS Isolation | None | Shadow DOM / Scoped | N/A (separate bundles) | Scoped selectors | Shadow DOM (native) | Scoped selectors |
| Sub-app Entry | JS exports | HTML URL | remoteEntry.js | HTML URL | HTML URL | HTML/JS entry |
| Migration Cost | Medium | Low | Medium-High | Very Low | Low | Low |
| Multi-instance | Yes | Yes (Proxy mode) | Yes | Yes | Yes | Yes (VM mode) |
| Keep-alive | No | No | N/A | Yes | Yes | No |
| Framework Agnostic | Yes | Yes | Yes | Yes | Yes | Yes |
| TypeScript Support | Good | Good | Excellent (v2.0) | Good | Good | Excellent |
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 contextKey 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:
| Pattern | Solutions | How it works |
|---|---|---|
| Sub-app self-renders | single-spa, qiankun, Garfish | Sub-app's mount() calls ReactDOM.render() or equivalent. The framework just tells the sub-app when to render. |
| Framework takes over | micro-app, Wujie | Framework fetches HTML, injects DOM into the container, and executes JS. Sub-app code runs but doesn't need to explicitly mount. |
| No rendering concept | Module Federation | Just 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:
windowwrites are captured, butdocumentoperations (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,bodyetc., 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 costStrengths 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
CustomElementandProxy(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
- Define clear boundaries between micro-frontends
- Minimize shared state - prefer events
- Use consistent design system across apps
- Version shared dependencies carefully
- Implement health checks and fallbacks
- Monitor performance of remote loading
- Test integration between micro-frontends
- Document communication contracts