Skip to content
All essays
WebMarch 27, 202622 min

Micro-Frontends Architecture: Complete Implementation Guide

Master micro-frontends architecture and build scalable, independently deployable frontend applications

Ü
Ümit Uz
Mobile & Full Stack Developer

Micro-frontends extend the microservices concept to frontend development. By breaking down monolithic frontend applications into smaller, semi-independent pieces, teams can develop, deploy, and maintain features autonomously. This comprehensive guide covers everything you need to build production-ready micro-frontend architectures.

Understanding Micro-Frontends

Micro-frontends architecture decomposes a frontend application into vertical slices—each owned by different teams. Each micro-frontend implements a specific business capability and can be developed, tested, and deployed independently.

Core Principles

  1. 1Technology Agnostic: Each team can choose their own technology stack
  2. 2Isolated Team Development: Teams work independently without blocking others
  3. 3Independent Deployment: Deploy micro-frontends without redeploying the entire app
  4. 4Efficient Communication: Establish clear contracts between teams
  5. 5Resilience: Failure in one micro-frontend doesn't break the entire application

When to Use Micro-Frontends

Micro-frontends shine in these scenarios:

  • Large applications with multiple teams
  • Organizations with different technology preferences
  • Applications requiring independent release cycles
  • Migrations from legacy systems to modern frameworks
  • Complex domains requiring specialized expertise

Architecture Patterns

Several patterns implement micro-frontends, each with distinct advantages.

Pattern 1: Runtime Integration

Micro-frontends integrate at runtime in the browser:

typescript
// app-shell.tsx
import { loadRemoteModule } from '@angular-architects/module-federation';
import { defineMicroFrontends } from './micro-frontends.config';

const microFrontends = defineMicroFrontends({
  products: {
    remoteEntry: 'http://localhost:3001/remoteEntry.js',
    exposedModule: './ProductModule',
    route: 'products'
  },
  checkout: {
    remoteEntry: 'http://localhost:3002/remoteEntry.js',
    exposedModule: './CheckoutModule',
    route: 'checkout'
  }
});

function AppShell() {
  return (
    <Layout>
      <Header />
      <main>
        <Routes>
          <Route path="/products/*" element={
            lazy(() => loadRemoteModule(microFrontends.products))
          } />
          <Route path="/checkout/*" element={
            lazy(() => loadRemoteModule(microFrontends.checkout))
          } />
        </Routes>
      </main>
      <Footer />
    </Layout>
  );
}

Pattern 2: Build-Time Integration

Combine micro-frontends during the build process:

javascript
// webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        products: 'products@http://localhost:3001/remoteEntry.js',
        checkout: 'checkout@http://localhost:3002/remoteEntry.js'
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

Pattern 3: Server-Side Composition

Compose micro-frontends on the server:

typescript
// server.ts
import express from 'express';
import fetch from 'node-fetch';

const app = express();

app.get('/fragments/:name', async (req, res) => {
  const { name } = req.params;
  const response = await fetch(`http://${name}-service/render`);
  const html = await response.text();
  res.send(html);
});

