Micro Frontends

9 min read
Rapid overview

Micro Frontends

Overview

Micro frontends extend the concept of microservices to the frontend, allowing teams to build, deploy, and maintain independent features or applications that compose into a unified user experience.

Core Concepts

What Are Micro Frontends?

  • Independent Deployment: Each micro frontend can be deployed independently
  • Technology Agnostic: Teams can choose different frameworks (React, Angular, Vue, etc.)
  • Team Ownership: Each team owns a specific business domain end-to-end
  • Isolated Development: Teams work in isolation without coordination overhead
  • Incremental Upgrades: Update parts of the application without rewriting everything

When to Use Micro Frontends

Good Use Cases:

  • Large applications with multiple teams
  • Applications that need technology diversity
  • Long-lived applications requiring gradual modernization
  • Organizations with independent business domains
  • Teams that need to scale independently

Not Recommended For:

  • Small applications with a single team
  • Applications requiring tight integration
  • Performance-critical applications (overhead considerations)
  • Short-lived projects

Implementation Approaches

1. Build-Time Integration

Micro frontends are composed at build time using package management.

{
  "name": "shell-app",
  "dependencies": {
    "@company/header-mfe": "^1.2.0",
    "@company/product-mfe": "^2.1.0",
    "@company/checkout-mfe": "^1.0.5"
  }
}

Pros:

  • Simple to implement
  • Good performance (single bundle)
  • Type safety across boundaries

Cons:

  • All micro frontends must be deployed together
  • Difficult to have independent deployments
  • Versioning can be complex

2. Runtime Integration via iframes

Each micro frontend runs in its own iframe.

<div class="app-container">
  <iframe src="https://header.example.com" />
  <iframe src="https://products.example.com" />
  <iframe src="https://footer.example.com" />
</div>

Pros:

  • Complete isolation (CSS, JS, global state)
  • Easy to implement
  • Technology agnostic

Cons:

  • Performance overhead
  • Difficult routing and deep linking
  • Complex communication between frames
  • Poor UX (scrolling, responsiveness)
  • SEO challenges

3. Runtime Integration via JavaScript

Micro frontends are loaded dynamically at runtime.

// Shell application
const loadMicroFrontend = async (name, host) => {
  const script = document.createElement('script');
  script.src = `${host}/remoteEntry.js`;
  script.onload = () => {
    window[name].init({
      sharedDependencies: {
        react: React,
        'react-dom': ReactDOM
      }
    });
  };
  document.head.appendChild(script);
};

await loadMicroFrontend('headerMFE', 'https://header.example.com');
await loadMicroFrontend('productMFE', 'https://products.example.com');

Pros:

  • Independent deployments
  • Runtime composition
  • Shared dependencies

Cons:

  • More complex setup
  • Runtime overhead
  • Version management complexity

4. Web Components

Use web components as a standard for creating micro frontends.

