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.
Share this article