Your frontend started as a small React or Angular app. Now it has 200+ components, multiple teams working on different features, and a single deployment pipeline that bottlenecks everyone. Micro-frontends solve this by splitting the UI into independently deployable pieces, each owned by a different team.
But micro-frontends add real complexity. This guide covers the architecture patterns, Module Federation implementation, and the honest tradeoffs so you can decide if the complexity is worth it for your team.
What Are Micro-Frontends?
Micro-frontends apply microservice principles to the frontend: each feature area is an independent application that can be developed, tested, and deployed separately.
- Shell/Host: The container app that provides navigation, authentication, and shared layout
- Remotes/Micro-apps: Independent applications loaded into the shell at runtime
- Shared dependencies: Libraries loaded once and shared across micro-apps (React, Angular, design system)
Integration Approaches
| Approach | How It Works | Pros | Cons |
|---|---|---|---|
| Module Federation | Webpack/Vite loads remote bundles at runtime | Shared deps, typed contracts, HMR | Build tool lock-in |
| iframes | Each micro-app in an iframe | Complete isolation | Poor UX, no shared state |
| Web Components | Custom elements wrapping each app | Framework agnostic | Shadow DOM quirks, SSR challenges |
| Build-time integration | NPM packages composed at build | Simple, typed | Not independently deployable |
Module Federation: The Modern Standard
Webpack 5 Module Federation lets multiple independent builds share code at runtime. One application can dynamically load modules from another deployed application.
Shell Application (Host)
// webpack.config.js (Shell)
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
// Load micro-apps from their deployed URLs
dashboard: 'dashboard@https://dashboard.example.com/remoteEntry.js',
settings: 'settings@https://settings.example.com/remoteEntry.js',
billing: 'billing@https://billing.example.com/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
'@company/design-system': { singleton: true },
},
}),
],
};
Remote Application (Micro-App)
// webpack.config.js (Dashboard micro-app)
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'dashboard',
filename: 'remoteEntry.js', // Entry point for the shell to load
exposes: {
'./DashboardApp': './src/DashboardApp', // Exposed component
'./DashboardWidget': './src/widgets/Summary',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
'@company/design-system': { singleton: true },
},
}),
],
};
Loading Remote Components
// Shell routing with lazy-loaded micro-apps
import { lazy, Suspense } from 'react';
// Dynamic imports from remote applications
const DashboardApp = lazy(() => import('dashboard/DashboardApp'));
const SettingsApp = lazy(() => import('settings/SettingsApp'));
const BillingApp = lazy(() => import('billing/BillingApp'));
function App() {
return (
<Shell>
<Navigation />
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard/*" element={<DashboardApp />} />
<Route path="/settings/*" element={<SettingsApp />} />
<Route path="/billing/*" element={<BillingApp />} />
</Routes>
</Suspense>
</Shell>
);
}
Shared State and Communication
// Option 1: Custom Events (loosely coupled)
// Micro-app dispatches:
window.dispatchEvent(new CustomEvent('user:updated', {
detail: { userId: 123, name: 'Alice' }
}));
// Shell or another micro-app listens:
window.addEventListener('user:updated', (event) => {
updateUserContext(event.detail);
});
// Option 2: Shared state store (via Module Federation shared module)
// shared-store.ts (exposed by shell, consumed by remotes)
import { create } from 'zustand';
export const useAppStore = create((set) => ({
user: null,
theme: 'light',
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
}));
// Any micro-app can import and use:
import { useAppStore } from 'shell/SharedStore';
const user = useAppStore((state) => state.user);
Shared Design System
// Publish your design system as a shared singleton
// All micro-apps use the SAME instance loaded once
// webpack.config.js (every app)
shared: {
'@company/design-system': {
singleton: true, // Only load ONE instance
eager: false, // Lazy load
requiredVersion: '^3.0.0',
},
}
// This ensures:
// 1. Consistent look across all micro-apps
// 2. One CSS bundle for the design system (not duplicated)
// 3. Version compatibility enforcement
Independent Deployment Pipeline
# .github/workflows/deploy-dashboard.yml
name: Deploy Dashboard Micro-App
on:
push:
paths:
- 'apps/dashboard/**' # Only trigger for dashboard changes
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- run: npm test
# Deploy to CDN independently
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: us-east-1
- run: |
aws s3 sync dist/ s3://micro-frontends/dashboard/ --delete
aws cloudfront create-invalidation --distribution-id DIST_ID --paths "/dashboard/*"
# Each micro-app has its own pipeline
# Dashboard team deploys without waiting for billing team
# Shell only redeploys when shell code changes
When NOT to Use Micro-Frontends
- Small team (less than 3-4 frontend developers): The coordination overhead exceeds the benefit
- Simple application: If one team can manage the entire frontend, a monolith is simpler
- Strong coupling between features: If features share lots of state and UI, splitting them creates more problems
- No independent deployment need: If you deploy everything together anyway, micro-frontends add complexity for no gain
- Performance-critical apps: Multiple bundles, runtime loading, and shared dependency negotiation add latency
Challenges and Mitigations
| Challenge | Mitigation |
|---|---|
| Inconsistent UI | Shared design system as singleton dependency |
| Shared state complexity | Custom events for loose coupling, shared store for tight coupling |
| Version conflicts | Singleton shared dependencies with version ranges |
| Performance overhead | Preload critical remotes, share common chunks |
| Local development | Run shell + one remote locally, mock others |
| Testing across boundaries | Contract tests + integration tests in staging |
Key Takeaways
- Micro-frontends solve organizational problems, not technical ones — use them when multiple teams need to deploy independently
- Module Federation is the current standard — runtime integration with shared dependencies
- Shared singletons prevent bundle duplication — React, design system, and state libraries should load once
- Communication via custom events keeps coupling low — micro-apps should not import from each other directly
- Each micro-app gets its own CI/CD pipeline — that is the whole point
- Do not split too early — start with a well-structured monolith and split only when team autonomy demands it
- The complexity cost is real — only worth it with 4+ teams working on the same frontend
Micro-frontends are a scaling strategy for organizations, not a technical improvement for applications. If you have one team, a monolith with good module boundaries is strictly better. If you have five teams stepping on each other during deployments, micro-frontends give each team their own lane. Match the architecture to the organization, not the other way around.