Micro Frontends Architecture Guide
A practical guide to micro frontends: splitting a monolithic frontend into independently deployable pieces with Module Federation and shared design systems.

Micro frontends apply the microservices principle to the frontend: split a large monolithic UI into smaller, independently developed, tested, and deployed applications that compose into a single user experience. Each team owns a vertical slice of the product — from the UI component down to the API call — and can ship changes without coordinating deployments with every other team.
The tradeoff is real complexity. You are introducing runtime composition, shared state management across applications, and potential inconsistency in user experience. Micro frontends solve organizational scaling problems, not technical ones. If you have one frontend team, you do not need them.
Composition Strategies
There are several ways to compose micro frontends. The right choice depends on your performance requirements, team structure, and how tightly the different parts of your application need to interact.
// Strategy 1: Route-based composition
// Each route loads a different application entirely
// Simplest approach — applications are fully isolated
interface RouteConfig {
path: string;
appName: string;
appUrl: string; // Where the micro frontend is hosted
activeWhen: (location: Location) => boolean;
}
const routes: RouteConfig[] = [
{
path: '/dashboard',
appName: 'dashboard-app',
appUrl: 'https://dashboard.cdn.example.com/main.js',
activeWhen: (loc) => loc.pathname.startsWith('/dashboard'),
},
{
path: '/settings',
appName: 'settings-app',
appUrl: 'https://settings.cdn.example.com/main.js',
activeWhen: (loc) => loc.pathname.startsWith('/settings'),
},
{
path: '/billing',
appName: 'billing-app',
appUrl: 'https://billing.cdn.example.com/main.js',
activeWhen: (loc) => loc.pathname.startsWith('/billing'),
},
];
// The shell app loads and mounts the right micro frontend
// based on the current route — like a frontend load balancer// Strategy 2: Component-based composition with Module Federation
// Multiple applications can share components at runtime
// Webpack 5 Module Federation enables this natively
// webpack.config.js for the host (shell) application
const hostConfig = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
// Load components from other micro frontends at runtime
dashboardApp: 'dashboard@https://dashboard.cdn.example.com/remoteEntry.js',
billingApp: 'billing@https://billing.cdn.example.com/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};
// webpack.config.js for the dashboard micro frontend
const dashboardConfig = {
plugins: [
new ModuleFederationPlugin({
name: 'dashboard',
filename: 'remoteEntry.js',
exposes: {
'./DashboardWidget': './src/components/DashboardWidget',
'./MetricsPanel': './src/components/MetricsPanel',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};The Shell Application
The shell (or host) application provides the shared layout — navigation, authentication, global error handling — and orchestrates loading micro frontends.
// Shell application — provides layout and orchestration
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Lazy-load remote micro frontends
const DashboardApp = lazy(() => import('dashboardApp/DashboardWidget'));
const BillingApp = lazy(() => import('billingApp/BillingPage'));
const SettingsApp = lazy(() => import('settingsApp/SettingsPage'));
function Shell() {
return (
<BrowserRouter>
<div className="app-layout">
<SharedNavigation />
<main>
<Suspense fallback={<LoadingSkeleton />}>
<ErrorBoundary fallback={<MicroFrontendError />}>
<Routes>
<Route path="/dashboard/*" element={<DashboardApp />} />
<Route path="/billing/*" element={<BillingApp />} />
<Route path="/settings/*" element={<SettingsApp />} />
</Routes>
</ErrorBoundary>
</Suspense>
</main>
</div>
</BrowserRouter>
);
}
// Error boundary prevents one crashed micro frontend from taking down the whole app
class ErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error) {
console.error('Micro frontend error:', error);
// Report to monitoring — which micro frontend failed?
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}// ❌ No error isolation — one micro frontend crash kills everything
function ShellNoIsolation() {
return (
<Routes>
<Route path="/dashboard/*" element={<DashboardApp />} />
<Route path="/billing/*" element={<BillingApp />} />
{/* If BillingApp throws, the entire page goes white */}
</Routes>
);
}
// ✅ Each micro frontend wrapped in its own error boundary
function ShellWithIsolation() {
return (
<Routes>
<Route path="/dashboard/*" element={
<ErrorBoundary fallback={<p>Dashboard unavailable</p>}>
<Suspense fallback={<LoadingSkeleton />}>
<DashboardApp />
</Suspense>
</ErrorBoundary>
} />
<Route path="/billing/*" element={
<ErrorBoundary fallback={<p>Billing unavailable</p>}>
<Suspense fallback={<LoadingSkeleton />}>
<BillingApp />
</Suspense>
</ErrorBoundary>
} />
</Routes>
);
}Cross-Application Communication
Micro frontends need to communicate — the navigation needs to know the user's authentication state, the billing page needs the selected team. The key constraint: communication must be loose coupling. Direct imports between micro frontends defeat the purpose.
// Custom events for loose coupling between micro frontends
// Any micro frontend can emit or listen without importing another
interface AppEvent {
type: string;
payload: unknown;
source: string; // Which micro frontend emitted this
}
class EventBus {
private listeners = new Map<string, Set<(event: AppEvent) => void>>();
on(eventType: string, handler: (event: AppEvent) => void): () => void {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, new Set());
}
this.listeners.get(eventType)!.add(handler);
// Return unsubscribe function
return () => this.listeners.get(eventType)?.delete(handler);
}
emit(event: AppEvent): void {
const handlers = this.listeners.get(event.type) ?? [];
for (const handler of handlers) {
try {
handler(event);
} catch (error) {
console.error(`Event handler error for "${event.type}":`, error);
}
}
}
}
// Singleton event bus shared via window (or a shared module)
const eventBus = (window as any).__EVENT_BUS__ ??= new EventBus();
// Dashboard micro frontend emits team selection
eventBus.emit({
type: 'team:selected',
payload: { teamId: 'team-123', teamName: 'Platform' },
source: 'dashboard-app',
});
// Billing micro frontend listens for team changes
eventBus.on('team:selected', (event) => {
const { teamId } = event.payload as { teamId: string };
loadBillingForTeam(teamId);
});Shared Design System
Visual consistency across micro frontends requires a shared design system — a library of components, tokens, and styles that every application uses.
// @company/design-system — published as an npm package
// Each micro frontend imports from this shared library
// Versioned and published independently
// Teams upgrade on their own schedule (within a compatibility window)
// design-system/src/Button.tsx
interface ButtonProps {
variant: 'primary' | 'secondary' | 'danger';
size: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
}
export function Button({ variant, size, children, ...props }: ButtonProps) {
return (
<button
className={`ds-btn ds-btn--${variant} ds-btn--${size}`}
{...props}
>
{children}
</button>
);
}
// design-system/src/tokens.ts — shared design tokens
export const tokens = {
colors: {
primary: '#2563eb',
danger: '#dc2626',
neutral: {
50: '#f8fafc',
900: '#0f172a',
},
},
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
},
borderRadius: {
sm: '0.25rem',
md: '0.5rem',
full: '9999px',
},
} as const;When Micro Frontends Are Worth It
Micro frontends add complexity. The gains come from organizational independence — teams can ship without waiting for each other. If your organization has 3+ frontend teams, deployment coordination is a bottleneck, and different parts of the application have different release cadences, micro frontends are worth evaluating.
If you have one team, a well-structured monolith with code splitting is simpler and equally effective.
Key Takeaways
- Micro frontends solve organizational problems, not technical ones — they enable independent team deployment, not better frontend architecture
- Choose composition strategy based on coupling needs — route-based for full isolation, Module Federation for shared components
- The shell app provides shared infrastructure — navigation, auth, error handling, and micro frontend orchestration
- Use error boundaries per micro frontend — one crashed application should not take down the entire page
- Communicate through events, not imports — loose coupling via an event bus preserves team independence
- Share a design system, not code — a versioned component library ensures visual consistency without runtime coupling


