Micro Frontends - Scaling Frontend Development

Micro Frontends - Scaling Frontend Development

Micro Frontends: Scaling Frontend Development

As web applications grow in complexity and team sizes increase, traditional monolithic frontend architectures can become bottlenecks for development velocity and scalability. Micro frontends extend the concepts of microservices to the frontend world, allowing teams to build, test, and deploy UI components independently while creating a cohesive user experience.

Understanding Micro Frontends

What Are Micro Frontends?

Micro frontends are an architectural approach where a frontend application is decomposed into smaller, semi-independent "micro applications" that can be developed, tested, and deployed by different teams. These micro applications are then composed together to create a complete user experience.

The core idea is to apply the same principles that make microservices successful on the backend to frontend development:

  • Independent development and deployment
  • Team autonomy
  • Technology agnosticism
  • Isolation and resilience
  • Scalable development

Key Principles

  1. Team Autonomy: Each team owns a specific part of the application from database to user interface
  2. Technology Independence: Teams can choose the best tools for their micro frontend
  3. Isolation: Micro frontends should be isolated to prevent conflicts
  4. Native Browser Features: Leverage built-in browser capabilities when possible
  5. Resilience: Failures in one micro frontend should not break the entire application

Micro Frontend Architectures

There are several approaches to implementing micro frontends, each with its own trade-offs:

1. Client-Side Composition

In this approach, micro frontends are composed in the browser using JavaScript:

// Container application
import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';

// Micro frontends loaded via dynamic imports or module federation
const ProductCatalog = React.lazy(() => import('productApp/ProductCatalog'));
const ShoppingCart = React.lazy(() => import('cartApp/ShoppingCart'));
const UserProfile = React.lazy(() => import('profileApp/UserProfile'));

function App() {
  return (
    <BrowserRouter>
      <header>Common Header</header>
      <React.Suspense fallback={<div>Loading...</div>}>
        <Switch>
          <Route path="/products" component={ProductCatalog} />
          <Route path="/cart" component={ShoppingCart} />
          <Route path="/profile" component={UserProfile} />
        </Switch>
      </React.Suspense>
      <footer>Common Footer</footer>
    </BrowserRouter>
  );
}

2. Server-Side Composition

With server-side composition, micro frontends are assembled on the server before being sent to the browser:

// Server-side template (using a template engine like EJS)
<!DOCTYPE html>
<html>
<head>
  <title>Micro Frontend Example</title>
</head>
<body>
  <header>
    <%- include('header-fragment') %>
  </header>
  
  <main>
    <% if (path.startsWith('/products')) { %>
      <%- include('products-fragment') %>
    <% } else if (path.startsWith('/cart')) { %>
      <%- include('cart-fragment') %>
    <% } else if (path.startsWith('/profile')) { %>
      <%- include('profile-fragment') %>
    <% } %>
  </main>
  
  <footer>
    <%- include('footer-fragment') %>
  </footer>
</body>
</html>

3. Edge-Side Composition

Edge-side composition uses edge computing platforms to assemble micro frontends at the CDN level:

// Example using Cloudflare Workers
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  const url = new URL(request.url)
  
  // Fetch the shell application
  const shell = await fetch('https://shell.example.com')
  let html = await shell.text()
  
  // Determine which micro frontend to load based on the path
  if (url.pathname.startsWith('/products')) {
    const productContent = await fetch('https://products.example.com')
    const productHtml = await productContent.text()
    html = html.replace('<div id="content"></div>', productHtml)
  } else if (url.pathname.startsWith('/cart')) {
    const cartContent = await fetch('https://cart.example.com')
    const cartHtml = await cartContent.text()
    html = html.replace('<div id="content"></div>', cartHtml)
  }
  
  return new Response(html, {
    headers: { 'content-type': 'text/html' },
  })
}

4. Build-Time Composition

With build-time composition, micro frontends are combined during the build process:

// Using npm packages for each micro frontend
// package.json
{
  "dependencies": {
    "product-micro-frontend": "1.0.0",
    "cart-micro-frontend": "1.0.0",
    "profile-micro-frontend": "1.0.0"
  }
}

// App.js
import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import { ProductCatalog } from 'product-micro-frontend';
import { ShoppingCart } from 'cart-micro-frontend';
import { UserProfile } from 'profile-micro-frontend';

function App() {
  return (
    <BrowserRouter>
      <header>Common Header</header>
      <Switch>
        <Route path="/products" component={ProductCatalog} />
        <Route path="/cart" component={ShoppingCart} />
        <Route path="/profile" component={UserProfile} />
      </Switch>
      <footer>Common Footer</footer>
    </BrowserRouter>
  );
}

Implementation Techniques

1. Webpack Module Federation

Webpack 5's Module Federation allows sharing JavaScript modules between different applications at runtime:

// webpack.config.js for a micro frontend
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  // ... other webpack config
  plugins: [
    new ModuleFederationPlugin({
      name: 'productApp',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductCatalog': './src/components/ProductCatalog',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
};

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

module.exports = {
  // ... other webpack config
  plugins: [
    new ModuleFederationPlugin({
      name: 'container',
      remotes: {
        productApp: 'productApp@http://localhost:3001/remoteEntry.js',
        cartApp: 'cartApp@http://localhost:3002/remoteEntry.js',
        profileApp: 'profileApp@http://localhost:3003/remoteEntry.js',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
};

2. Web Components

Web Components provide a standards-based way to create custom, reusable UI elements:

// product-catalog.js
class ProductCatalog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        /* Component styles */
      </style>
      <div class="product-catalog">
        <h2>Product Catalog</h2>
        <div class="products"></div>
      </div>
    `;
    
    this.fetchProducts();
  }
  
  async fetchProducts() {
    const response = await fetch('/api/products');
    const products = await response.json();
    
    const productsContainer = this.shadowRoot.querySelector('.products');
    products.forEach(product => {
      const productEl = document.createElement('div');
      productEl.className = 'product';
      productEl.innerHTML = `
        <h3>${product.name}</h3>
        <p>${product.price}</p>
        <button>Add to Cart</button>
      `;
      productsContainer.appendChild(productEl);
    });
  }
}

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

// In the container application's HTML
<!DOCTYPE html>
<html>
<head>
  <title>Micro Frontend Example</title>
  <script src="/header.js"></script>
  <script src="/footer.js"></script>
</head>
<body>
  <app-header></app-header>
  
  <main id="content">
    <!-- The appropriate micro frontend will be loaded here based on the route -->
  </main>
  
  <app-footer></app-footer>
  
  <script>
    // Simple router
    const path = window.location.pathname;
    const contentEl = document.getElementById('content');
    
    if (path.startsWith('/products')) {
      // Load the product catalog micro frontend
      const script = document.createElement('script');
      script.src = '/product-catalog.js';
      document.head.appendChild(script);
      
      const productCatalog = document.createElement('product-catalog');
      contentEl.appendChild(productCatalog);
    } else if (path.startsWith('/cart')) {
      // Load the shopping cart micro frontend
      // Similar approach
    }
  </script>
</body>
</html>

3. iframes

iframes provide strong isolation between micro frontends:

<!DOCTYPE html>
<html>
<head>
  <title>Micro Frontend Example</title>
  <style>
    iframe {
      border: none;
      width: 100%;
      height: 500px;
    }
  </style>
</head>
<body>
  <header>
    <!-- Common header -->
  </header>
  
  <main id="content">
    <!-- The appropriate micro frontend will be loaded here based on the route -->
  </main>
  
  <footer>
    <!-- Common footer -->
  </footer>
  
  <script>
    // Simple router
    const path = window.location.pathname;
    const contentEl = document.getElementById('content');
    
    if (path.startsWith('/products')) {
      contentEl.innerHTML = '<iframe src="https://products.example.com"></iframe>';
    } else if (path.startsWith('/cart')) {
      contentEl.innerHTML = '<iframe src="https://cart.example.com"></iframe>';
    } else if (path.startsWith('/profile')) {
      contentEl.innerHTML = '<iframe src="https://profile.example.com"></iframe>';
    }
  </script>
</body>
</html>

4. Server-Side Includes (SSI)

Server-side includes can be used to compose micro frontends on the server:

<!DOCTYPE html>
<html>
<head>
  <title>Micro Frontend Example</title>
</head>
<body>
  <!--#include virtual="/header.html" -->
  
  <!--#if expr="$REQUEST_URI = /^\/products/" -->
    <!--#include virtual="/products-fragment.html" -->
  <!--#elif expr="$REQUEST_URI = /^\/cart/" -->
    <!--#include virtual="/cart-fragment.html" -->
  <!--#elif expr="$REQUEST_URI = /^\/profile/" -->
    <!--#include virtual="/profile-fragment.html" -->
  <!--#endif -->
  
  <!--#include virtual="/footer.html" -->
</body>
</html>

Communication Between Micro Frontends

Micro frontends need to communicate with each other while maintaining loose coupling:

1. Custom Events

Use browser's native custom events for communication:

// In the shopping cart micro frontend
function addToCart(product) {
  // Add product to cart
  // ...
  
  // Dispatch event to notify other micro frontends
  const event = new CustomEvent('cart:item-added', {
    detail: { product },
    bubbles: true,
  });
  document.dispatchEvent(event);
}

// In the header micro frontend (listening for cart updates)
document.addEventListener('cart:item-added', (event) => {
  const { product } = event.detail;
  updateCartIndicator();
  showNotification(`Added ${product.name} to cart`);
});

2. Shared State Management

Implement a shared state management solution:

// shared-state.js
class SharedState {
  constructor() {
    this.state = {};
    this.listeners = {};
  }
  
  getState(key) {
    return this.state[key];
  }
  
  setState(key, value) {
    this.state[key] = value;
    
    if (this.listeners[key]) {
      this.listeners[key].forEach(listener => listener(value));
    }
  }
  
  subscribe(key, listener) {
    if (!this.listeners[key]) {
      this.listeners[key] = [];
    }
    
    this.listeners[key].push(listener);
    
    return () => {
      this.listeners[key] = this.listeners[key].filter(l => l !== listener);
    };
  }
}

// Create a singleton instance
const sharedState = new SharedState();

// Make it globally available
window.sharedState = sharedState;

export default sharedState;

// In the shopping cart micro frontend
import sharedState from './shared-state';

function addToCart(product) {
  // Add product to cart
  const cart = [...(sharedState.getState('cart') || []), product];
  
  // Update shared state
  sharedState.setState('cart', cart);
}

// In the header micro frontend
import sharedState from './shared-state';

function setupCartIndicator() {
  const updateIndicator = (cart = []) => {
    document.getElementById('cart-count').textContent = cart.length;
  };
  
  // Initialize
  updateIndicator(sharedState.getState('cart'));
  
  // Subscribe to changes
  sharedState.subscribe('cart', updateIndicator);
}

3. URL/Route Based Communication

Use the URL as a communication mechanism:

// In the product catalog micro frontend
function viewProductDetails(productId) {
  window.history.pushState({}, '', `/products/${productId}`);
}

// In the container application
window.addEventListener('popstate', () => {
  routeToCorrectMicroFrontend(window.location.pathname);
});

function routeToCorrectMicroFrontend(path) {
  // Logic to load the appropriate micro frontend based on the path
}

Styling in Micro Frontends

Consistent styling across micro frontends is crucial for a cohesive user experience:

1. Shared Design System

Implement a shared design system as a package:

// design-system package
export const Button = ({ variant = 'primary', children, ...props }) => (
  <button className={`btn btn-${variant}`} {...props}>
    {children}
  </button>
);

export const Card = ({ title, children, ...props }) => (
  <div className="card" {...props}>
    {title && <div className="card-header">{title}</div>}
    <div className="card-body">{children}</div>
  </div>
);

// In a micro frontend
import { Button, Card } from '@company/design-system';

function ProductItem({ product }) {
  return (
    <Card title={product.name}>
      <p>{product.description}</p>
      <p>${product.price}</p>
      <Button onClick={() => addToCart(product)}>Add to Cart</Button>
    </Card>
  );
}

2. CSS-in-JS with Scoped Styles

Use CSS-in-JS libraries to scope styles to components:

import styled from 'styled-components';

const ProductCard = styled.div`
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 16px;
  margin-bottom: 16px;
  
  h3 {
    margin-top: 0;
    color: #333;
  }
  
  .price {
    font-weight: bold;
    color: #e53e3e;
  }
  
  button {
    background-color: #3182ce;
    color: white;
    border: none;
    border-radius: 4px;
    padding: 8px 16px;
    cursor: pointer;
    
    &:hover {
      background-color: #2b6cb0;
    }
  }
`;

function Product({ product }) {
  return (
    <ProductCard>
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <p className="price">${product.price}</p>
      <button onClick={() => addToCart(product)}>Add to Cart</button>
    </ProductCard>
  );
}

3. CSS Custom Properties (Variables)

Use CSS custom properties for theming:

/* shared-theme.css */
:root {
  --primary-color: #3182ce;
  --secondary-color: #718096;
  --danger-color: #e53e3e;
  --success-color: #38a169;
  --text-color: #333;
  --background-color: #fff;
  --border-radius: 4px;
  --spacing-unit: 8px;
  --font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

/* In a micro frontend's CSS */
.product-card {
  border: 1px solid #ddd;
  border-radius: var(--border-radius);
  padding: calc(var(--spacing-unit) * 2);
  margin-bottom: calc(var(--spacing-unit) * 2);
  font-family: var(--font-family);
}

.product-card h3 {
  color: var(--text-color);
}

.product-card .price {
  color: var(--danger-color);
}

.product-card button {
  background-color: var(--primary-color);
  color: white;
  border: none;
  border-radius: var(--border-radius);
  padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
}

Testing Micro Frontends

Testing micro frontends requires a comprehensive approach:

1. Unit Testing

Test individual components within each micro frontend:

// product-card.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import ProductCard from './ProductCard';

test('renders product information correctly', () => {
  const product = {
    id: '1',
    name: 'Test Product',
    description: 'A test product',
    price: 19.99,
  };
  
  render(<ProductCard product={product} />);
  
  expect(screen.getByText('Test Product')).toBeInTheDocument();
  expect(screen.getByText('A test product')).toBeInTheDocument();
  expect(screen.getByText('$19.99')).toBeInTheDocument();
});

test('calls addToCart when button is clicked', () => {
  const product = {
    id: '1',
    name: 'Test Product',
    description: 'A test product',
    price: 19.99,
  };
  
  const addToCart = jest.fn();
  
  render(<ProductCard product={product} addToCart={addToCart} />);
  
  fireEvent.click(screen.getByText('Add to Cart'));
  
  expect(addToCart).toHaveBeenCalledWith(product);
});

2. Integration Testing

Test how micro frontends work together:

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

test('adding a product to cart updates the cart count', async () => {
  render(<App />);
  
  // Navigate to products page
  fireEvent.click(screen.getByText('Products'));
  
  // Wait for products to load
  const addToCartButton = await screen.findByText('Add to Cart');
  
  // Initial cart count should be 0
  expect(screen.getByTestId('cart-count')).toHaveTextContent('0');
  
  // Add product to cart
  fireEvent.click(addToCartButton);
  
  // Cart count should be updated to 1
  expect(screen.getByTestId('cart-count')).toHaveTextContent('1');
});

3. End-to-End Testing

Test the entire application flow:

// Using Cypress for E2E testing
describe('E-commerce Flow', () => {
  it('allows a user to browse products, add to cart, and checkout', () => {
    // Visit the homepage
    cy.visit('/');
    
    // Navigate to products
    cy.contains('Products').click();
    
    // Add a product to cart
    cy.contains('Add to Cart').first().click();
    
    // Verify cart count updated
    cy.get('[data-testid="cart-count"]').should('contain', '1');
    
    // Go to cart
    cy.contains('Cart').click();
    
    // Verify product is in cart
    cy.get('.cart-item').should('have.length', 1);
    
    // Proceed to checkout
    cy.contains('Checkout').click();
    
    // Fill in shipping information
    cy.get('#name').type('John Doe');
    cy.get('#email').type('john@example.com');
    cy.get('#address').type('123 Main St');
    
    // Complete order
    cy.contains('Complete Order').click();
    
    // Verify order confirmation
    cy.contains('Thank you for your order').should('be.visible');
  });
});

4. Contract Testing

Test the contracts between micro frontends:

// Using Pact.js for contract testing
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const { like } = MatchersV3;

describe('Cart API', () => {
  const provider = new PactV3({
    consumer: 'product-catalog',
    provider: 'shopping-cart',
  });
  
  it('adds a product to the cart', async () => {
    // Define the expected interaction
    await provider.addInteraction({
      states: [{ description: 'cart exists' }],
      uponReceiving: 'a request to add a product to the cart',
      withRequest: {
        method: 'POST',
        path: '/api/cart/items',
        headers: { 'Content-Type': 'application/json' },
        body: {
          productId: like('123'),
          quantity: like(1),
        },
      },
      willRespondWith: {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          success: like(true),
          cartSize: like(1),
        },
      },
    });
    
    // Verify the interaction
    await provider.executeTest(async (mockServer) => {
      const response = await fetch(`${mockServer.url}/api/cart/items`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          productId: '123',
          quantity: 1,
        }),
      });
      
      const result = await response.json();
      expect(result.success).toBe(true);
      expect(result.cartSize).toBe(1);
    });
  });
});

Implementing Micro Frontends with Next.js

Next.js can be used to implement micro frontends with its built-in features:

1. Using Next.js for Each Micro Frontend

Create separate Next.js applications for each micro frontend:

// product-catalog/next.config.js
module.exports = {
  basePath: '/products',
  assetPrefix: '/products',
};

// shopping-cart/next.config.js
module.exports = {
  basePath: '/cart',
  assetPrefix: '/cart',
};

// user-profile/next.config.js
module.exports = {
  basePath: '/profile',
  assetPrefix: '/profile',
};

2. Using Module Federation with Next.js

Implement Webpack Module Federation in Next.js:

// next.config.js for a micro frontend
const { NextFederationPlugin } = require('@module-federation/nextjs-mf');

module.exports = {
  webpack(config, options) {
    config.plugins.push(
      new NextFederationPlugin({
        name: 'productApp',
        filename: 'static/chunks/remoteEntry.js',
        exposes: {
          './ProductCatalog': './components/ProductCatalog.js',
        },
        shared: {
          react: {
            singleton: true,
            requiredVersion: false,
          },
          'react-dom': {
            singleton: true,
            requiredVersion: false,
          },
        },
      })
    );
    
    return config;
  },
};

// next.config.js for the container application
const { NextFederationPlugin } = require('@module-federation/nextjs-mf');

module.exports = {
  webpack(config, options) {
    config.plugins.push(
      new NextFederationPlugin({
        name: 'container',
        remotes: {
          productApp: 'productApp@http://localhost:3001/_next/static/chunks/remoteEntry.js',
          cartApp: 'cartApp@http://localhost:3002/_next/static/chunks/remoteEntry.js',
          profileApp: 'profileApp@http://localhost:3003/_next/static/chunks/remoteEntry.js',
        },
        shared: {
          react: {
            singleton: true,
            requiredVersion: false,
          },
          'react-dom': {
            singleton: true,
            requiredVersion: false,
          },
        },
      })
    );
    
    return config;
  },
};

// pages/products.js in the container application
import dynamic from 'next/dynamic';

const ProductCatalog = dynamic(() => import('productApp/ProductCatalog'), {
  ssr: false, // Disable SSR for federated modules
  loading: () => <p>Loading Product Catalog...</p>,
});

export default function ProductsPage() {
  return (
    <div>
      <h1>Products</h1>
      <ProductCatalog />
    </div>
  );
}

3. Using Next.js API Routes for Backend Communication

Implement API routes for communication between micro frontends:

// pages/api/cart/add.js in the shopping cart micro frontend
export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }
  
  const { productId, quantity } = req.body;
  
  // Add to cart logic
  // ...
  
  res.status(200).json({ success: true, cartSize: 1 });
}

// In the product catalog micro frontend
async function addToCart(product, quantity = 1) {
  try {
    const response = await fetch('/api/cart/add', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        productId: product.id,
        quantity,
      }),
    });
    
    const result = await response.json();
    
    if (result.success) {
      // Update UI or show notification
    }
  } catch (error) {
    console.error('Error adding to cart:', error);
  }
}

Challenges and Solutions

1. Consistent User Experience

Challenge: Ensuring a consistent look and feel across micro frontends.

Solution: Implement a shared design system and component library that all teams use.

// design-system/theme.js
export const theme = {
  colors: {
    primary: '#3182ce',
    secondary: '#718096',
    danger: '#e53e3e',
    success: '#38a169',
    text: '#333',
    background: '#fff',
  },
  spacing: {
    xs: '4px',
    sm: '8px',
    md: '16px',
    lg: '24px',
    xl: '32px',
  },
  typography: {
    fontFamily: '"Segoe UI", Tahoma, Geneva, Verdana, sans-serif',
    fontSize: {
      small: '0.875rem',
      medium: '1rem',
      large: '1.25rem',
      xlarge: '1.5rem',
    },
  },
  // ... other theme properties
};

// Using the theme in a micro frontend
import { ThemeProvider } from 'styled-components';
import { theme } from '@company/design-system';

function MyMicroFrontend() {
  return (
    <ThemeProvider theme={theme}>
      {/* Micro frontend content */}
    </ThemeProvider>
  );
}

2. Performance Optimization

Challenge: Multiple micro frontends can lead to performance issues.

Solution: Implement code splitting, lazy loading, and shared dependencies.

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

module.exports = {
  // ... other webpack config
  plugins: [
    new ModuleFederationPlugin({
      name: 'productApp',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductCatalog': './src/components/ProductCatalog',
      },
      shared: {
        // Share dependencies to avoid duplicates
        react: { singleton: true, eager: true },
        'react-dom': { singleton: true, eager: true },
        'styled-components': { singleton: true },
        lodash: { singleton: true },
      },
    }),
  ],
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          test: /[\\]node_modules[\\]/,
          name: 'vendors',
          priority: -10,
        },
      },
    },
  },
};

3. Authentication and Authorization

Challenge: Managing user authentication across micro frontends.

Solution: Implement a centralized authentication service and use tokens.

// auth-service.js
class AuthService {
  constructor() {
    this.token = localStorage.getItem('auth_token');
    this.user = JSON.parse(localStorage.getItem('user') || 'null');
    this.listeners = [];
  }
  
  isAuthenticated() {
    return !!this.token;
  }
  
  getUser() {
    return this.user;
  }
  
  getToken() {
    return this.token;
  }
  
  async login(credentials) {
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(credentials),
      });
      
      const data = await response.json();
      
      if (data.token) {
        this.token = data.token;
        this.user = data.user;
        
        localStorage.setItem('auth_token', this.token);
        localStorage.setItem('user', JSON.stringify(this.user));
        
        this.notifyListeners();
        return true;
      }
      
      return false;
    } catch (error) {
      console.error('Login error:', error);
      return false;
    }
  }
  
  logout() {
    this.token = null;
    this.user = null;
    
    localStorage.removeItem('auth_token');
    localStorage.removeItem('user');
    
    this.notifyListeners();
  }
  
  subscribe(listener) {
    this.listeners.push(listener);
    
    return () => {
      this.listeners = this.listeners.filter(l => l !== listener);
    };
  }
  
  notifyListeners() {
    this.listeners.forEach(listener => listener({
      isAuthenticated: this.isAuthenticated(),
      user: this.user,
    }));
  }
}

// Create a singleton instance
const authService = new AuthService();

// Make it globally available
window.authService = authService;

export default authService;

// Using the auth service in a micro frontend
import { useState, useEffect } from 'react';
import authService from './auth-service';

function ProfilePage() {
  const [authState, setAuthState] = useState({
    isAuthenticated: authService.isAuthenticated(),
    user: authService.getUser(),
  });
  
  useEffect(() => {
    const unsubscribe = authService.subscribe(setAuthState);
    return unsubscribe;
  }, []);
  
  if (!authState.isAuthenticated) {
    return <p>Please log in to view your profile.</p>;
  }
  
  return (
    <div>
      <h1>Profile</h1>
      <p>Name: {authState.user.name}</p>
      <p>Email: {authState.user.email}</p>
      <button onClick={() => authService.logout()}>Log Out</button>
    </div>
  );
}

4. Deployment and CI/CD

Challenge: Managing deployment of multiple micro frontends.

Solution: Implement independent CI/CD pipelines with coordinated deployments.

# .github/workflows/product-catalog.yml
name: Product Catalog CI/CD

on:
  push:
    branches: [ main ]
    paths:
      - 'product-catalog/**'

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v2
    
    - name: Set up Node.js
      uses: actions/setup-node@v2
      with:
        node-version: '16'
    
    - name: Install dependencies
      run: |
        cd product-catalog
        npm ci
    
    - name: Build
      run: |
        cd product-catalog
        npm run build
    
    - name: Deploy
      run: |
        cd product-catalog
        npm run deploy

Best Practices

1. Define Clear Boundaries

Each micro frontend should have a clear responsibility and domain boundary. Avoid overlapping responsibilities.

2. Minimize Shared State

Reduce dependencies between micro frontends by minimizing shared state. Use events for communication when possible.

3. Standardize Communication Patterns

Establish consistent patterns for how micro frontends communicate with each other.

4. Implement Comprehensive Testing

Test each micro frontend independently and test the integrated application as a whole.

5. Document Integration Points

Clearly document the APIs and events that each micro frontend exposes and consumes.

6. Monitor Performance

Implement performance monitoring to identify and address issues early.

7. Establish Governance

Create guidelines and standards for all teams to follow to ensure consistency.

Conclusion

Micro frontends offer a powerful approach to scaling frontend development across multiple teams. By applying the principles of microservices to the frontend, organizations can achieve greater team autonomy, technology flexibility, and development velocity.

However, implementing micro frontends comes with challenges, including ensuring a consistent user experience, managing performance, and coordinating deployments. By following best practices and choosing the right implementation techniques, these challenges can be overcome.

Whether you're using client-side composition with Module Federation, server-side composition, or a hybrid approach, the key is to establish clear boundaries, standardize communication patterns, and implement comprehensive testing.

As web applications continue to grow in complexity, micro frontends provide a scalable architecture that allows teams to deliver value independently while creating a cohesive experience for users.