Skip to main content

Command Palette

Search for a command to run...

Micro Frontend Architecture Part 2: Implementation Guide and Production Patterns

Updated
15 min read
Micro Frontend Architecture Part 2: Implementation Guide and Production Patterns
L
I help startups and businesses build scalable web and mobile apps using MERN and React Native. From backend architecture to frontend experience, I deliver production-ready solutions with a focus on speed, performance, and reliability. I also integrate AI features to add smarter functionality and automation.

In Part 1, I broke down when Micro Frontends make sense and when they're architectural overkill. We covered the decision framework, performance trade-offs, and red flags to watch for.

Now comes the hard part: actually building them.

During my work at Accenture, I spent months evaluating different implementation patterns large React applications. I tested Module Federation, Single-SPA, different state management approaches, and deployment strategies.

This is the guide I wish I had when I started. No fluff, just production-ready patterns with real code.

If you've decided micro frontends are right for your team (and you've checked all the boxes from Part 1), here's exactly how to implement them without destroying your developer experience.


The Right Way to Implement Micro Frontends

There are two main patterns in the React ecosystem:

  1. Module Federation (Webpack 5+) — Recommended for React/Next.js

  2. Single-SPA — Framework agnostic, use when mixing React/Vue/Angular

Let's start with Module Federation since 90% of teams should use this.


Architecture Pattern 1: Module Federation (The Industry Standard)

Webpack Module Federation is the most mature solution for React-based micro frontends. It allows runtime code sharing between independently deployed apps.

How It Works

Three key concepts:

  • Shell App: The container that orchestrates everything (navigation, authentication, global state)

  • Remote Apps: Individual micro frontends exposed as federated modules

  • Shared Dependencies: React, React-DOM, common libraries loaded once (not duplicated)

Real-World Architecture

Here's how I structured a micro frontend system for a large analytics platform:

┌─────────────────────────────────────────────────────┐
│              Shell App (Port 3000)                  │
│  - Routing                                          │
│  - Authentication                                   │
│  - Global Layout                                    │
│  - Remote orchestration                             │
└─────────────────────────────────────────────────────┘
              │           │           │
              ▼           ▼           ▼
┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│  Analytics   │  │   Billing    │  │  Settings    │
│  (Port 3001) │  │  (Port 3002) │  │  (Port 3003) │
│              │  │              │  │              │
│ - Dashboard  │  │ - Invoices   │  │ - Profile    │
│ - Reports    │  │ - Payment    │  │ - Teams      │
│ - Insights   │  │ - Usage      │  │ - Security   │
└──────────────┘  └──────────────┘  └──────────────┘

Step 1: Configure the Remote App (Analytics Micro Frontend)

// webpack.config.js for Analytics Micro Frontend
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const path = require('path');

module.exports = {
  entry: './src/index.js',
  mode: 'development',
  devServer: {
    port: 3001,
    historyApiFallback: true,
  },
  output: {
    publicPath: 'auto',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'analytics',
      filename: 'remoteEntry.js',
      exposes: {
        './AnalyticsDashboard': './src/AnalyticsDashboard',
        './ReportsPage': './src/ReportsPage',
        './InsightsPage': './src/InsightsPage',
      },
      shared: {
        react: { 
          singleton: true, 
          requiredVersion: '^18.2.0',
          eager: true 
        },
        'react-dom': { 
          singleton: true, 
          requiredVersion: '^18.2.0',
          eager: true 
        },
        'react-router-dom': { 
          singleton: true,
          requiredVersion: '^6.8.0' 
        },
      },
    }),
  ],
  // ... rest of webpack config
};

Key Configuration Details:

  • name: 'analytics' — Unique identifier for this remote

  • filename: 'remoteEntry.js' — Entry point the shell will load

  • exposes — What components this remote makes available

  • shared — Dependencies shared with shell (prevents duplication)

  • singleton: true — Only one instance of React loaded across all apps

  • eager: true — Load immediately (prevents async loading issues)

Step 2: Configure the Shell App

// webpack.config.js for Shell App
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

module.exports = {
  entry: './src/index.js',
  mode: 'development',
  devServer: {
    port: 3000,
    historyApiFallback: true,
  },
  output: {
    publicPath: 'auto',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        analytics: 'analytics@http://localhost:3001/remoteEntry.js',
        billing: 'billing@http://localhost:3002/remoteEntry.js',
        settings: 'settings@http://localhost:3003/remoteEntry.js',
      },
      shared: {
        react: { 
          singleton: true, 
          requiredVersion: '^18.2.0',
          eager: true 
        },
        'react-dom': { 
          singleton: true, 
          requiredVersion: '^18.2.0',
          eager: true 
        },
        'react-router-dom': { 
          singleton: true,
          requiredVersion: '^6.8.0' 
        },
      },
    }),
  ],
  // ... rest of webpack config
};

