ArchitectureFrontend

Micro-Frontend Architecture: Modular UI Design for Large-Scale Web Apps

TT
TopicTrick Team
Micro-Frontend Architecture: Modular UI Design for Large-Scale Web Apps

Micro-Frontend Architecture: Modular UI Design for Large-Scale Web Apps

Micro-frontend architecture extends the microservices idea to the frontend: instead of one large React or Vue application owned by all teams, each team owns and independently deploys a slice of the UI. Teams can use different frameworks, release on their own schedule, and scale development without the merge conflicts, coordination overhead, and shared pipeline bottlenecks of a monolithic frontend.

This guide covers when micro-frontends are justified, the four integration approaches with real implementation examples, module federation with Webpack and Vite, shared state patterns, design system integration, and the performance trade-offs to monitor.


When Micro-Frontends Are Justified

Micro-frontends add significant orchestration complexity. Apply Conway's Law honestly before committing:

SituationRecommendation
1-3 frontend developersMonorepo monolith — micro-frontends will slow you down
4-15 developers, one teamModular monorepo — shared boundaries, single deploy
3+ independent teams, different release cadencesMicro-frontends worth evaluating
Independent features that never share stateStrong candidate
Features deeply intertwined with shared stateMonolith probably better
Different tech stacks per teamMicro-frontends enable this

The micro-frontend pattern is used by IKEA, Spotify, Zalando, and DAZN — all organisations with dozens of frontend teams and clear domain boundaries. It is not a pattern for small teams trying to look architecturally sophisticated.


Integration Approaches

1. Build-Time Integration (npm packages)

Teams publish components as npm packages. The shell app imports and bundles them.

bash
# Team A publishes their component
cd packages/dashboard && npm publish --access restricted

# Shell app installs
npm install @my-org/dashboard @my-org/analytics
tsx
// shell/src/App.tsx
import { Dashboard } from '@my-org/dashboard';
import { Analytics } from '@my-org/analytics';

export function App() {
  return (
    <Router>
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/analytics" element={<Analytics />} />
    </Router>
  );
}

Trade-off: Simple to implement, but loses independence — the shell must be redeployed every time a micro-frontend updates. Not a true micro-frontend; more like a modular monolith.

2. Runtime Integration via iframes

Each micro-frontend runs in an isolated iframe.

html
<!-- shell/index.html -->
<div id="main-content">
  <iframe
    id="mfe-dashboard"
    src="https://dashboard.internal.example.com"
    style="width:100%; border:none; height:600px;"
    title="Dashboard application"
  ></iframe>
</div>

Trade-off: Maximum isolation (no CSS or JS leakage), but terrible UX — navigation, deep linking, and shared state are painful. The iframe approach is largely avoided in 2026 except for embedding genuinely external applications.

3. Web Components

Each micro-frontend wraps itself in a custom element. Works across any host framework.

typescript
// dashboard-mfe/src/index.ts
import React from 'react';
import { createRoot } from 'react-dom/client';
import { DashboardApp } from './App';

class DashboardElement extends HTMLElement {
  private root: ReturnType<typeof createRoot> | null = null;

  connectedCallback() {
    // Read configuration from attributes
    const userId = this.getAttribute('user-id') ?? '';
    const theme = this.getAttribute('theme') ?? 'light';

    this.root = createRoot(this);
    this.root.render(<DashboardApp userId={userId} theme={theme} />);
  }

  disconnectedCallback() {
    this.root?.unmount();
  }

  // React to attribute changes
  static get observedAttributes() {
    return ['user-id', 'theme'];
  }

  attributeChangedCallback() {
    if (this.root) {
      this.connectedCallback();
    }
  }
}

customElements.define('mfe-dashboard', DashboardElement);
html
<!-- Shell consumes it as a standard HTML element -->
<script src="https://dashboard.example.com/bundle.js" defer></script>
<mfe-dashboard user-id="123" theme="dark"></mfe-dashboard>

Trade-off: Framework-agnostic, good browser support. Shadow DOM provides CSS isolation. Attribute-based configuration is limited compared to props.

4. Module Federation (The Modern Standard)

Webpack 5 Module Federation and Vite Federation allow micro-frontends to expose components that other applications load at runtime — no npm publish required.

