Micro Frontends in 2025: Architecture Patterns That Scale

8 min read1410 words

Our monolithic React application reached 2.3 million lines of code. Builds took 45 minutes. Three teams blocked each other constantly. We split it into seven micro frontends, each owned by a single team. Builds dropped to 4 minutes per app. Teams deployed independently. Here's the architecture that made it work.

When Micro Frontends Make Sense

Before diving into patterns, assess whether you actually need micro frontends:

Good candidates:

  • Multiple teams (3+) working on the same application
  • Different release cadences for different features
  • Legacy migration where you can't rewrite everything
  • Large applications where build times become painful

Poor candidates:

  • Small teams (< 10 developers)
  • New applications that can start with good architecture
  • Projects where shared state is complex and tightly coupled
  • When you lack platform engineering capacity

Micro frontends add complexity. The benefits must outweigh the costs.

Module Federation Pattern

Module Federation, available in Webpack 5 and Vite, allows applications to share code at runtime. One application can load components from another without bundling them together.

Host Configuration

The host application orchestrates the micro frontends:

// apps/shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
 
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        checkout: 'checkout@http://localhost:3001/remoteEntry.js',
        catalog: 'catalog@http://localhost:3002/remoteEntry.js',
        account: 'account@http://localhost:3003/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

Each micro frontend exposes its components:

// apps/checkout/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
 
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'checkout',
      filename: 'remoteEntry.js',
      exposes: {
        './Cart': './src/components/Cart',
        './Checkout': './src/pages/Checkout',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};

Loading Remote Components

The shell loads micro frontends dynamically:

// apps/shell/src/App.tsx
import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
 
const RemoteCart = lazy(() => import('checkout/Cart'));
const RemoteCheckout = lazy(() => import('checkout/Checkout'));
const RemoteCatalog = lazy(() => import('catalog/ProductList'));
const RemoteAccount = lazy(() => import('account/Profile'));
 
function App() {
  return (
    <div className="app">
      <Header />
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/products/*" element={<RemoteCatalog />} />
          <Route path="/cart" element={<RemoteCart />} />
          <Route path="/checkout/*" element={<RemoteCheckout />} />
          <Route path="/account/*" element={<RemoteAccount />} />
        </Routes>
      </Suspense>
      <Footer />
    </div>
  );
}

Type Safety Across Boundaries

Create type definitions for remote modules:

// apps/shell/src/types/remotes.d.ts
declare module 'checkout/Cart' {
  const Cart: React.ComponentType<{
    onCheckout: () => void;
    currency?: string;
  }>;
  export default Cart;
}
 
declare module 'checkout/Checkout' {
  const Checkout: React.ComponentType<{
    cartId: string;
    onComplete: (orderId: string) => void;
  }>;
  export default Checkout;
}

Next.js with Module Federation

For Next.js applications, use the dedicated plugin:

// apps/shell/next.config.js
const NextFederationPlugin = require('@module-federation/nextjs-mf');
 
module.exports = {
  webpack(config, options) {
    config.plugins.push(
      new NextFederationPlugin({
        name: 'shell',
        remotes: {
          checkout: `checkout@${process.env.CHECKOUT_URL}/_next/static/chunks/remoteEntry.js`,
        },
        shared: {
          // Share Next.js internals carefully
        },
        extraOptions: {
          exposePages: true,
        },
      })
    );
    return config;
  },
};

Next.js micro frontends can share the router and maintain SSR capabilities:

// apps/shell/pages/checkout/[[...slug]].tsx
import dynamic from 'next/dynamic';
 
const RemoteCheckoutPage = dynamic(
  () => import('checkout/pages/checkout'),
  {
    ssr: false, // Or true with careful configuration
    loading: () => <CheckoutSkeleton />,
  }
);
 
export default function CheckoutPage() {
  return <RemoteCheckoutPage />;
}

Single-SPA for Multi-Framework

When different teams use different frameworks, single-spa orchestrates them:

// root-config.js
import { registerApplication, start } from 'single-spa';
 
registerApplication({
  name: '@myorg/navbar',
  app: () => System.import('@myorg/navbar'),
  activeWhen: ['/'],
});
 
registerApplication({
  name: '@myorg/dashboard',
  app: () => System.import('@myorg/dashboard'),
  activeWhen: ['/dashboard'],
});
 
registerApplication({
  name: '@myorg/settings',
  app: () => System.import('@myorg/settings'),
  activeWhen: ['/settings'],
});
 
start();

Each application implements lifecycle hooks:

// apps/dashboard/src/myorg-dashboard.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import singleSpaReact from 'single-spa-react';
import Dashboard from './Dashboard';
 
const lifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: Dashboard,
  errorBoundary(err, info, props) {
    return <ErrorFallback error={err} />;
  },
});
 
export const { bootstrap, mount, unmount } = lifecycles;

Communication Between Micro Frontends

Micro frontends need to communicate without tight coupling.

Custom Events

// Shared event types
interface CartUpdatedEvent extends CustomEvent {
  detail: { itemCount: number; total: number };
}
 
// Publishing (from checkout MFE)
function updateCart(itemCount: number, total: number) {
  window.dispatchEvent(
    new CustomEvent('cart:updated', {
      detail: { itemCount, total },
    })
  );
}
 
// Subscribing (in shell)
useEffect(() => {
  const handler = (event: CartUpdatedEvent) => {
    setCartCount(event.detail.itemCount);
  };
 
  window.addEventListener('cart:updated', handler);
  return () => window.removeEventListener('cart:updated', handler);
}, []);

Shared State via URL

URL is inherently shareable across micro frontends:

// Navigation preserves state in URL
function ProductFilter({ onFilter }: { onFilter: (filters: Filters) => void }) {
  const [searchParams, setSearchParams] = useSearchParams();
 
  const filters = {
    category: searchParams.get('category'),
    minPrice: searchParams.get('minPrice'),
    maxPrice: searchParams.get('maxPrice'),
  };
 
  const updateFilter = (key: string, value: string) => {
    const newParams = new URLSearchParams(searchParams);
    newParams.set(key, value);
    setSearchParams(newParams);
  };
 
  // Other MFEs read the same URL params
  return (/* filter UI */);
}