Production Configuration:

// webpack.config.prod.js for Shell App
remotes: {
  analytics: 'analytics@https://analytics.yourapp.com/remoteEntry.js',
  billing: 'billing@https://billing.yourapp.com/remoteEntry.js',
  settings: 'settings@https://settings.yourapp.com/remoteEntry.js',
}

Step 3: Dynamic Remote Loading in Shell

// src/App.tsx (Shell App)
import React, { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import ErrorBoundary from './components/ErrorBoundary';
import LoadingSpinner from './components/LoadingSpinner';

// Lazy load micro frontends
const AnalyticsDashboard = lazy(() => import('analytics/AnalyticsDashboard'));
const BillingPage = lazy(() => import('billing/BillingPage'));
const SettingsPage = lazy(() => import('settings/SettingsPage'));

function App() {
  return (
    <Router>
      <div className="app-shell">
        <Navigation />
        
        <main className="content">
          <ErrorBoundary>
            <Suspense fallback={<LoadingSpinner />}>
              <Routes>
                <Route path="/" element={<HomePage />} />
                <Route path="/analytics/*" element={<AnalyticsDashboard />} />
                <Route path="/billing/*" element={<BillingPage />} />
                <Route path="/settings/*" element={<SettingsPage />} />
              </Routes>
            </Suspense>
          </ErrorBoundary>
        </main>
      </div>
    </Router>
  );
}

export default App;

Critical Pattern: Always wrap remote components in both ErrorBoundary and Suspense. If a remote fails to load, the shell should gracefully handle it.


Critical Implementation Challenges (And Solutions)

Challenge 1: State Management Across Micro Frontends

Problem: User logs in on dashboard, settings page needs to know who they are. How do micro frontends communicate?

Solution 1 - Custom Events (Simple, Works for Basic Cases):

// Shared event types
export const USER_LOGIN = 'user:login';
export const USER_LOGOUT = 'user:logout';
export const THEME_CHANGE = 'theme:change';

// Micro Frontend A - Emit event
function login(userData) {
  window.dispatchEvent(
    new CustomEvent(USER_LOGIN, { 
      detail: { userId: userData.id, name: userData.name, email: userData.email } 
    })
  );
}

// Micro Frontend B - Listen for event
import { useEffect, useState } from 'react';

function useGlobalUser() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const handleUserLogin = (e) => setUser(e.detail);
    const handleUserLogout = () => setUser(null);

    window.addEventListener(USER_LOGIN, handleUserLogin);
    window.addEventListener(USER_LOGOUT, handleUserLogout);

    return () => {
      window.removeEventListener(USER_LOGIN, handleUserLogin);
      window.removeEventListener(USER_LOGOUT, handleUserLogout);
    };
  }, []);

  return user;
}

// Usage in any micro frontend
function AnalyticsDashboard() {
  const user = useGlobalUser();
  
  if (!user) return <LoginPrompt />;
  
  return <Dashboard userId={user.userId} />;
}

Solution 2 - Shared State Store (Advanced, Recommended for Complex Apps):

// Shared package: @myapp/shared-state
// packages/shared-state/src/store.ts
import create from 'zustand';
import { persist } from 'zustand/middleware';

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

interface GlobalState {
  user: User | null;
  theme: 'light' | 'dark';
  setUser: (user: User | null) => void;
  setTheme: (theme: 'light' | 'dark') => void;
  logout: () => void;
}

export const useGlobalStore = create<GlobalState>()(
  persist(
    (set) => ({
      user: null,
      theme: 'light',
      
      setUser: (user) => set({ user }),
      
      setTheme: (theme) => set({ theme }),
      
      logout: () => set({ user: null }),
    }),
    {
      name: 'global-storage',
      partialize: (state) => ({ 
        user: state.user, 
        theme: state.theme 
      }),
    }
  )
);
// Usage in Shell App
import { useGlobalStore } from '@myapp/shared-state';

function ShellApp() {
  const { user, setUser } = useGlobalStore();
  
  const handleLogin = async (credentials) => {
    const userData = await loginAPI(credentials);
    setUser(userData);
  };
  
  return (
    <Router>
      {/* ... routes */}
    </Router>
  );
}
// Usage in Analytics Micro Frontend
import { useGlobalStore } from '@myapp/shared-state';

