Skip to main content

Micro-frontends Architecture: Module Federation and Single-SPA

Master micro-frontends architecture with Module Federation and Single-SPA. Learn when to use micro-frontends, implementation strategies, and practical examples for scalable frontend applications.

Table of Contents

Introduction

As frontend applications grow in size and complexity, traditional monolithic architectures begin to show their limitations. Large teams working on a single codebase face merge conflicts, deployment bottlenecks, and the challenge of scaling development velocity. Micro-frontends architecture addresses these challenges by breaking down large frontend applications into smaller, independently deployable applications that work together seamlessly.

Micro-frontends bring the benefits of microservices architecture to the frontend world. Just as backend teams split monolithic services into smaller, focused services, frontend teams can split monolithic applications into smaller, independently developed and deployed applications. This approach enables multiple teams to work in parallel, deploy independently, and scale development efforts without stepping on each other’s toes.

Two of the most popular solutions for implementing micro-frontends are Module Federation (built into Webpack 5 and supported by Vite) and Single-SPA (a framework-agnostic routing solution). Each approach has its strengths, trade-offs, and ideal use cases. Understanding both will help you make informed architectural decisions for your projects.

This comprehensive guide will teach you everything you need to know about micro-frontends architecture. You’ll learn when micro-frontends make sense, how Module Federation and Single-SPA work, and how to implement them in real-world scenarios. By the end, you’ll be able to evaluate whether micro-frontends are right for your project and choose the best implementation strategy.


What Are Micro-frontends?

Micro-frontends are an architectural pattern where a frontend application is composed of multiple smaller applications, each owned by different teams and deployed independently. These smaller applications, called “micro-frontends” or “micro-apps,” are integrated at runtime to create a cohesive user experience.

Core Concepts

Independent Development: Each micro-frontend is developed by a separate team using their preferred technology stack, development workflow, and deployment pipeline.

Independent Deployment: Micro-frontends can be deployed independently without affecting other parts of the application. This enables faster release cycles and reduces deployment risk.

Runtime Integration: Unlike traditional component libraries that are bundled at build time, micro-frontends are integrated at runtime. This allows for true independence between applications.

Technology Diversity: Different micro-frontends can use different frameworks (React, Vue, Angular, etc.) or even vanilla JavaScript, as long as they can communicate through agreed-upon interfaces.

Key Benefits

Team Autonomy: Teams can work independently without coordination overhead
Technology Flexibility: Choose the right tool for each micro-frontend
Independent Deployment: Deploy changes without coordinating with other teams
Scalability: Add new features as separate micro-frontends without bloating existing code
Fault Isolation: Bugs in one micro-frontend don’t crash the entire application

Challenges

Complexity: Managing multiple applications adds operational overhead
Bundle Size: Without careful optimization, total bundle size can increase
Consistency: Maintaining design consistency across teams requires discipline
Testing: End-to-end testing becomes more complex
Performance: Runtime integration can impact initial load time

💡 Tip: Micro-frontends are not always the right solution. Consider them when you have multiple teams, large codebases, or need independent deployment cycles. For smaller teams or applications, a monolithic approach might be simpler and more efficient.


When to Use Micro-frontends

Micro-frontends architecture isn’t a silver bullet. It adds complexity and should only be adopted when the benefits outweigh the costs. Here are scenarios where micro-frontends make sense:

✅ Good Use Cases

Large Organizations with Multiple Teams

  • Multiple frontend teams working on different features
  • Need for independent release cycles
  • Different teams have different expertise (React vs Vue vs Angular)

Legacy Application Migration

  • Gradually migrate from legacy technology to modern frameworks
  • Replace parts of the application incrementally
  • Avoid “big bang” rewrites

Complex Domain Boundaries

  • Clear separation between business domains (e.g., e-commerce: product catalog, checkout, user account)
  • Each domain can be owned by a dedicated team
  • Domains have different release cadences

Multi-Tenant Applications

  • Different tenants need different features
  • Customization per tenant without affecting others
  • White-label solutions with tenant-specific branding

Scalability Requirements

  • Application is too large for a single team to maintain effectively
  • Need to scale development velocity horizontally
  • Codebase has become difficult to navigate and modify

