Micro-Frontends: Scaling Frontend Architecture
Learn micro-frontend architecture patterns, implementation strategies, communication between micro-frontends, and deployment techniques for large-scale applications.
Table of Contents
Micro-Frontends: Scaling Frontend Architecture
As frontend applications grow in complexity, monolithic architectures become difficult to maintain. Micro-frontends offer a way to scale frontend development by breaking applications into smaller, independently deployable pieces.
What are Micro-Frontends?
Micro-frontends extend microservices architecture to the frontend, allowing teams to work independently on different parts of the user interface.
Benefits
- Independent deployment: Deploy features without affecting the entire application
- Technology diversity: Use different frameworks for different parts of the app
- Team autonomy: Teams can work independently with their own release cycles
- Scalability: Better code organization and maintainability
Challenges
- Complexity: Additional architectural complexity
- Consistency: Maintaining UI/UX consistency across teams
- Performance: Potential performance overhead
- Integration: Coordinating between different frontend pieces
Architecture Patterns
1. Build-time Integration
Compile all micro-frontends into a single application:
// webpack.config.js
const ModuleFederationPlugin = require(
"webpack/lib/container/ModuleFederationPlugin",
);
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "shell",
remotes: {
header: "header@http://localhost:3001/remoteEntry.js",
sidebar: "sidebar@http://localhost:3002/remoteEntry.js",
content: "content@http://localhost:3003/remoteEntry.js",
},
}),
],
};2. Run-time Integration
Load micro-frontends dynamically at runtime:
// shell/src/App.js
import React, { Suspense } from "react";
import { BrowserRouter, Route, Switch } from "react-router-dom";
const Header = React.lazy(() => import("header/Header"));
const Sidebar = React.lazy(() => import("sidebar/Sidebar"));
const Content = React.lazy(() => import("content/Content"));
function App() {
return (
<BrowserRouter>
<div className="app">
<Suspense fallback={<div>Loading header...</div>}>
<Header />
</Suspense>
<div className="main">
<Suspense fallback={<div>Loading sidebar...</div>}>
<Sidebar />
</Suspense>
<Suspense fallback={<div>Loading content...</div>}>
<Switch>
<Route path="/dashboard" component={Content} />
<Route path="/users" component={Content} />
</Switch>
</Suspense>
</div>
</div>
</BrowserRouter>
);
}3. Server-side Composition
Compose micro-frontends on the server:
// server.js (Express)
const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");
const app = express();
// Proxy micro-frontends
app.use(
"/header",
createProxyMiddleware({
target: "http://localhost:3001",
changeOrigin: true,
}),
);
app.use(
"/sidebar",
createProxyMiddleware({
target: "http://localhost:3002",
changeOrigin: true,
}),
);
app.get("/", (req, res) => {
// Server-side rendering with composition
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Micro-frontend App</title>
</head>
<body>
<div id="header"></div>
<div id="sidebar"></div>
<div id="content"></div>
<script src="/header/main.js"></script>
<script src="/sidebar/main.js"></script>
<script src="/content/main.js"></script>
</body>
</html>
`;
res.send(html);
});Module Federation with Webpack 5
Host Application (Shell)
// shell/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "shell",
filename: "remoteEntry.js",
remotes: {
header: "header@http://localhost:3001/remoteEntry.js",
dashboard: "dashboard@http://localhost:3002/remoteEntry.js",
},
shared: ["react", "react-dom"],
}),
],
};// shell/src/App.js
import React from "react";
const Header = React.lazy(() => import("header/Header"));
const Dashboard = React.lazy(() => import("dashboard/Dashboard"));
function App() {
return (
<div>
<React.Suspense fallback="Loading Header...">
<Header />
</React.Suspense>
<React.Suspense fallback="Loading Dashboard...">
<Dashboard />
</React.Suspense>
</div>
);
}Remote Application (Header)
// header/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "header",
filename: "remoteEntry.js",
exposes: {
"./Header": "./src/Header",
},
shared: ["react", "react-dom"],
}),
],
};// header/src/Header.js
import React from "react";
const Header = () => {
return (
<header>
<h1>My App Header</h1>
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/users">Users</a>
</nav>
</header>
);
};
export default Header;Communication Between Micro-Frontends
1. Custom Events
Use browser's event system for communication:
// header/src/Header.js
import React, { useEffect } from "react";
const Header = () => {
const handleNavigation = (path) => {
// Dispatch custom event
window.dispatchEvent(
new CustomEvent("navigation", {
detail: { path },
}),
);
};
return (
<header>
<button onClick={() => handleNavigation("/dashboard")}>
Dashboard
</button>
</header>
);
};// shell/src/App.js
import React, { useEffect } from "react";
function App() {
useEffect(() => {
const handleNavigation = (event) => {
const { path } = event.detail;
// Handle navigation
window.history.pushState(null, "", path);
};
window.addEventListener("navigation", handleNavigation);
return () => window.removeEventListener("navigation", handleNavigation);
}, []);
return <div>{/* App content */}</div>;
}2. Shared State Management
Use a shared state management solution:
// shared-state/src/store.js
import { createStore } from "redux";
const initialState = {
user: null,
theme: "light",
};
const rootReducer = (state = initialState, action) => {
switch (action.type) {
case "SET_USER":
return { ...state, user: action.payload };
case "SET_THEME":
return { ...state, theme: action.payload };
default:
return state;
}
};
export const store = createStore(rootReducer);// shared-state/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "sharedState",
filename: "remoteEntry.js",
exposes: {
"./store": "./src/store",
},
shared: ["redux"],
}),
],
};3. Props Passing
Pass data through component props:
// shell/src/App.js
import React from "react";
import Header from "header/Header";
import Dashboard from "dashboard/Dashboard";
function App() {
const [user, setUser] = React.useState(null);
const [theme, setTheme] = React.useState("light");
return (
<div>
<Header
user={user}
theme={theme}
onThemeChange={setTheme}
/>
<Dashboard
user={user}
theme={theme}
/>
</div>
);
}Routing in Micro-Frontends
Client-side Routing
// shell/src/App.js
import React from "react";
import { BrowserRouter, Route, Switch } from "react-router-dom";
const Dashboard = React.lazy(() => import("dashboard/Dashboard"));
const Users = React.lazy(() => import("users/Users"));
function App() {
return (
<BrowserRouter>
<React.Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route path="/dashboard" component={Dashboard} />
<Route path="/users" component={Users} />
<Route path="/" exact component={Dashboard} />
</Switch>
</React.Suspense>
</BrowserRouter>
);
}Nested Routing
// dashboard/src/Dashboard.js
import React from "react";
import { Route, Switch, useRouteMatch } from "react-router-dom";
const Overview = React.lazy(() => import("./Overview"));
const Analytics = React.lazy(() => import("./Analytics"));
function Dashboard() {
const { path } = useRouteMatch();
return (
<div>
<h2>Dashboard</h2>
<React.Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route path={`${path}/analytics`} component={Analytics} />
<Route path={path} component={Overview} />
</Switch>
</React.Suspense>
</div>
);
}Styling and Theming
CSS-in-JS with Theme Provider
// shared-theme/src/theme.js
export const theme = {
colors: {
primary: "#007bff",
secondary: "#6c757d",
success: "#28a745",
},
spacing: {
small: "8px",
medium: "16px",
large: "24px",
},
};// shared-theme/src/ThemeProvider.js
import React from "react";
import { ThemeProvider as StyledThemeProvider } from "styled-components";
export const ThemeProvider = ({ children }) => (
<StyledThemeProvider theme={theme}>
{children}
</StyledThemeProvider>
);CSS Modules
/* header/src/Header.module.css */
.header {
background-color: var(--primary-color);
padding: var(--spacing-medium);
}
.nav {
display: flex;
gap: var(--spacing-small);
}// header/src/Header.js
import React from "react";
import styles from "./Header.module.css";
const Header = () => (
<header className={styles.header}>
<nav className={styles.nav}>
<a href="/dashboard">Dashboard</a>
</nav>
</header>
);Testing Micro-Frontends
Unit Testing
// header/src/Header.test.js
import React from "react";
import { render, screen } from "@testing-library/react";
import Header from "./Header";
test("renders header with navigation", () => {
render(<Header />);
expect(screen.getByText("Dashboard")).toBeInTheDocument();
});Integration Testing
// e2e/shell.test.js
import { expect, test } from "@playwright/test";
test("loads micro-frontends correctly", async ({ page }) => {
await page.goto("/");
// Wait for micro-frontends to load
await page.waitForSelector('[data-testid="header"]');
await page.waitForSelector('[data-testid="dashboard"]');
// Test interaction between micro-frontends
await page.click('[data-testid="dashboard-link"]');
await expect(page).toHaveURL("/dashboard");
});Deployment Strategies
Independent Deployment
# header/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: header
spec:
replicas: 2
selector:
matchLabels:
app: header
template:
metadata:
labels:
app: header
spec:
containers:
- name: header
image: header:v1.2.3
ports:
- containerPort: 3000Blue-Green Deployment
#!/bin/bash
# Deploy new version of header micro-frontend
# Create new deployment
kubectl apply -f header-v2-deployment.yaml
# Wait for readiness
kubectl wait --for=condition=available --timeout=300s deployment/header-v2
# Switch service to new version
kubectl patch service header -p '{"spec":{"selector":{"version":"v2"}}}'
# Clean up old deployment
kubectl delete deployment header-v1Canary Deployment
# header/canary-deployment.yaml
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: header
spec:
http:
- route:
- destination:
host: header
subset: v1
weight: 90
- destination:
host: header
subset: v2
weight: 10Performance Optimization
Code Splitting
// dashboard/src/Dashboard.js
import React, { Suspense } from "react";
const Analytics = React.lazy(() => import("./Analytics"));
const Reports = React.lazy(() => import("./Reports"));
function Dashboard() {
return (
<div>
<Suspense fallback={<div>Loading analytics...</div>}>
<Analytics />
</Suspense>
<Suspense fallback={<div>Loading reports...</div>}>
<Reports />
</Suspense>
</div>
);
}Bundle Analysis
// webpack.config.js
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: "static",
openAnalyzer: false,
}),
],
};Monitoring and Observability
Error Boundaries
// shared-error-boundary/src/ErrorBoundary.js
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// Log error to monitoring service
console.error("Micro-frontend error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return <div>Something went wrong in this section.</div>;
}
return this.props.children;
}
}Performance Monitoring
// shared-monitoring/src/performance.js
export const measureLoadTime = (microFrontendName) => {
const startTime = performance.now();
return () => {
const loadTime = performance.now() - startTime;
// Send to monitoring service
console.log(`${microFrontendName} loaded in ${loadTime}ms`);
};
};Conclusion
Micro-frontends provide a powerful way to scale frontend development, but they require careful planning and coordination. By implementing proper communication patterns, shared tooling, and deployment strategies, teams can maintain consistency while gaining the benefits of independent development and deployment.
The key to successful micro-frontend architecture is finding the right balance between autonomy and coordination, ensuring that the overall user experience remains seamless despite the underlying architectural complexity.