function AnalyticsDashboard() {
  const user = useGlobalStore((state) => state.user);
  const theme = useGlobalStore((state) => state.theme);
  
  return (
    <div className={`dashboard theme-${theme}`}>
      <h1>Welcome, {user?.name}</h1>
      {/* analytics content */}
    </div>
  );
}

Shared as a Federated Module:

// webpack.config.js for shared-state package
new ModuleFederationPlugin({
  name: 'sharedState',
  filename: 'remoteEntry.js',
  exposes: {
    './store': './src/store.ts',
  },
  shared: ['zustand', 'react'],
});

Best Practice: Keep shared state minimal. Only share:

  • Authentication (user object, tokens)

  • Theme preferences

  • Global config (feature flags, API base URLs)

Everything else should be local to the micro frontend.


Challenge 2: Routing Across Micro Frontends

Problem: User navigates from /dashboard to /analytics. How do routes work without conflicts?

Solution - Shell-Based Routing with Nested Routes:

// Shell App - Top-level routing
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

function ShellApp() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/dashboard/*" element={<DashboardApp />} />
        <Route path="/analytics/*" element={<AnalyticsApp />} />
        <Route path="/billing/*" element={<BillingApp />} />
      </Routes>
    </Router>
  );
}
// Analytics Micro Frontend - Sub-routes
import { Routes, Route } from 'react-router-dom';

export default function AnalyticsApp() {
  return (
    <Routes>
      <Route index element={<AnalyticsOverview />} />
      <Route path="reports" element={<ReportsPage />} />
      <Route path="reports/:reportId" element={<ReportDetailPage />} />
      <Route path="insights" element={<InsightsPage />} />
      <Route path="*" element={<NotFound />} />
    </Routes>
  );
}

Navigation Between Micro Frontends:

// From Dashboard to Analytics
import { useNavigate } from 'react-router-dom';

function DashboardWidget() {
  const navigate = useNavigate();
  
  return (
    <button onClick={() => navigate('/analytics/reports')}>
      View Full Report
    </button>
  );
}

Programmatic Navigation with State:

// Pass data between micro frontends via navigation state
navigate('/analytics/reports', { 
  state: { 
    dateRange: { start: '2024-01-01', end: '2024-01-31' } 
  } 
});

// In Analytics app
import { useLocation } from 'react-router-dom';

function ReportsPage() {
  const location = useLocation();
  const dateRange = location.state?.dateRange;
  
  return <Reports initialDateRange={dateRange} />;
}

Challenge 3: Shared UI Component Library

Problem: You need consistent buttons, inputs, modals across all micro frontends without duplicating code.

Wrong Approach: Each micro frontend bundles its own copy → 200KB+ duplication.

Right Approach: Share the component library as a federated module.

// Component Library - webpack.config.js
// packages/design-system/webpack.config.js
new ModuleFederationPlugin({
  name: 'designSystem',
  filename: 'remoteEntry.js',
  exposes: {
    './Button': './src/components/Button',
    './Input': './src/components/Input',
    './Modal': './src/components/Modal',
    './Card': './src/components/Card',
    './Select': './src/components/Select',
  },
  shared: {
    react: { singleton: true },
    'react-dom': { singleton: true },
  },
});
// Usage in any micro frontend
import { Button, Input, Modal } from 'designSystem/components';

function LoginForm() {
  return (
    <Modal>
      <Input placeholder="Email" />
      <Input type="password" placeholder="Password" />
      <Button>Login</Button>
    </Modal>
  );
}

Version Control Strategy:

// Pin specific version in production
remotes: {
  designSystem: 'designSystem@https://cdn.yourapp.com/design-system/v1.2.3/remoteEntry.js',
}

// Development - use local version
remotes: {
  designSystem: process.env.NODE_ENV === 'development'
    ? 'designSystem@http://localhost:4000/remoteEntry.js'
    : 'designSystem@https://cdn.yourapp.com/design-system/v1.2.3/remoteEntry.js',
}

Challenge 4: Performance and Bundle Size Optimization

Problem: Each micro frontend adds network overhead and bundle size.

Solution 1 - Aggressive Code Splitting:

// Lazy load heavy dependencies
const HeavyChart = lazy(() => import('./components/HeavyChart'));
const DataTable = lazy(() => import('./components/DataTable'));

function AnalyticsDashboard() {
  const [showChart, setShowChart] = useState(false);
  
  return (
    <div>
      <button onClick={() => setShowChart(true)}>Show Chart</button>
      
      {showChart && (
        <Suspense fallback={<Skeleton />}>
          <HeavyChart />
        </Suspense>
      )}
    </div>
  );
}

Solution 2 - Prefetching and Preloading:

// Prefetch on hover for instant navigation
import { Link } from 'react-router-dom';

function Navigation() {
  const prefetchAnalytics = () => {
    // Webpack magic comment for prefetching
    import(/* webpackPrefetch: true */ 'analytics/AnalyticsDashboard');
  };
  
  return (
    <nav>
      <Link 
        to="/analytics" 
        onMouseEnter={prefetchAnalytics}
      >
        Analytics
      </Link>
    </nav>
  );
}