❌ When to Avoid Micro-frontends

Small Teams or Applications

  • Single team working on the application
  • Application is small enough to manage as a monolith
  • Overhead doesn’t justify the benefits

Tight Coupling Between Features

  • Features share significant amounts of code
  • Changes frequently span multiple features
  • Strong interdependencies between components

Performance-Critical Applications

  • Initial load time is critical
  • Bundle size must be minimized
  • Runtime overhead is unacceptable

Limited DevOps Maturity

  • Team lacks experience with multiple deployments
  • No CI/CD infrastructure for multiple applications
  • Limited monitoring and observability capabilities

⚠️ Important: Micro-frontends require strong engineering practices, including CI/CD pipelines, monitoring, and cross-team communication. Without these foundations, micro-frontends can become a maintenance nightmare.


Micro-frontends Architecture Patterns

There are several patterns for implementing micro-frontends, each with different trade-offs:

1. Build-Time Integration

Components are integrated at build time, similar to traditional component libraries:

// Build-time integration (traditional approach)
import ProductCatalog from "@company/product-catalog";
import Checkout from "@company/checkout";
function App() {
return (
<div>
<ProductCatalog />
<Checkout />
</div>
);
}

Pros: Simple, familiar approach
Cons: Requires coordination for deployments, no true independence

2. Server-Side Integration (SSI)

Micro-frontends are integrated on the server using Server-Side Includes or Edge-Side Includes:

<!-- Server-side integration -->
<html>
<head>
<title>E-Commerce App</title>
</head>
<body>
<!--# include virtual="/header.html" -->
<!--# include virtual="/product-catalog.html" -->
<!--# include virtual="/checkout.html" -->
<!--# include virtual="/footer.html" -->
</body>
</html>

Pros: Simple, works with any technology
Cons: Limited interactivity, requires server-side rendering

3. Client-Side Integration (Runtime)

Micro-frontends are integrated in the browser at runtime. This is where Module Federation and Single-SPA excel:

// Runtime integration (Module Federation example)
const ProductCatalog = React.lazy(
() => import("productCatalog/ProductCatalog"),
);
const Checkout = React.lazy(() => import("checkout/Checkout"));
function App() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<ProductCatalog />
<Checkout />
</Suspense>
</div>
);
}

Pros: True independence, runtime flexibility
Cons: More complex, requires careful orchestration

4. Iframe Integration

Each micro-frontend runs in its own iframe:

<iframe src="https://product-catalog.example.com" />
<iframe src="https://checkout.example.com" />

Pros: Complete isolation, works with any technology
Cons: Communication complexity, styling challenges, performance overhead

🔍 Deep Dive: Most modern micro-frontends implementations use client-side runtime integration because it provides the best balance of independence and integration. Module Federation and Single-SPA both use this pattern but with different approaches.


Module Federation: Deep Dive

Module Federation is a Webpack 5 feature (and available in Vite via plugins) that allows JavaScript applications to dynamically load code from other applications at runtime. It enables true code sharing and runtime integration between independently built applications.

How Module Federation Works

Module Federation uses a “host” and “remote” model:

  • Host Application: The main application that consumes remote modules
  • Remote Application: A micro-frontend that exposes modules to be consumed by hosts
  • Shared Dependencies: Common libraries (React, React-DOM, etc.) that are shared between host and remotes

Setting Up Module Federation with Webpack

Host Application Configuration

// webpack.config.js (Host Application)
const ModuleFederationPlugin = require("@module-federation/webpack");
module.exports = {
mode: "production",
entry: "./src/index.js",
plugins: [
new ModuleFederationPlugin({
name: "host",
remotes: {
productCatalog:
"productCatalog@https://product-catalog.example.com/remoteEntry.js",
checkout: "checkout@https://checkout.example.com/remoteEntry.js",
},
shared: {
react: {
singleton: true,
requiredVersion: "^18.0.0",
},
"react-dom": {
singleton: true,
requiredVersion: "^18.0.0",
},
},
}),
],
};

Remote Application Configuration

