Back to Blog

Microfrontend Architecture with React - Lessons from Production

R
Randy Vianda Putra
· · 3 min read
Microfrontend Architecture with React - Lessons from Production

Microfrontend Architecture with React - Lessons from Production

At Moladin, we ran a full microfrontend architecture across a fintech platform for over two years. I was part of the team that designed, built, and maintained it - first as a Frontend Engineer then as a Full-stack Engineer. Here’s the honest, battle-tested take.

Why We Went Microfrontend

We had a monolithic React app that was getting unwieldy. Three different tribes (squads) were shipping to the same codebase, causing merge conflicts, slow builds, and deployment coupling.

The promise: each tribe owns its domain end-to-end. Ship independently. Scale teams independently.

Our Setup: Module Federation

We used Webpack 5 Module Federation. Each team owned a “remote” app; a central “shell” app composed them at runtime.

shell-app (host)
  ├── crm-app (remote)       → /crm/*
  ├── payments-app (remote)  → /payments/*
  └── inventory-app (remote) → /inventory/*
// webpack.config.js - Remote App (crm-app)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'crmApp',
      filename: 'remoteEntry.js',
      exposes: {
        './CRMRoutes': './src/routes/CRMRoutes',
        './CustomerDetail': './src/pages/CustomerDetail',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};
// webpack.config.js - Shell App (host)
new ModuleFederationPlugin({
  name: 'shell',
  remotes: {
    crmApp: 'crmApp@https://crm.internal/remoteEntry.js',
    paymentsApp: 'paymentsApp@https://payments.internal/remoteEntry.js',
  },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),

Shared State: The Hard Problem

This is where microfrontends get painful. You can’t just use React Context across app boundaries.

We solved it with a custom event bus and Redux slices published as shared modules.

// shared/event-bus.ts
type EventHandler<T = unknown> = (payload: T) => void;

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

  emit<T>(event: string, payload: T) {
    this.listeners.get(event)?.forEach((handler) => handler(payload));
  }

  on<T>(event: string, handler: EventHandler<T>) {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set());
    this.listeners.get(event)!.add(handler as EventHandler);
    return () => this.listeners.get(event)?.delete(handler as EventHandler);
  }
}

export const eventBus = new EventBus();

// Usage: When a payment succeeds in payments-app, notify crm-app
eventBus.emit('payment:completed', { orderId, customerId, amount });

TypeScript Across Boundaries

Sharing types between apps without creating circular dependencies was a recurring challenge. Our solution: a shared types package published as an internal npm package.

// @company/shared-types/src/index.ts
export interface Customer {
  id: string;
  name: string;
  email: string;
  tier: 'bronze' | 'silver' | 'gold';
}

export interface Order {
  id: string;
  customerId: string;
  status: OrderStatus;
  amount: number;
  createdAt: Date;
}

Every team adds @company/shared-types as a dependency. Type changes go through a PR review process.

What Went Well

  • Independent deployments - Teams shipped without stepping on each other
  • Clear ownership - Every component had an owner
  • Smaller bundles - Each app only loaded what it needed

What Went Wrong

  • Version drift - Teams lagged behind on React upgrades, causing version conflicts in shared modules
  • Initial load complexity - The shell needed to orchestrate loading multiple remotes with proper error boundaries
  • Debugging is harder - Stack traces cross app boundaries. Distributed tracing for UIs is painful.
  • Shared styles bleed - CSS-in-JS helped, but global Tailwind styles would leak between apps

My Recommendation

Don’t reach for microfrontends until your team is 8+ engineers and the monolith pain is real. Module Federation adds meaningful complexity. If you’re starting fresh, a well-structured monorepo with Turborepo is usually a better first step.

But if you’re at scale - it works. We shipped faster with autonomous teams than we ever did with a shared codebase.