Solution 3 - Bundle Size Monitoring:

// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      openAnalyzer: false,
      reportFilename: 'bundle-report.html',
    }),
  ],
  performance: {
    maxEntrypointSize: 300000, // 300KB limit
    maxAssetSize: 300000,
    hints: 'error',
  },
};

Real-World Implementation Checklist

Here's the exact 10-week roadmap I follow:

Phase 1 - Foundation (Week 1-2)

Week 1: Architecture Planning

  • [ ] Define clear domain boundaries (1 micro frontend = 1 business domain)

  • [ ] Map existing features to micro frontend domains

  • [ ] Identify shared dependencies and create dependency matrix

  • [ ] Set up mono repo structure or multi-repo strategy

  • [ ] Create architecture documentation

Week 2: Infrastructure Setup

  • [ ] Set up Module Federation shell app

  • [ ] Create shared state package with Zustand

  • [ ] Set up shared component library as federated module

  • [ ] Configure webpack for all micro frontends

  • [ ] Set up local development environment (run all apps simultaneously)

Phase 2 - First Micro Frontend (Week 3-4)

Week 3: Build First Remote

  • [ ] Choose least critical domain (e.g., Analytics, Reports)

  • [ ] Build micro frontend with proper error boundaries

  • [ ] Implement routing within micro frontend

  • [ ] Integrate with shared state

  • [ ] Add comprehensive error handling

Week 4: Integration

  • [ ] Integrate first micro frontend into shell

  • [ ] Test cross-app navigation

  • [ ] Implement fallback UI if remote fails

  • [ ] Set up monitoring (bundle size, load times)

  • [ ] Deploy to staging environment

Phase 3 - CI/CD and Testing (Week 5-6)

Week 5: Deployment Pipelines

  • [ ] Set up independent CI/CD for each micro frontend

  • [ ] Configure versioned deployments

  • [ ] Implement health checks for each remote

  • [ ] Set up centralized logging (Sentry, DataDog)

  • [ ] Create rollback procedures

Week 6: Testing Infrastructure

  • [ ] Write integration tests across micro frontends

  • [ ] Set up E2E testing (Cypress, Playwright)

  • [ ] Implement visual regression testing

  • [ ] Test failure scenarios (remote unavailable)

  • [ ] Performance testing and benchmarking

Phase 4 - Scale to All Domains (Week 7-9)

Week 7-9: Migrate Remaining Domains

  • [ ] Build and integrate 2nd micro frontend

  • [ ] Build and integrate 3rd micro frontend

  • [ ] Optimize shared dependencies

  • [ ] Implement prefetching strategies

  • [ ] Fine-tune webpack configs for production

Phase 5 - Production Hardening (Week 10)

  • [ ] Security audit (CSP policies, CORS)

  • [ ] Accessibility audit across all micro frontends

  • [ ] Create developer onboarding documentation

  • [ ] Set up feature flag system

  • [ ] Final performance optimization

  • [ ] Production deployment


Common Mistakes I've Seen (And How to Avoid Them)

Mistake 1: Too Many Micro Frontends

Bad: Breaking a 5-page app into 5 micro frontends.

Good: 1 micro frontend per major business domain. For most apps, 3-5 micro frontends is the sweet spot.

Rule of thumb: If a "micro frontend" is less than 3 pages, it shouldn't be separate.

Mistake 2: Sharing Too Much

Bad: Shared utils, shared helpers, shared hooks, shared context, shared everything → You've created a distributed monolith.

Good: Only share authentication, theme, and global config. Each micro frontend should be 80% independent.

// BAD - Over-sharing
import { useDataFetching } from '@shared/hooks';
import { formatDate, calculateTotal } from '@shared/utils';
import { UserContext, ProductContext } from '@shared/contexts';