// Product micro frontend
class ProductCatalog extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host { display: block; }
        .product { padding: 1rem; }
      </style>
      <div class="product-catalog">
        <h2>Products</h2>
        <div id="products"></div>
      </div>
    `;
    this.loadProducts();
  }

  async loadProducts() {
    const products = await fetch('/api/products').then(r => r.json());
    this.renderProducts(products);
  }

  renderProducts(products) {
    const container = this.shadowRoot.querySelector('#products');
    container.innerHTML = products.map(p => `
      <div class="product">${p.name} - $${p.price}</div>
    `).join('');
  }
}

customElements.define('product-catalog', ProductCatalog);

Usage:

<product-catalog></product-catalog>

Pros:

  • Standards-based
  • Great encapsulation
  • Framework agnostic
  • Native browser support

Cons:

  • Learning curve
  • Limited framework integration
  • SSR challenges

5. Module Federation (Webpack 5+)

Share code and dependencies across micro frontends at runtime.

// webpack.config.js for Header MFE
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'header',
      filename: 'remoteEntry.js',
      exposes: {
        './Header': './src/Header'
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

// webpack.config.js for Shell App
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        header: 'header@https://header.example.com/remoteEntry.js',
        products: 'products@https://products.example.com/remoteEntry.js'
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

// Usage in Shell App
import React, { lazy, Suspense } from 'react';

const Header = lazy(() => import('header/Header'));
const Products = lazy(() => import('products/ProductList'));

function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading header...</div>}>
        <Header />
      </Suspense>
      <Suspense fallback={<div>Loading products...</div>}>
        <Products />
      </Suspense>
    </div>
  );
}

Pros:

  • True runtime integration
  • Shared dependencies
  • Independent deployments
  • Built into Webpack 5

Cons:

  • Webpack-specific
  • Complex configuration
  • Debugging can be difficult

Communication Between Micro Frontends

1. Custom Events

// Micro Frontend A - Dispatch event
const event = new CustomEvent('cart:updated', {
  detail: { itemCount: 5, total: 99.99 }
});
window.dispatchEvent(event);

// Micro Frontend B - Listen for event
window.addEventListener('cart:updated', (event) => {
  console.log('Cart updated:', event.detail);
  updateCartBadge(event.detail.itemCount);
});

2. Shared Event Bus

// eventBus.js (shared library)
class EventBus {
  constructor() {
    this.events = {};
  }

  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
    return () => this.off(event, callback);
  }

  off(event, callback) {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    }
  }

  emit(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(callback => callback(data));
    }
  }
}

export const eventBus = new EventBus();

// Usage in MFE A
import { eventBus } from '@company/shared-event-bus';
eventBus.emit('user:login', { userId: 123, name: 'John' });

// Usage in MFE B
import { eventBus } from '@company/shared-event-bus';
eventBus.on('user:login', (user) => {
  console.log('User logged in:', user);
});

3. Shared State (Redux, Zustand)

// Shared store
import create from 'zustand';

export const useGlobalStore = create((set) => ({
  user: null,
  cart: [],
  setUser: (user) => set({ user }),
  addToCart: (item) => set((state) => ({ cart: [...state.cart, item] }))
}));

// MFE A
import { useGlobalStore } from '@company/shared-store';

function Header() {
  const user = useGlobalStore(state => state.user);
  return <div>Welcome, {user?.name}</div>;
}

// MFE B
import { useGlobalStore } from '@company/shared-store';

function ProductCard({ product }) {
  const addToCart = useGlobalStore(state => state.addToCart);
  return (
    <button onClick={() => addToCart(product)}>
      Add to Cart
    </button>
  );
}

4. Props/Callbacks (Parent-Child)

// Shell App
import ProductList from 'products/ProductList';

function App() {
  const [selectedProduct, setSelectedProduct] = useState(null);

  return (
    <div>
      <ProductList onProductSelect={setSelectedProduct} />
      {selectedProduct && <ProductDetails product={selectedProduct} />}
    </div>
  );
}

Routing in Micro Frontends

Centralized Routing (Shell Controls)

// Shell App with React Router
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';

const Header = lazy(() => import('header/Header'));
const Products = lazy(() => import('products/ProductList'));
const ProductDetail = lazy(() => import('products/ProductDetail'));
const Checkout = lazy(() => import('checkout/CheckoutFlow'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading...</div>}>
        <Header />
        <Routes>
          <Route path="/" element={<Products />} />
          <Route path="/products/:id" element={<ProductDetail />} />
          <Route path="/checkout" element={<Checkout />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Decentralized Routing (MFEs Control Own Routes)

// Shell App
import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <Header />
      <Routes>
        <Route path="/products/*" element={<ProductsApp />} />
        <Route path="/checkout/*" element={<CheckoutApp />} />
        <Route path="/account/*" element={<AccountApp />} />
      </Routes>
    </BrowserRouter>
  );
}

// Products MFE (internally manages /products/*)
import { Routes, Route } from 'react-router-dom';

function ProductsApp() {
  return (
    <Routes>
      <Route index element={<ProductList />} />
      <Route path=":id" element={<ProductDetail />} />
      <Route path="search" element={<ProductSearch />} />
    </Routes>
  );
}

Styling Strategies

1. CSS Modules / Scoped Styles

// ProductCard.module.css
.card {
  border: 1px solid #ccc;
  padding: 1rem;
}

// ProductCard.jsx
import styles from './ProductCard.module.css';

function ProductCard({ product }) {
  return <div className={styles.card}>{product.name}</div>;
}

2. CSS-in-JS with Unique Class Names

import styled from 'styled-components';

// Each MFE uses a unique prefix
const Card = styled.div.withConfig({
  displayName: 'ProductCard' // Generates unique class
})`
  border: 1px solid #ccc;
  padding: 1rem;
`;

3. Shadow DOM (Web Components)

class ProductCard extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        /* Styles are scoped to this component */
        .card { border: 1px solid #ccc; }
      </style>
      <div class="card">Product</div>
    `;
  }
}

4. Design System with CSS Variables

/* Shared design tokens */
:root {
  --color-primary: #007bff;
  --color-secondary: #6c757d;
  --spacing-unit: 8px;
  --font-family: 'Inter', sans-serif;
}

/* Each MFE uses the tokens */
.product-card {
  color: var(--color-primary);
  padding: calc(var(--spacing-unit) * 2);
  font-family: var(--font-family);
}

Best Practices

1. Define Clear Boundaries

  • Each micro frontend should own a specific business domain
  • Avoid splitting UI components arbitrarily
  • Use vertical slicing (features) not horizontal (layers)

2. Shared Dependencies