// webpack.config.js (Remote Application - Product Catalog)
const ModuleFederationPlugin = require("@module-federation/webpack");
module.exports = {
mode: "production",
entry: "./src/index.js",
plugins: [
new ModuleFederationPlugin({
name: "productCatalog",
filename: "remoteEntry.js",
exposes: {
"./ProductCatalog": "./src/components/ProductCatalog",
"./ProductList": "./src/components/ProductList",
},
shared: {
react: {
singleton: true,
requiredVersion: "^18.0.0",
},
"react-dom": {
singleton: true,
requiredVersion: "^18.0.0",
},
},
}),
],
};

Using Module Federation in React

Host Application Usage

// src/App.tsx (Host Application)
import React, { Suspense, lazy } from 'react';
// Dynamically import remote components
const ProductCatalog = lazy(() => import('productCatalog/ProductCatalog'));
const Checkout = lazy(() => import('checkout/Checkout'));
function App() {
return (
<div className="app">
<header>
<h1>E-Commerce Platform</h1>
</header>
<main>
<Suspense fallback={<div>Loading Product Catalog...</div>}>
<ProductCatalog />
</Suspense>
<Suspense fallback={<div>Loading Checkout...</div>}>
<Checkout />
</Suspense>
</main>
</div>
);
}
export default App;

Remote Application Component

// src/components/ProductCatalog.tsx (Remote Application)
import React, { useState, useEffect } from 'react';
type Product = {
id: string;
name: string;
price: number;
};
const ProductCatalog: React.FC = () => {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch products from API
fetch('/api/products')
.then((res) => res.json())
.then((data) => {
setProducts(data);
setLoading(false);
});
}, []);
if (loading) {
return <div>Loading products...</div>;
}
return (
<div className="product-catalog">
<h2>Product Catalog</h2>
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
</div>
);
};
export default ProductCatalog;

Module Federation with Vite

Vite supports Module Federation through the @originjs/vite-plugin-federation plugin:

// vite.config.ts (Host Application)
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
export default defineConfig({
plugins: [
react(),
federation({
name: "host",
remotes: {
productCatalog: "http://localhost:3001/assets/remoteEntry.js",
checkout: "http://localhost:3002/assets/remoteEntry.js",
},
shared: ["react", "react-dom"],
}),
],
build: {
target: "esnext",
minify: false,
cssCodeSplit: false,
},
});

Advanced Module Federation Patterns

Dynamic Remote Loading

// Dynamically load remotes based on configuration
const loadRemote = async (remoteName: string, moduleName: string) => {
const container = await import(/* webpackIgnore: true */ remoteName);
const factory = await container.get(moduleName);
return factory();
};
// Usage
const ProductCatalog = await loadRemote("productCatalog", "./ProductCatalog");

Shared State Management

shared-store/src/store.ts
// Shared state store (can be a remote module)
import { createStore } from "zustand";
export const useSharedStore = createStore((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
// Host application
import { useSharedStore } from "sharedStore/store";
function App() {
const user = useSharedStore((state) => state.user);
// ...
}

Best Practice: Use singleton mode for shared dependencies to ensure only one instance is loaded, preventing version conflicts and reducing bundle size.


Single-SPA: Complete Guide

Single-SPA is a framework-agnostic JavaScript router for frontend microservices. Unlike Module Federation, which focuses on code sharing, Single-SPA focuses on routing and lifecycle management for multiple applications.

Core Concepts

Single-SPA treats each micro-frontend as an “application” with its own lifecycle:

  • Registration: Applications are registered with Single-SPA
  • Lifecycle Methods: Each application implements mount, unmount, and update methods
  • Routing: Single-SPA routes to different applications based on URL
  • Framework Agnostic: Works with React, Vue, Angular, or vanilla JavaScript

Setting Up Single-SPA

Root Configuration

// root-config.js (Main Application)
import { registerApplication, start } from "single-spa";
// Register React application
registerApplication({
name: "product-catalog",
app: () => System.import("product-catalog"),
activeWhen: ["/products"],
});
// Register Vue application
registerApplication({
name: "checkout",
app: () => System.import("checkout"),
activeWhen: ["/checkout"],
});
// Register Angular application
registerApplication({
name: "user-account",
app: () => System.import("user-account"),
activeWhen: ["/account"],
});
// Start Single-SPA
start();

React Application Setup

product-catalog/src/product-catalog.js
import React from "react";
import ReactDOM from "react-dom";
import singleSpaReact from "single-spa-react";
import App from "./App";
const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: App,
errorBoundary(err, info, props) {
return <div>Error: {err.message}</div>;
},
});
export const { bootstrap, mount, unmount } = lifecycles;

