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
- What Are Micro-frontends?
- When to Use Micro-frontends
- Micro-frontends Architecture Patterns
- Module Federation: Deep Dive
- Single-SPA: Complete Guide
- Comparing Module Federation vs Single-SPA
- Implementation Strategies
- State Management Across Micro-frontends
- Routing and Navigation
- Styling and CSS Isolation
- Testing Micro-frontends
- Deployment and DevOps
- Common Pitfalls and Anti-Patterns
- Best Practices
- Conclusion
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 componentsconst 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 configurationconst loadRemote = async (remoteName: string, moduleName: string) => { const container = await import(/* webpackIgnore: true */ remoteName); const factory = await container.get(moduleName); return factory();};
// Usageconst ProductCatalog = await loadRemote("productCatalog", "./ProductCatalog");Shared State Management
// Shared state store (can be a remote module)import { createStore } from "zustand";
export const useSharedStore = createStore((set) => ({ user: null, setUser: (user) => set({ user }),}));
// Host applicationimport { 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 applicationregisterApplication({ name: "product-catalog", app: () => System.import("product-catalog"), activeWhen: ["/products"],});
// Register Vue applicationregisterApplication({ name: "checkout", app: () => System.import("checkout"), activeWhen: ["/checkout"],});
// Register Angular applicationregisterApplication({ name: "user-account", app: () => System.import("user-account"), activeWhen: ["/account"],});
// Start Single-SPAstart();React Application Setup
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
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
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
import { setPublicPath } from "systemjs-webpack-interop";
// Configure shared dependenciessetPublicPath("@company/shared", "/shared");
// Applications can import shared codeimport { utils } from "@company/shared";Cross-Application Communication
import { EventEmitter } from "events";
export const eventBus = new EventEmitter();
// Application Aimport { eventBus } from "@company/shared-events";eventBus.emit("user-logged-in", { userId: "123" });
// Application Bimport { eventBus } from "@company/shared-events";eventBus.on("user-logged-in", (data) => { console.log("User logged in:", data);});Loading States
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:
| Feature | Module Federation | Single-SPA |
|---|---|---|
| Primary Focus | Code sharing and component composition | Routing and application lifecycle |
| Build Tool | Webpack 5 or Vite | Framework agnostic (any bundler) |
| Integration Level | Component-level | Application-level |
| Technology Lock-in | Requires Webpack/Vite | Works with any build tool |
| Learning Curve | Moderate (Webpack knowledge helpful) | Steeper (lifecycle management) |
| Bundle Optimization | Automatic code splitting | Manual optimization required |
| Framework Support | React, Vue, Angular (via plugins) | React, Vue, Angular, Svelte, etc. |
| State Management | Shared modules | Event bus or shared modules |
| Best For | Component composition, shared UI | Independent 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 routingregisterApplication({ name: "product-catalog", app: () => System.import("product-catalog"), activeWhen: ["/products"],});
// Use Module Federation within applications for component sharingconst 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 monolithfunction 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 domain3. Shared Component Library
Create a shared component library for common UI:
export const Button: React.FC<ButtonProps> = ({ children, ...props }) => { return <button className="shared-button" {...props}>{children}</button>;};
// Both micro-frontends use itimport { Button } from '@company/shared-components';4. API Gateway Pattern
Use an API gateway to coordinate backend calls:
export const apiGateway = { products: "/api/products", checkout: "/api/checkout", users: "/api/users",};
// Micro-frontends use the gatewayfetch(apiGateway.products).then(/* ... */);5. Feature Flags
Use feature flags to control micro-frontend rollout:
export const featureFlags = { newCheckout: process.env.REACT_APP_NEW_CHECKOUT === "true", newProductCatalog: process.env.REACT_APP_NEW_PRODUCT_CATALOG === "true",};
// Conditional loadingif (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
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 AeventBus.emit("cart-updated", { itemCount: 5 });
// Usage in micro-frontend BeventBus.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-frontendimport { 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 URLconst 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 URLconst getFiltersFromURL = (): FilterState => { const params = new URLSearchParams(window.location.search); return { category: params.get("category") || "", priceRange: params.get("priceRange") || "", };};4. LocalStorage/SessionStorage
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 updateswindow.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:
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:
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
export const navigateTo = (path: string) => { window.history.pushState({}, "", path); window.dispatchEvent(new PopStateEvent("popstate"));};
// UsagenavigateTo("/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));}
// Usageimport styles from './ProductCatalog.module.css';
<div className={styles.productCatalog}> {/* Styles are scoped */}</div>CSS-in-JS
// Using styled-componentsimport styled from "styled-components";
const ProductCatalog = styled.div` padding: 20px; /* Styles are automatically scoped */`;Shadow DOM
// Isolate styles completelyclass 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
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
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
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:
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 serviceVersion Management
export const microFrontendVersions = { "product-catalog": "1.2.3", checkout: "2.1.0", "user-account": "1.0.5",};
// Host application can check versionsconst checkVersion = async (name: string) => { const response = await fetch(`/api/versions/${name}`); const { version } = await response.json(); return version;};Rollback Strategy
// Deploy with version tagsconst 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-frontendsimport { Product } from "product-catalog/types"; // Don't do this!
// ✅ Good: Shared types in separate packageimport { Product } from "@company/shared-types";❌ Anti-Pattern: Shared State Without Coordination
// ❌ Bad: Multiple sources of truth// Product catalog updates cartlocalStorage.setItem("cart", JSON.stringify(cart));
// Checkout reads cartconst cart = JSON.parse(localStorage.getItem("cart") || "[]");
// ✅ Good: Use shared store or event buseventBus.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 systemimport { Button } from '@company/design-system';❌ Anti-Pattern: Version Conflicts
// ❌ Bad: Different React versions// Product catalog uses React 18// Checkout uses React 17
// ✅ Good: Shared dependenciesshared: { 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
- Explore Module Federation documentation for Webpack implementation details
- Check out Single-SPA documentation for routing patterns
- Review Clean Architecture in Frontend for architectural principles
- Learn about React Server Components for modern React patterns
- Consider State Management in React for shared state patterns
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.