// Share common libraries to reduce bundle size
shared: {
  react: { singleton: true, requiredVersion: '^18.0.0' },
  'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  '@company/design-system': { singleton: true }
}

3. Versioning Strategy

// Semantic versioning for micro frontends
{
  "name": "@company/header-mfe",
  "version": "2.1.4",
  // Major: Breaking changes
  // Minor: New features
  // Patch: Bug fixes
}

4. Error Boundaries

class MicroFrontendErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('MFE Error:', error, errorInfo);
    // Log to error tracking service
  }

  render() {
    if (this.state.hasError) {
      return <div>This section is temporarily unavailable</div>;
    }
    return this.props.children;
  }
}

// Wrap each MFE
<MicroFrontendErrorBoundary>
  <ProductsMFE />
</MicroFrontendErrorBoundary>

5. Performance Monitoring

// Track MFE load times
const loadMFE = async (name, url) => {
  const startTime = performance.now();

  try {
    await loadRemoteModule(name, url);
    const loadTime = performance.now() - startTime;

    // Send to analytics
    analytics.track('mfe_loaded', {
      name,
      loadTime,
      success: true
    });
  } catch (error) {
    analytics.track('mfe_load_failed', {
      name,
      error: error.message
    });
  }
};

Testing Strategies

Unit Testing

Test each micro frontend independently.

// ProductCard.test.jsx
import { render, screen } from '@testing-library/react';
import ProductCard from './ProductCard';

test('displays product name', () => {
  const product = { id: 1, name: 'Widget', price: 9.99 };
  render(<ProductCard product={product} />);
  expect(screen.getByText('Widget')).toBeInTheDocument();
});

Integration Testing

Test communication between micro frontends.

// Integration test
import { render, fireEvent } from '@testing-library/react';
import { eventBus } from '@company/shared-event-bus';

test('cart updates when product added', () => {
  const cartListener = jest.fn();
  eventBus.on('cart:updated', cartListener);

  const { getByText } = render(<ProductCard product={product} />);
  fireEvent.click(getByText('Add to Cart'));

  expect(cartListener).toHaveBeenCalledWith({
    itemCount: 1,
    items: [product]
  });
});

E2E Testing

Test the composed application.

// Cypress E2E test
describe('Product Purchase Flow', () => {
  it('completes full purchase', () => {
    cy.visit('/products');
    cy.get('[data-testid="product-1"]').click();
    cy.get('[data-testid="add-to-cart"]').click();
    cy.get('[data-testid="cart-badge"]').should('contain', '1');
    cy.get('[data-testid="checkout-button"]').click();
    cy.url().should('include', '/checkout');
  });
});

Common Pitfalls and Solutions

1. Version Conflicts

Problem: Different micro frontends use different versions of shared libraries.

Solution:

// Use singleton pattern for critical libraries
shared: {
  react: {
    singleton: true,
    strictVersion: true,
    requiredVersion: '^18.2.0'
  }
}

2. Performance Issues

Problem: Loading too many micro frontends slows down the app.

Solution:

  • Use code splitting and lazy loading
  • Implement intelligent preloading
  • Monitor bundle sizes
// Lazy load with prefetch
const ProductsMFE = lazy(() => {
  const promise = import('products/ProductList');
  // Prefetch during idle time
  requestIdleCallback(() => promise);
  return promise;
});

3. Styling Conflicts

Problem: CSS from different micro frontends conflicts.

Solution:

  • Use CSS Modules or CSS-in-JS
  • Implement naming conventions
  • Use Shadow DOM

4. State Management Complexity

Problem: Sharing state between micro frontends is complex.

Solution:

  • Minimize shared state
  • Use event-driven communication
  • Implement a shared state library only when necessary

Tools and Frameworks

Single-SPA

Framework-agnostic micro frontend framework.

import { registerApplication, start } from 'single-spa';

registerApplication({
  name: '@company/products',
  app: () => import('@company/products'),
  activeWhen: '/products'
});

start();

Module Federation

Webpack 5's built-in solution.

Nx

Monorepo tool with micro frontend support.

nx generate @nrwl/react:host shell
nx generate @nrwl/react:remote products

Bit

Component-driven micro frontends.

bit export @company/header
bit import @company/header

Real-World Examples

  • Spotify: Uses micro frontends for different sections
  • Zalando: Implements micro frontends for their e-commerce platform
  • IKEA: Uses micro frontends for product catalogs
  • SoundCloud: Implements different features as micro frontends

Summary

Micro frontends enable:

  • ✅ Independent deployments
  • ✅ Technology diversity
  • ✅ Team autonomy
  • ✅ Gradual migrations
  • ✅ Scalability

But require:

  • ⚠️ Careful architecture planning
  • ⚠️ Strong governance
  • ⚠️ Performance monitoring
  • ⚠️ Clear boundaries

Choose micro frontends when benefits outweigh complexity.