javascript
// dashboard-mfe/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@originjs/vite-plugin-federation';

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: 'dashboard',
      filename: 'remoteEntry.js',
      exposes: {
        './DashboardPage': './src/pages/DashboardPage',
        './DashboardWidget': './src/components/DashboardWidget'
      },
      shared: ['react', 'react-dom', 'react-router-dom']   // Shared to avoid duplicate bundles
    })
  ],
  build: {
    target: 'esnext',
    minify: false,
    cssCodeSplit: false
  }
});
javascript
// shell/vite.config.ts
import federation from '@originjs/vite-plugin-federation';

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: 'shell',
      remotes: {
        dashboard: 'https://dashboard.example.com/assets/remoteEntry.js',
        analytics: 'https://analytics.example.com/assets/remoteEntry.js'
      },
      shared: ['react', 'react-dom', 'react-router-dom']
    })
  ]
});
tsx
// shell/src/App.tsx
import React, { Suspense, lazy } from 'react';

// Lazy-loaded from the remote server at runtime
const DashboardPage = lazy(() => import('dashboard/DashboardPage'));
const AnalyticsPage = lazy(() => import('analytics/AnalyticsPage'));

export function App() {
  return (
    <Router>
      <Suspense fallback={<div>Loading...</div>}>
        <Route path="/dashboard" element={<DashboardPage />} />
        <Route path="/analytics" element={<AnalyticsPage />} />
      </Suspense>
    </Router>
  );
}

When the user navigates to /dashboard, the shell fetches remoteEntry.js from the dashboard deployment and renders the component. The dashboard team can deploy updates independently — the shell picks them up without a redeploy.


Shared State Between Micro-Frontends

Shared state is the hardest problem in micro-frontend architecture. Redux and React Context do not cross bundle boundaries. The main options:

Custom Events (for loose coupling)

typescript
// Emitting from the authentication micro-frontend
window.dispatchEvent(new CustomEvent('user:logged-in', {
  detail: { userId: '123', name: 'Alice', role: 'admin' },
  bubbles: true
}));

// Consuming in the dashboard micro-frontend
window.addEventListener('user:logged-in', (event: Event) => {
  const customEvent = event as CustomEvent<{ userId: string; name: string; role: string }>;
  setCurrentUser(customEvent.detail);
});

Shared State in localStorage / sessionStorage

typescript
// Shared auth utility (published as an npm package used by all MFEs)
// @my-org/shared-auth

const AUTH_KEY = 'mfe:auth:user';

export function setCurrentUser(user: User) {
  sessionStorage.setItem(AUTH_KEY, JSON.stringify(user));
  window.dispatchEvent(new CustomEvent('mfe:user-changed'));
}

export function getCurrentUser(): User | null {
  const stored = sessionStorage.getItem(AUTH_KEY);
  return stored ? JSON.parse(stored) : null;
}

export function onUserChanged(callback: (user: User | null) => void) {
  const handler = () => callback(getCurrentUser());
  window.addEventListener('mfe:user-changed', handler);
  return () => window.removeEventListener('mfe:user-changed', handler);
}

Shared State Server (for complex cases)

For complex real-time state, use a lightweight backend service that all micro-frontends connect to via WebSocket or Server-Sent Events.

Golden rule: minimise shared state between micro-frontends. If two MFEs need to share state constantly, they may belong in the same MFE. Shared state is coupling — it undermines the independence that justifies the architecture.


Design System Integration

A mandatory requirement for micro-frontends: a shared design system package that all MFEs depend on. Without it, each team implements buttons, inputs, and typography independently, producing an inconsistent user experience.

bash
# Design system structure
@my-org/design-system/
├── src/
│   ├── components/
│   │   ├── Button/
│   │   ├── Input/
│   │   ├── Modal/
│   │   └── index.ts
│   ├── tokens/
│   │   ├── colors.ts
│   │   ├── spacing.ts
│   │   └── typography.ts
│   └── index.ts
├── dist/
└── package.json
tsx
// Each MFE imports from the shared design system
import { Button, Input, Modal } from '@my-org/design-system';

export function LoginForm() {
  return (
    <form>
      <Input type="email" label="Email" name="email" />
      <Input type="password" label="Password" name="password" />
      <Button variant="primary" type="submit">Sign In</Button>
    </form>
  );
}

