Micro Frontends in 2025: Architecture Patterns That Scale
Our monolithic React application reached 2.3 million lines of code. Builds took 45 minutes. Three teams blocked each other constantly. We split it into seven micro frontends, each owned by a single team. Builds dropped to 4 minutes per app. Teams deployed independently. Here's the architecture that made it work.
When Micro Frontends Make Sense
Before diving into patterns, assess whether you actually need micro frontends:
Good candidates:
- Multiple teams (3+) working on the same application
- Different release cadences for different features
- Legacy migration where you can't rewrite everything
- Large applications where build times become painful
Poor candidates:
- Small teams (< 10 developers)
- New applications that can start with good architecture
- Projects where shared state is complex and tightly coupled
- When you lack platform engineering capacity
Micro frontends add complexity. The benefits must outweigh the costs.
Module Federation Pattern
Module Federation, available in Webpack 5 and Vite, allows applications to share code at runtime. One application can load components from another without bundling them together.
Host Configuration
The host application orchestrates the micro frontends:
// apps/shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
checkout: 'checkout@http://localhost:3001/remoteEntry.js',
catalog: 'catalog@http://localhost:3002/remoteEntry.js',
account: 'account@http://localhost:3003/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
'react-router-dom': { singleton: true },
},
}),
],
};Remote Configuration
Each micro frontend exposes its components:
// apps/checkout/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
'./Cart': './src/components/Cart',
'./Checkout': './src/pages/Checkout',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};Loading Remote Components
The shell loads micro frontends dynamically:
// apps/shell/src/App.tsx
import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
const RemoteCart = lazy(() => import('checkout/Cart'));
const RemoteCheckout = lazy(() => import('checkout/Checkout'));
const RemoteCatalog = lazy(() => import('catalog/ProductList'));
const RemoteAccount = lazy(() => import('account/Profile'));
function App() {
return (
<div className="app">
<Header />
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/products/*" element={<RemoteCatalog />} />
<Route path="/cart" element={<RemoteCart />} />
<Route path="/checkout/*" element={<RemoteCheckout />} />
<Route path="/account/*" element={<RemoteAccount />} />
</Routes>
</Suspense>
<Footer />
</div>
);
}Type Safety Across Boundaries
Create type definitions for remote modules:
// apps/shell/src/types/remotes.d.ts
declare module 'checkout/Cart' {
const Cart: React.ComponentType<{
onCheckout: () => void;
currency?: string;
}>;
export default Cart;
}
declare module 'checkout/Checkout' {
const Checkout: React.ComponentType<{
cartId: string;
onComplete: (orderId: string) => void;
}>;
export default Checkout;
}Next.js with Module Federation
For Next.js applications, use the dedicated plugin:
// apps/shell/next.config.js
const NextFederationPlugin = require('@module-federation/nextjs-mf');
module.exports = {
webpack(config, options) {
config.plugins.push(
new NextFederationPlugin({
name: 'shell',
remotes: {
checkout: `checkout@${process.env.CHECKOUT_URL}/_next/static/chunks/remoteEntry.js`,
},
shared: {
// Share Next.js internals carefully
},
extraOptions: {
exposePages: true,
},
})
);
return config;
},
};Next.js micro frontends can share the router and maintain SSR capabilities:
// apps/shell/pages/checkout/[[...slug]].tsx
import dynamic from 'next/dynamic';
const RemoteCheckoutPage = dynamic(
() => import('checkout/pages/checkout'),
{
ssr: false, // Or true with careful configuration
loading: () => <CheckoutSkeleton />,
}
);
export default function CheckoutPage() {
return <RemoteCheckoutPage />;
}Single-SPA for Multi-Framework
When different teams use different frameworks, single-spa orchestrates them:
// root-config.js
import { registerApplication, start } from 'single-spa';
registerApplication({
name: '@myorg/navbar',
app: () => System.import('@myorg/navbar'),
activeWhen: ['/'],
});
registerApplication({
name: '@myorg/dashboard',
app: () => System.import('@myorg/dashboard'),
activeWhen: ['/dashboard'],
});
registerApplication({
name: '@myorg/settings',
app: () => System.import('@myorg/settings'),
activeWhen: ['/settings'],
});
start();Each application implements lifecycle hooks:
// apps/dashboard/src/myorg-dashboard.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import singleSpaReact from 'single-spa-react';
import Dashboard from './Dashboard';
const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: Dashboard,
errorBoundary(err, info, props) {
return <ErrorFallback error={err} />;
},
});
export const { bootstrap, mount, unmount } = lifecycles;Communication Between Micro Frontends
Micro frontends need to communicate without tight coupling.
Custom Events
// Shared event types
interface CartUpdatedEvent extends CustomEvent {
detail: { itemCount: number; total: number };
}
// Publishing (from checkout MFE)
function updateCart(itemCount: number, total: number) {
window.dispatchEvent(
new CustomEvent('cart:updated', {
detail: { itemCount, total },
})
);
}
// Subscribing (in shell)
useEffect(() => {
const handler = (event: CartUpdatedEvent) => {
setCartCount(event.detail.itemCount);
};
window.addEventListener('cart:updated', handler);
return () => window.removeEventListener('cart:updated', handler);
}, []);Shared State via URL
URL is inherently shareable across micro frontends:
// Navigation preserves state in URL
function ProductFilter({ onFilter }: { onFilter: (filters: Filters) => void }) {
const [searchParams, setSearchParams] = useSearchParams();
const filters = {
category: searchParams.get('category'),
minPrice: searchParams.get('minPrice'),
maxPrice: searchParams.get('maxPrice'),
};
const updateFilter = (key: string, value: string) => {
const newParams = new URLSearchParams(searchParams);
newParams.set(key, value);
setSearchParams(newParams);
};
// Other MFEs read the same URL params
return (/* filter UI */);
}Shared Services
Create a shared package for cross-cutting concerns:
// packages/shared-services/src/auth.ts
class AuthService {
private static instance: AuthService;
private user: User | null = null;
private listeners: Set<(user: User | null) => void> = new Set();
static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
getUser(): User | null {
return this.user;
}
subscribe(listener: (user: User | null) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
setUser(user: User | null): void {
this.user = user;
this.listeners.forEach((l) => l(user));
}
}
export const authService = AuthService.getInstance();Design System Integration
A shared design system maintains visual consistency:
packages/
├── design-system/
│ ├── src/
│ │ ├── components/
│ │ │ ├── Button/
│ │ │ ├── Input/
│ │ │ └── Modal/
│ │ ├── tokens/
│ │ │ ├── colors.ts
│ │ │ ├── spacing.ts
│ │ │ └── typography.ts
│ │ └── index.ts
│ └── package.jsonEach micro frontend imports from the design system:
// apps/checkout/src/components/CartItem.tsx
import { Button, Card, Text } from '@myorg/design-system';
function CartItem({ item }: { item: CartItemType }) {
return (
<Card padding="md">
<Text variant="body">{item.name}</Text>
<Text variant="price">${item.price}</Text>
<Button size="sm" onClick={() => removeItem(item.id)}>
Remove
</Button>
</Card>
);
}Version the design system carefully—breaking changes affect all micro frontends.
Performance Optimization
Shared Dependencies
Configure shared dependencies to load once:
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0',
eager: false, // Lazy load
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
},
// Large libraries that all MFEs use
'@tanstack/react-query': {
singleton: true,
},
}Lazy Loading
Load micro frontends only when needed:
const routes = [
{
path: '/dashboard',
component: lazy(() => import('dashboard/App')),
},
{
path: '/settings',
component: lazy(() => import('settings/App')),
},
];
// Preload on hover
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
const preload = () => {
const route = routes.find((r) => r.path === to);
if (route) {
route.component.preload?.();
}
};
return (
<Link to={to} onMouseEnter={preload}>
{children}
</Link>
);
}Bundle Analysis
Monitor each micro frontend's bundle size independently:
# Per-MFE analysis
cd apps/checkout && npm run build -- --analyze
cd apps/catalog && npm run build -- --analyzeSet size budgets per micro frontend:
{
"budgets": [
{
"path": "apps/checkout/dist",
"maxSize": "150kb"
},
{
"path": "apps/catalog/dist",
"maxSize": "200kb"
}
]
}Team Organization
Micro frontends work best with clear ownership:
Team: Checkout
├── apps/checkout/
├── Owns: Cart, Checkout flow, Payment
└── On-call rotation
Team: Catalog
├── apps/catalog/
├── Owns: Product listing, Search, Filters
└── On-call rotation
Team: Platform
├── apps/shell/
├── packages/design-system/
├── packages/shared-services/
└── Owns: Infrastructure, Shared toolingEach team:
- Deploys independently
- Owns their CI/CD pipeline
- Handles their own monitoring
- Maintains their own documentation
The platform team provides shared infrastructure but doesn't block feature teams.
Deployment Strategy
Deploy micro frontends independently:
# apps/checkout/.github/workflows/deploy.yml
name: Deploy Checkout
on:
push:
branches: [main]
paths:
- 'apps/checkout/**'
- 'packages/shared-services/**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm ci
- run: npm run build -w apps/checkout
- run: npm run deploy -w apps/checkoutUse versioned URLs for remote entries:
remotes: {
checkout: `checkout@${CDN_URL}/checkout/v${CHECKOUT_VERSION}/remoteEntry.js`,
}This enables rollbacks without redeploying the shell.
Micro frontends trade simplicity for team autonomy. When you have the scale to justify the complexity—multiple teams, large codebase, independent release needs—the architecture enables parallel development that monoliths can't match. Start simple, split when the pain of coordination exceeds the pain of distribution.