app.get('/', async (req, res) => {
  const [header, products, footer] = await Promise.all([
    fetch('http://header-service/render').then(r => r.text()),
    fetch('http://products-service/render').then(r => r.text()),
    fetch('http://footer-service/render').then(r => r.text())
  ]);
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <body>
        ${header}
        <main>${products}</main>
        ${footer}
      </body>
    </html>
  `);
});

Module Federation

Webpack Module Federation is the most popular micro-frontend implementation.

Host Configuration

javascript
// host/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        mfe1: 'mfe1@http://localhost:3001/remoteEntry.js',
        mfe2: 'mfe2@http://localhost:3002/remoteEntry.js'
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
        'react-router-dom': { singleton: true }
      }
    })
  ]
};

Remote Configuration

javascript
// remote/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'mfe1',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App',
        './Button': './src/components/Button'
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

Consuming Remote Modules

typescript
// host/src/App.tsx
import React, { lazy, Suspense, useState } from 'react';

const RemoteApp = lazy(() => import('mfe1/App'));
const RemoteButton = lazy(() => import('mfe1/Button'));

function App() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h1>Host Application</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <RemoteApp title="Remote Micro-Frontend" />
        <RemoteButton onClick={() => setCount(c => c + 1)}>
          Count: {count}
        </RemoteButton>
      </Suspense>
    </div>
  );
}

Communication Patterns

Micro-frontends need to communicate while maintaining isolation.

Event Bus Pattern

typescript
// event-bus.ts
type EventHandler = (data: any) => void;

class EventBus {
  private listeners: Map<string, Set<EventHandler>> = new Map();

  on(event: string, handler: EventHandler) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(handler);
  }

  off(event: string, handler: EventHandler) {
    this.listeners.get(event)?.delete(handler);
  }

  emit(event: string, data?: any) {
    this.listeners.get(event)?.forEach(handler => handler(data));
  }
}

export const eventBus = new EventBus();

// Usage in MFE 1
eventBus.emit('product:added', { id: 123, name: 'Product' });

// Usage in MFE 2
eventBus.on('product:added', (product) => {
  console.log('Product added:', product);
});

Shared State with RxJS

typescript
// state-manager.ts
import { BehaviorSubject } fromrxjs';

const cartSubject = new BehaviorSubject<CartItem[]>([]);

export const cartState = {
  items$: cartSubject.asObservable(),
  addItem: (item: CartItem) => {
    const current = cartSubject.value;
    cartSubject.next([...current, item]);
  },
  removeItem: (id: string) => {
    const current = cartSubject.value;
    cartSubject.next(current.filter(item => item.id !== id));
  }
};

// MFE 1: Add to cart
import { cartState } from './state-manager';
cartState.addItem({ id: '1', name: 'Product', price: 99 });

// MFE 2: Display cart
import { cartState } from './state-manager';
cartState.items$.subscribe(items => {
  updateCartDisplay(items);
});

Custom Properties

typescript
// global.d.ts
declare global {
  interface Window {
    microFrontends: {
      cart: {
        addItem: (item: CartItem) => void;
        getItems: () => CartItem[];
      };
      user: {
        getCurrentUser: () => User;
      };
    };
  }
}

// Cart MFE
window.microFrontends = {
  cart: {
    addItem: (item) => { /* implementation */ },
    getItems: () => { /* implementation */ }
  }
};

// Checkout MFE
const items = window.microFrontends.cart.getItems();

Styling Strategies

Managing styles across micro-frontends requires careful planning.

CSS Modules

css
/* mfe1/Button.module.css */
.button {
  padding: 10px 20px;
  background: blue;
  color: white;
  border: none;
  border-radius: 4px;
}
typescript
// mfe1/Button.tsx
import styles from './Button.module.css';

export function Button({ children, onClick }) {
  return (
    <button className={styles.button} onClick={onClick}>
      {children}
    </button>
  );
}

Shadow DOM

typescript
class MicroFrontendElement extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        .container {
          padding: 20px;
          background: white;
        }
      </style>
      <div class="container">
        <slot></slot>
      </div>
    `;
  }
}

customElements.define('micro-frontend', MicroFrontendElement);

Web Components

typescript
// product-card.ts
export class ProductCard extends HTMLElement {
  static get observedAttributes() {
    return ['name', 'price', 'image'];
  }

  attributeChangedCallback() {
    this.render();
  }

  render() {
    const name = this.getAttribute('name') || '';
    const price = this.getAttribute('price') || '';
    const image = this.getAttribute('image') || '';

    this.innerHTML = `
      <div class="card">
        <img src="${image}" alt="${name}" />
        <h3>${name}</h3>
        <p>$${price}</p>
      </div>
    `;
  }
}

customElements.define('product-card', ProductCard);

Routing Strategies

Handle routing across micro-frontends seamlessly.

Client-Side Routing

typescript
// host/src/App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/products/*" element={
          lazy(() => import('products/App'))
        } />
        <Route path="/checkout/*" element={
          lazy(() => import('checkout/App'))
        } />
      </Routes>
    </BrowserRouter>
  );
}

Server-Side Routing

typescript
// server.ts
app.get('/products/*', (req, res) => {
  res.sendFile('products/index.html');
});

app.get('/checkout/*', (req, res) => {
  res.sendFile('checkout/index.html');
});

Dynamic Route Registration

typescript
// route-registry.ts
interface RouteConfig {
  path: string;
  component: React.ComponentType;
  exact?: boolean;
}

class RouteRegistry {
  private routes: Map<string, RouteConfig> = new Map();

  register(key: string, config: RouteConfig) {
    this.routes.set(key, config);
  }

  getRoutes(): RouteConfig[] {
    return Array.from(this.routes.values());
  }
}

export const routeRegistry = new RouteRegistry();

// MFE 1 registers routes
routeRegistry.register('products', {
  path: '/products',
  component: ProductList
});