Version the design system with semantic versioning. MFEs pin to a version and upgrade deliberately — a breaking change to the design system should not force every MFE to update simultaneously.


Performance Considerations

Module federation enables independent deployments but creates performance risks:

Shared Dependencies

Without sharing, each MFE bundles its own copy of React (140KB gzipped). With 5 MFEs, that is 700KB of redundant React. The shared configuration in federation prevents this:

javascript
shared: {
  react: { singleton: true, requiredVersion: '^18.0.0' },
  'react-dom': { singleton: true, requiredVersion: '^18.0.0' }
}

singleton: true ensures only one version loads even if multiple MFEs request it.

Lazy Loading

Never eagerly load all remote entry files. Load each MFE only when the user navigates to its route:

tsx
// Bad: all remoteEntry.js files load on initial page load
import DashboardPage from 'dashboard/DashboardPage';

// Good: loads only when the route is matched
const DashboardPage = lazy(() => import('dashboard/DashboardPage'));

Performance Budget per MFE

AssetBudget
Initial JS (shared-excluded)< 150KB gzipped
CSS< 30KB gzipped
Time to Interactive (TTI)< 3s on 4G
remoteEntry.js< 10KB

Deployment and CI/CD

Each micro-frontend has its own independent pipeline:

yaml
# dashboard-mfe/.github/workflows/deploy.yml
name: Deploy Dashboard MFE

on:
  push:
    branches: [main]

jobs:
  build-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - run: npm ci && npm run build

      - name: Deploy to CDN
        run: aws s3 sync ./dist s3://mfe-assets/dashboard/ --cache-control "public,max-age=31536000,immutable"

      - name: Invalidate remoteEntry.js cache only
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} \
            --paths "/dashboard/remoteEntry.js"
        # Note: content-addressed assets (hashed filenames) never need invalidation
        # Only remoteEntry.js (the manifest) needs invalidation

The shell application does not need to be redeployed when a micro-frontend updates — it fetches the new remoteEntry.js on the next request.


Frequently Asked Questions

Q: Does micro-frontend architecture work with server-side rendering (SSR)?

Yes, but it is significantly more complex. Each MFE needs to support SSR independently, and the shell must coordinate server-side composition. Tools like Mosaic (Zalando) and OpenComponents were built specifically for server-side composition. For most teams, client-side composition via Module Federation is the practical starting point, with SSR for specific performance-critical routes handled separately.

Q: How do I handle authentication across micro-frontends?

Centralise authentication in the shell or a dedicated authentication MFE. After login, store the JWT or session in a shared location (sessionStorage, a shared cookie). Individual MFEs read the token from that location — they never handle the login flow. Use a shared @my-org/auth utility package that all MFEs import for reading user identity and refreshing tokens.

Q: Can different micro-frontends use different versions of React?

Technically yes (by not using the singleton sharing option), but this loads multiple copies of React which impacts performance significantly. In practice, align all MFEs on the same major React version to keep the shared bundle overhead minimal. Framework diversity is more valuable across microservices (backend) than across MFEs sharing the same browser session.

Q: How do I test micro-frontends end-to-end?

Unit test each MFE independently with its own test suite. For integration testing, spin up all MFEs together in a test environment and run Playwright or Cypress tests against the composed application. Contract testing (using tools like Pact) can verify that the shell and each MFE agree on event schemas and attribute interfaces without requiring all components to be running simultaneously.


Key Takeaway

Micro-frontend architecture scales frontend development across multiple independent teams by decomposing the UI into separately deployable units. Module Federation (Webpack 5 or Vite) is the production-grade integration approach in 2026 — it enables runtime composition without forcing MFE teams to coordinate releases through the shell. The mandatory supporting infrastructure includes a shared design system (for consistency), a shared dependency strategy (for performance), and minimal shared state (to preserve independence). Only adopt micro-frontends when Conway's Law clearly demands it — three or more teams with genuinely independent domains and release cadences. For smaller teams, a well-structured monorepo monolith delivers most of the benefits with a fraction of the complexity.

Read next: Service-Oriented Architecture (SOA): The Enterprise Legacy →


Part of the Software Architecture Hub — engineering the UI.