Shared Services

Create a shared package for cross-cutting concerns:

// packages/shared-services/src/auth.ts
class AuthService {
  private static instance: AuthService;
  private user: User | null = null;
  private listeners: Set<(user: User | null) => void> = new Set();
 
  static getInstance(): AuthService {
    if (!AuthService.instance) {
      AuthService.instance = new AuthService();
    }
    return AuthService.instance;
  }
 
  getUser(): User | null {
    return this.user;
  }
 
  subscribe(listener: (user: User | null) => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }
 
  setUser(user: User | null): void {
    this.user = user;
    this.listeners.forEach((l) => l(user));
  }
}
 
export const authService = AuthService.getInstance();

Design System Integration

A shared design system maintains visual consistency:

packages/
├── design-system/
│   ├── src/
│   │   ├── components/
│   │   │   ├── Button/
│   │   │   ├── Input/
│   │   │   └── Modal/
│   │   ├── tokens/
│   │   │   ├── colors.ts
│   │   │   ├── spacing.ts
│   │   │   └── typography.ts
│   │   └── index.ts
│   └── package.json

Each micro frontend imports from the design system:

// apps/checkout/src/components/CartItem.tsx
import { Button, Card, Text } from '@myorg/design-system';
 
function CartItem({ item }: { item: CartItemType }) {
  return (
    <Card padding="md">
      <Text variant="body">{item.name}</Text>
      <Text variant="price">${item.price}</Text>
      <Button size="sm" onClick={() => removeItem(item.id)}>
        Remove
      </Button>
    </Card>
  );
}

Version the design system carefully—breaking changes affect all micro frontends.

Performance Optimization

Shared Dependencies

Configure shared dependencies to load once:

shared: {
  react: {
    singleton: true,
    requiredVersion: '^18.0.0',
    eager: false, // Lazy load
  },
  'react-dom': {
    singleton: true,
    requiredVersion: '^18.0.0',
  },
  // Large libraries that all MFEs use
  '@tanstack/react-query': {
    singleton: true,
  },
}

Lazy Loading

Load micro frontends only when needed:

const routes = [
  {
    path: '/dashboard',
    component: lazy(() => import('dashboard/App')),
  },
  {
    path: '/settings',
    component: lazy(() => import('settings/App')),
  },
];
 
// Preload on hover
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
  const preload = () => {
    const route = routes.find((r) => r.path === to);
    if (route) {
      route.component.preload?.();
    }
  };
 
  return (
    <Link to={to} onMouseEnter={preload}>
      {children}
    </Link>
  );
}

Bundle Analysis

Monitor each micro frontend's bundle size independently:

# Per-MFE analysis
cd apps/checkout && npm run build -- --analyze
cd apps/catalog && npm run build -- --analyze

Set size budgets per micro frontend:

{
  "budgets": [
    {
      "path": "apps/checkout/dist",
      "maxSize": "150kb"
    },
    {
      "path": "apps/catalog/dist",
      "maxSize": "200kb"
    }
  ]
}

Team Organization

Micro frontends work best with clear ownership:

Team: Checkout
├── apps/checkout/
├── Owns: Cart, Checkout flow, Payment
└── On-call rotation
 
Team: Catalog
├── apps/catalog/
├── Owns: Product listing, Search, Filters
└── On-call rotation
 
Team: Platform
├── apps/shell/
├── packages/design-system/
├── packages/shared-services/
└── Owns: Infrastructure, Shared tooling

Each team:

  • Deploys independently
  • Owns their CI/CD pipeline
  • Handles their own monitoring
  • Maintains their own documentation

The platform team provides shared infrastructure but doesn't block feature teams.

Deployment Strategy

Deploy micro frontends independently:

# apps/checkout/.github/workflows/deploy.yml
name: Deploy Checkout
 
on:
  push:
    branches: [main]
    paths:
      - 'apps/checkout/**'
      - 'packages/shared-services/**'
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run build -w apps/checkout
      - run: npm run deploy -w apps/checkout

Use versioned URLs for remote entries:

remotes: {
  checkout: `checkout@${CDN_URL}/checkout/v${CHECKOUT_VERSION}/remoteEntry.js`,
}

This enables rollbacks without redeploying the shell.

Micro frontends trade simplicity for team autonomy. When you have the scale to justify the complexity—multiple teams, large codebase, independent release needs—the architecture enables parallel development that monoliths can't match. Start simple, split when the pain of coordination exceeds the pain of distribution.