// GOOD - Minimal sharing
import { useAuth } from '@shared/state'; // Only auth
const formatDate = (date) => { /* local implementation */ };

Mistake 3: Ignoring Developer Experience

Bad: Developers run 4 separate apps to test one feature. Local development is painful.

Good: Mock remote apps in development. Use webpack aliases.

// webpack.config.dev.js
const isDev = process.env.NODE_ENV === 'development';

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      remotes: {
        analytics: isDev && process.env.LOCAL_ANALYTICS
          ? 'analytics@http://localhost:3001/remoteEntry.js'
          : 'analytics@https://analytics.prod.com/remoteEntry.js',
      },
    }),
  ],
};
# .env.development
LOCAL_ANALYTICS=true  # Use local version
LOCAL_BILLING=false   # Use production version

Mistake 4: No Versioning Strategy

Bad: Shell always loads latest version of remotes → Breaking changes crash production.

Good: Pin remote versions explicitly. Use semantic versioning.

// Production - Explicit versions
remotes: {
  analytics: 'analytics@https://cdn.app.com/analytics/v1.2.3/remoteEntry.js',
  billing: 'billing@https://cdn.app.com/billing/v2.0.1/remoteEntry.js',
}

// Update process:
// 1. Deploy new version (v1.2.4) to CDN
// 2. Test in staging with new version
// 3. Update shell config to point to v1.2.4
// 4. Deploy shell

Mistake 5: Not Handling Remote Failures

Bad: Remote fails to load → Entire app crashes.

Good: Graceful degradation with error boundaries.

// ErrorBoundary.tsx
class RemoteErrorBoundary extends React.Component {
  state = { hasError: false, error: null };

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

  componentDidCatch(error, info) {
    console.error('Remote loading failed:', error);
    // Send to error tracking
    logErrorToService(error, info);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="remote-error">
          <h2>This section is temporarily unavailable</h2>
          <p>We're working on it. Please try again later.</p>
          <button onClick={() => window.location.reload()}>
            Refresh Page
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

Architecture Pattern 2: Single-SPA (Framework Agnostic)

Use this only if you need to mix React, Vue, and Angular. Otherwise, stick with Module Federation.

// single-spa-config.ts
import { registerApplication, start } from 'single-spa';

registerApplication({
  name: '@myapp/react-dashboard',
  app: () => System.import('@myapp/react-dashboard'),
  activeWhen: ['/dashboard'],
});

registerApplication({
  name: '@myapp/vue-analytics',
  app: () => System.import('@myapp/vue-analytics'),
  activeWhen: ['/analytics'],
});

registerApplication({
  name: '@myapp/angular-billing',
  app: () => System.import('@myapp/angular-billing'),
  activeWhen: ['/billing'],
});

start();

Why I don't recommend this:

  • Adds significant complexity

  • Developer context switching between frameworks

  • Harder to maintain consistent UX

  • Tooling fragmentation

When it makes sense: Legacy migration where rewriting isn't feasible.


Final Thoughts

Micro Frontends are not a silver bullet. They're a team scaling solution with real technical costs.

If you've made it this far and you're implementing them, here are my parting recommendations:

  1. Start small: Build one micro frontend first. Validate the pattern works for your team before going all-in.

  2. Measure everything: Track bundle sizes, load times, deployment frequency, team velocity. Make data-driven decisions.

  3. Invest in DX: If local development is painful, developers will hate it. Make the tooling seamless.

  4. Document extensively: Micro frontends have more moving parts. Good docs prevent disasters.

  5. Plan for failures: Remotes will fail. Networks will be slow. Have fallbacks.

The teams that succeed with micro frontends are the ones who treat it as an organizational architecture, not just a technical one.


Did Part 1 and Part 2 give you a complete picture of Micro Frontends? What's still unclear?

If you're implementing this in your team, what's the biggest challenge you're facing? Drop it in the comments — I'd love to help.

Found this guide useful? Share it with other developers evaluating architectural decisions. It might save them months of trial and error.


More from this blog

K

Krishna's Blog | MERN | React Native

5 posts

4 years in the JS/TS ecosystem has taught me one thing: most "best practices" don't work at scale. I write the guides I wish I had when I was battling 3 AM production crashes and messy technical debt. I focus on high-impact engineering—MERN, React Native optimization, and making sense of AI integrations. No generic roadmaps here; just tactical, blunt insights to help you ship better software and get paid what you’re worth.