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: 3000

Blue-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-v1

Canary 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: 10

Performance 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.