// Host consumes routes
function App() {
  const routes = routeRegistry.getRoutes();
  
  return (
    <BrowserRouter>
      <Routes>
        {routes.map(route => (
          <Route 
            key={route.path}
            path={route.path}
            element={<route.component />}
          />
        ))}
      </Routes>
    </BrowserRouter>
  );
}

Testing Strategies

Test micro-frontends independently and together.

Unit Testing

typescript
// mfe1/Button.test.tsx
import { render, screen } from '@testing-library/react';
import { Button } from './Button';

test('renders button with text', () => {
  render(<Button>Click me</Button>);
  expect(screen.getByText('Click me')).toBeInTheDocument();
});

Integration Testing

typescript
// integration/cart-flow.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { App } from './App';

test('add product to cart across MFEs', async () => {
  render(<App />);
  
  // Products MFE
  const addButton = await screen.findByText('Add to Cart');
  fireEvent.click(addButton);
  
  // Cart MFE
  const cartCount = await screen.findByText('Cart (1)');
  expect(cartCount).toBeInTheDocument();
});

End-to-End Testing

typescript
// e2e/add-to-cart.spec.ts
import { test, expect } from '@playwright/test';

test('complete purchase flow', async ({ page }) => {
  await page.goto('http://localhost:3000');
  
  // Products MFE
  await page.click('text=Products');
  await page.click('text=Add to Cart', { first: true });
  
  // Cart MFE
  await page.click('text=Cart');
  await expect(page.locator('text=Cart (1)')).toBeVisible();
  
  // Checkout MFE
  await page.click('text=Checkout');
  await page.fill('input[name="email"]', 'test@example.com');
  await page.click('text=Place Order');
  
  await expect(page.locator('text=Order Placed')).toBeVisible();
});

Performance Optimization

Optimize micro-frontend performance for production.

Code Splitting

typescript
// MFE level code splitting
const ProductList = lazy(() => 
  import('./ProductList').then(module => ({
    default: module.ProductList
  }))
);

Shared Dependencies

javascript
// webpack.config.js
new ModuleFederationPlugin({
  shared: {
    react: {
      singleton: true,
      requiredVersion: '^18.0.0',
      eager: true
    },
    'react-dom': {
      singleton: true,
      requiredVersion: '^18.0.0'
    },
    lodash: {
      singleton: false // Allow multiple versions
    }
  }
});

Lazy Loading

typescript
function App() {
  const [mfeLoaded, setMfeLoaded] = useState(false);

  useEffect(() => {
    // Load MFE only when needed
    const loadMFE = async () => {
      const module = await import('mfe1/App');
      setMfeLoaded(true);
    };
    
    if (shouldLoadMFE) {
      loadMFE();
    }
  }, [shouldLoadMFE]);

  return (
    <div>
      {mfeLoaded && <RemoteMFE />}
    </div>
  );
}

Deployment Strategies

Deploy micro-frontends independently.

Independent Deployments

yaml
# .github/workflows/deploy-mfe1.yml
name: Deploy MFE 1

on:
  push:
    branches: [main]
    paths:
      - 'packages/mfe1/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Build
        run: npm run build --workspace=packages/mfe1
      - name: Deploy
        run: npm run deploy --workspace=packages/mfe1

Versioned Dependencies

typescript
// version-registry.ts
interface MFEVersion {
  name: string;
  version: string;
  url: string;
}

class VersionRegistry {
  private versions: Map<string, MFEVersion[]> = new Map();

  register(mfe: MFEVersion) {
    const versions = this.versions.get(mfe.name) || [];
    versions.push(mfe);
    this.versions.set(mfe.name, versions);
  }

  getLatest(name: string): MFEVersion | null {
    const versions = this.versions.get(name);
    return versions?.[versions.length - 1] || null;
  }

  getVersion(name: string, version: string): MFEVersion | null {
    const versions = this.versions.get(name);
    return versions?.find(v => v.version === version) || null;
  }
}

export const versionRegistry = new VersionRegistry();

Conclusion

Micro-frontends enable teams to build scalable, maintainable frontend applications. By following the patterns and best practices in this guide, you can successfully decompose your monolithic frontend into independently deployable pieces.

Remember that micro-frontends introduce complexity—only use them when the benefits outweigh the costs. Start small, measure carefully, and iterate based on real needs.

Enjoyed this essay? Share it.

Related essays

Next essay
React Native Performance: Complete Optimization Guide