Vue Application Setup

checkout/src/checkout.js
import Vue from "vue";
import singleSpaVue from "single-spa-vue";
import App from "./App.vue";
const lifecycles = singleSpaVue({
Vue,
appOptions: {
render(h) {
return h(App);
},
},
});
export const { bootstrap, mount, unmount } = lifecycles;

Single-SPA with React Router

product-catalog/src/App.tsx
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import ProductList from './components/ProductList';
import ProductDetail from './components/ProductDetail';
const App: React.FC = () => {
return (
<BrowserRouter basename="/products">
<Routes>
<Route path="/" element={<ProductList />} />
<Route path="/:id" element={<ProductDetail />} />
</Routes>
</BrowserRouter>
);
};
export default App;

Advanced Single-SPA Patterns

Shared Dependencies

root-config.js
import { setPublicPath } from "systemjs-webpack-interop";
// Configure shared dependencies
setPublicPath("@company/shared", "/shared");
// Applications can import shared code
import { utils } from "@company/shared";

Cross-Application Communication

shared-events.js
import { EventEmitter } from "events";
export const eventBus = new EventEmitter();
// Application A
import { eventBus } from "@company/shared-events";
eventBus.emit("user-logged-in", { userId: "123" });
// Application B
import { eventBus } from "@company/shared-events";
eventBus.on("user-logged-in", (data) => {
console.log("User logged in:", data);
});

Loading States

root-config.js
registerApplication({
name: "product-catalog",
app: () => System.import("product-catalog"),
activeWhen: ["/products"],
customProps: {
loadingComponent: () => import("./loading-component"),
},
});

💡 Tip: Single-SPA works best when each micro-frontend handles its own routing internally. Use Single-SPA for top-level routing between applications, and React Router, Vue Router, or Angular Router for routing within each application.


Comparing Module Federation vs Single-SPA

Both Module Federation and Single-SPA solve the micro-frontends problem but with different approaches:

FeatureModule FederationSingle-SPA
Primary FocusCode sharing and component compositionRouting and application lifecycle
Build ToolWebpack 5 or ViteFramework agnostic (any bundler)
Integration LevelComponent-levelApplication-level
Technology Lock-inRequires Webpack/ViteWorks with any build tool
Learning CurveModerate (Webpack knowledge helpful)Steeper (lifecycle management)
Bundle OptimizationAutomatic code splittingManual optimization required
Framework SupportReact, Vue, Angular (via plugins)React, Vue, Angular, Svelte, etc.
State ManagementShared modulesEvent bus or shared modules
Best ForComponent composition, shared UIIndependent applications, routing

When to Choose Module Federation

✅ You want to compose components from different applications
✅ You’re using Webpack 5 or Vite
✅ You need fine-grained code sharing
✅ Applications share many components
✅ You want automatic bundle optimization

When to Choose Single-SPA

✅ You have completely independent applications
✅ You need framework-agnostic solution
✅ Applications have clear routing boundaries
✅ You want maximum flexibility in build tools
✅ Applications rarely share components

Hybrid Approach

You can combine both approaches:

// Use Single-SPA for routing
registerApplication({
name: "product-catalog",
app: () => System.import("product-catalog"),
activeWhen: ["/products"],
});
// Use Module Federation within applications for component sharing
const SharedButton = lazy(() => import("shared-components/Button"));

🔍 Deep Dive: Many organizations start with Single-SPA for routing and gradually adopt Module Federation for shared components as they identify common UI patterns.


Implementation Strategies

Implementing micro-frontends requires careful planning and coordination. Here are proven strategies:

1. Strangler Fig Pattern

Gradually replace parts of a monolithic application:

// Phase 1: Add new micro-frontend alongside monolith
function App() {
const useNewCheckout = useFeatureFlag('new-checkout');
return (
<div>
{useNewCheckout ? (
<Suspense fallback={<div>Loading...</div>}>
<NewCheckout /> {/* Micro-frontend */}
</Suspense>
) : (
<OldCheckout /> {/* Monolith */}
)}
</div>
);
}

2. Domain-Driven Design Boundaries

Organize micro-frontends by business domains:

e-commerce-platform/
├── product-catalog/ # Product domain
├── checkout/ # Order domain
├── user-account/ # User domain
└── recommendations/ # Recommendation domain

3. Shared Component Library

Create a shared component library for common UI:

shared-components/src/Button.tsx
export const Button: React.FC<ButtonProps> = ({ children, ...props }) => {
return <button className="shared-button" {...props}>{children}</button>;
};
// Both micro-frontends use it
import { Button } from '@company/shared-components';

4. API Gateway Pattern

Use an API gateway to coordinate backend calls:

api-gateway.ts
export const apiGateway = {
products: "/api/products",
checkout: "/api/checkout",
users: "/api/users",
};
// Micro-frontends use the gateway
fetch(apiGateway.products).then(/* ... */);

5. Feature Flags

Use feature flags to control micro-frontend rollout:

feature-flags.ts
export const featureFlags = {
newCheckout: process.env.REACT_APP_NEW_CHECKOUT === "true",
newProductCatalog: process.env.REACT_APP_NEW_PRODUCT_CATALOG === "true",
};
// Conditional loading
if (featureFlags.newCheckout) {
// Load new checkout micro-frontend
}

State Management Across Micro-frontends

Managing state across micro-frontends is one of the biggest challenges. Here are common patterns:

1. Event Bus Pattern

shared/event-bus.ts
type EventCallback = (data: any) => void;
class EventBus {
private listeners: Map<string, EventCallback[]> = new Map();
on(event: string, callback: EventCallback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(callback);
}
emit(event: string, data: any) {
const callbacks = this.listeners.get(event) || [];
callbacks.forEach((callback) => callback(data));
}
off(event: string, callback: EventCallback) {
const callbacks = this.listeners.get(event) || [];
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
export const eventBus = new EventBus();
// Usage in micro-frontend A
eventBus.emit("cart-updated", { itemCount: 5 });
// Usage in micro-frontend B
eventBus.on("cart-updated", (data) => {
updateCartBadge(data.itemCount);
});

2. Shared State Store

// shared/store.ts (using Zustand)
import { create } from "zustand";
type SharedState = {
user: User | null;
cart: CartItem[];
setUser: (user: User) => void;
addToCart: (item: CartItem) => void;
};
export const useSharedStore = create<SharedState>((set) => ({
user: null,
cart: [],
setUser: (user) => set({ user }),
addToCart: (item) => set((state) => ({ cart: [...state.cart, item] })),
}));
// Usage in any micro-frontend
import { useSharedStore } from "@company/shared-store";
function Header() {
const user = useSharedStore((state) => state.user);
const cartCount = useSharedStore((state) => state.cart.length);
// ...
}

3. URL-Based State

// Use URL parameters for shared state
// Micro-frontend A updates URL
const updateFilters = (filters: FilterState) => {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
params.set(key, String(value));
});
window.history.pushState({}, "", `?${params.toString()}`);
};
// Micro-frontend B reads from URL
const getFiltersFromURL = (): FilterState => {
const params = new URLSearchParams(window.location.search);
return {
category: params.get("category") || "",
priceRange: params.get("priceRange") || "",
};
};

4. LocalStorage/SessionStorage

shared/storage.ts
export const sharedStorage = {
get: <T>(key: string): T | null => {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
},
set: <T>(key: string, value: T): void => {
localStorage.setItem(key, JSON.stringify(value));
// Notify other micro-frontends
window.dispatchEvent(
new CustomEvent("storage-updated", { detail: { key } }),
);
},
};
// Listen for storage updates
window.addEventListener("storage-updated", (event) => {
const { key } = event.detail;
// Update local state
});

Best Practice: Use a combination of patterns. Use event bus for ephemeral events, shared store for application state, and URL for shareable state.


Routing and Navigation

Routing in micro-frontends requires coordination between applications:

Single-SPA Routing

Single-SPA handles routing automatically:

root-config.js
registerApplication({
name: "product-catalog",
app: () => System.import("product-catalog"),
activeWhen: (location) => location.pathname.startsWith("/products"),
});
registerApplication({
name: "checkout",
app: () => System.import("checkout"),
activeWhen: (location) => location.pathname.startsWith("/checkout"),
});

Module Federation Routing

With Module Federation, you need to handle routing manually:

host/src/App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';
const ProductCatalog = lazy(() => import('productCatalog/ProductCatalog'));
const Checkout = lazy(() => import('checkout/Checkout'));
function App() {
return (
<BrowserRouter>
<Routes>
<Route
path="/products/*"
element={
<Suspense fallback={<div>Loading...</div>}>
<ProductCatalog />
</Suspense>
}
/>
<Route
path="/checkout/*"
element={
<Suspense fallback={<div>Loading...</div>}>
<Checkout />
</Suspense>
}
/>
</Routes>
</BrowserRouter>
);
}

Cross-Application Navigation

shared/navigation.ts
export const navigateTo = (path: string) => {
window.history.pushState({}, "", path);
window.dispatchEvent(new PopStateEvent("popstate"));
};
// Usage
navigateTo("/checkout");

Styling and CSS Isolation

Preventing CSS conflicts between micro-frontends is crucial:

CSS Modules

// product-catalog/src/ProductCatalog.module.css
.productCatalog {
padding: 20px;
}
.productList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
// Usage
import styles from './ProductCatalog.module.css';
<div className={styles.productCatalog}>
{/* Styles are scoped */}
</div>

CSS-in-JS

// Using styled-components
import styled from "styled-components";
const ProductCatalog = styled.div`
padding: 20px;
/* Styles are automatically scoped */
`;

Shadow DOM

// Isolate styles completely
class ProductCatalogElement extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
/* Styles are isolated */
.product-catalog { padding: 20px; }
</style>
<div class="product-catalog">...</div>
`;
}
}

CSS Prefixes

/* Use unique prefixes for each micro-frontend */
.product-catalog__container {
/* Product catalog styles */
}
.checkout__container {
/* Checkout styles */
}

Best Practice: Use CSS Modules or CSS-in-JS for automatic scoping. Avoid global CSS unless using a design system with namespaced classes.


Testing Micro-frontends

Testing micro-frontends requires testing individual applications and integration:

Unit Testing Individual Applications

product-catalog/src/ProductCatalog.test.tsx
import { render, screen } from '@testing-library/react';
import ProductCatalog from './ProductCatalog';
describe('ProductCatalog', () => {
it('renders product list', async () => {
render(<ProductCatalog />);
expect(await screen.findByText('Product Catalog')).toBeInTheDocument();
});
});

Integration Testing

e2e/integration.test.ts
import { test, expect } from "@playwright/test";
test("navigate between micro-frontends", async ({ page }) => {
await page.goto("http://localhost:3000/products");
await expect(page.locator("h1")).toContainText("Product Catalog");
await page.click('a[href="/checkout"]');
await expect(page.locator("h1")).toContainText("Checkout");
});

Mocking Remote Applications

host/src/__mocks__/productCatalog.ts
export const ProductCatalog = () => <div>Mocked Product Catalog</div>;

Deployment and DevOps

Deploying micro-frontends requires careful orchestration:

Independent Deployment

Each micro-frontend has its own CI/CD pipeline:

.github/workflows/deploy-product-catalog.yml
name: Deploy Product Catalog
on:
push:
branches: [main]
paths:
- "product-catalog/**"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build
run: |
cd product-catalog
npm install
npm run build
- name: Deploy
run: |
# Deploy to CDN or hosting service

Version Management

shared/version-registry.ts
export const microFrontendVersions = {
"product-catalog": "1.2.3",
checkout: "2.1.0",
"user-account": "1.0.5",
};
// Host application can check versions
const checkVersion = async (name: string) => {
const response = await fetch(`/api/versions/${name}`);
const { version } = await response.json();
return version;
};

Rollback Strategy

// Deploy with version tags
const loadMicroFrontend = async (name: string, version?: string) => {
const url = version
? `https://cdn.example.com/${name}/${version}/remoteEntry.js`
: `https://cdn.example.com/${name}/latest/remoteEntry.js`;
return import(/* webpackIgnore: true */ url);
};

Common Pitfalls and Anti-Patterns

❌ Anti-Pattern: Tight Coupling

// ❌ Bad: Direct imports between micro-frontends
import { Product } from "product-catalog/types"; // Don't do this!
// ✅ Good: Shared types in separate package
import { Product } from "@company/shared-types";

❌ Anti-Pattern: Shared State Without Coordination

// ❌ Bad: Multiple sources of truth
// Product catalog updates cart
localStorage.setItem("cart", JSON.stringify(cart));
// Checkout reads cart
const cart = JSON.parse(localStorage.getItem("cart") || "[]");
// ✅ Good: Use shared store or event bus
eventBus.emit("cart-updated", cart);

❌ Anti-Pattern: Inconsistent Styling

// ❌ Bad: Each micro-frontend defines its own button styles
// Product catalog
.button { background: blue; }
// Checkout
.button { background: green; }
// ✅ Good: Use shared design system
import { Button } from '@company/design-system';

❌ Anti-Pattern: Version Conflicts

// ❌ Bad: Different React versions
// Product catalog uses React 18
// Checkout uses React 17
// ✅ Good: Shared dependencies
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
}

⚠️ Warning: Micro-frontends require discipline. Without proper coordination, you’ll end up with inconsistent UX, version conflicts, and maintenance nightmares.


Best Practices

✅ Do’s

Establish Clear Boundaries

  • Define ownership and responsibilities
  • Document APIs and contracts between micro-frontends
  • Use TypeScript for type safety across boundaries

Implement Design System

  • Shared component library for consistency
  • Design tokens for colors, spacing, typography
  • Regular design reviews across teams

Monitor and Observe

  • Track errors per micro-frontend
  • Monitor bundle sizes and load times
  • Set up alerts for deployment failures

Version Management

  • Semantic versioning for micro-frontends
  • Version registry for compatibility checking
  • Gradual rollout with feature flags

Communication Patterns

  • Event bus for loose coupling
  • Shared types for contracts
  • Documentation for APIs

❌ Don’ts

Don’t Over-Engineer

  • Start simple, add complexity only when needed
  • Don’t create micro-frontends for small features
  • Avoid premature optimization

Don’t Ignore Performance

  • Monitor bundle sizes
  • Implement code splitting
  • Use lazy loading strategically

Don’t Skip Testing

  • Test individual applications
  • Test integration points
  • Test cross-application flows

Don’t Neglect Documentation

  • Document APIs and contracts
  • Document deployment processes
  • Document troubleshooting guides

💡 Pro Tip: Start with a single micro-frontend as a proof of concept. Learn from the experience before scaling to multiple teams.


Conclusion

Micro-frontends architecture offers a powerful solution for scaling frontend development in large organizations. By breaking down monolithic applications into smaller, independently deployable applications, teams can work autonomously, deploy faster, and scale development velocity.

Module Federation and Single-SPA are two excellent tools for implementing micro-frontends, each with its strengths. Module Federation excels at component composition and code sharing, while Single-SPA provides framework-agnostic routing and lifecycle management. Many organizations use both tools together, leveraging Single-SPA for routing and Module Federation for shared components.

However, micro-frontends are not without challenges. They add complexity, require strong engineering practices, and need careful coordination between teams. Before adopting micro-frontends, evaluate whether the benefits justify the costs for your specific situation.

If you’re building a new application with a small team, a monolithic approach might be simpler and more efficient. But if you’re working in a large organization with multiple teams, need independent deployment cycles, or are migrating from legacy systems, micro-frontends can provide significant benefits.

The key to success with micro-frontends is starting small, establishing clear boundaries, maintaining consistency through design systems, and continuously monitoring and improving your architecture. With the right approach, micro-frontends can help you build scalable, maintainable frontend applications that grow with your organization.

Next Steps

Whether you choose Module Federation, Single-SPA, or a combination of both, the principles of micro-frontends architecture will help you build applications that scale with your team and your users’ needs.