Skip to content
← All posts
036 min read

Micro Frontend Architecture with Module Federation 2.0 — Production Setup 2026

  • frontend-architecture
  • micro-frontends
  • module-federation
Micro Frontend Architecture with Module Federation 2.0 — Production Setup 2026 — by Chirag Mehta
Micro Frontend Architecture with Module Federation 2.0 — Production Setup 2026

A deep-dive production guide to Micro Frontend Architecture using Module Federation 2.0 in 2026. Learn host/remote setup, shared dependencies, dynamic remotes, and deployment strategies that actually scale.

Micro frontends promised the same freedom that microservices gave backend teams. In theory, each team owns their slice of the UI — independent deployments, independent tech stacks, zero coordination overhead.

In practice, most setups from 2021–2023 were a mess. Shared state was a nightmare. CSS leaked everywhere. Bundle sizes ballooned. And the “independent deployments” dream fell apart the moment two teams needed the same version of React.

Module Federation 2.0 changes that. Released as a stable feature in Webpack 5 and now natively supported in Rspack and Vite, it solves the hardest problems from the first generation. This is the production setup guide I wish I had.

What Is Module Federation 2.0?

Module Federation lets one JavaScript application dynamically load code from another application at runtime — without a build-time dependency between them.

Version 2.0 brings:

  • Dynamic type hints across remotes (TypeScript types shared at runtime)
  • Runtime plugin system for custom loading logic
  • Better shared dependency negotiation — no more version mismatches crashing the page
  • Enhanced manifest system for more predictable remote discovery

The Architecture We’re Building

The Architecture We’re Building
The Architecture We’re Building

Three apps. One user experience. Fully independent deployments.

Step 1: Project Setup

Create three separate apps:

# Shell app
npx create-next-app@latest shell --typescript
cd shell && npm install @module-federation/enhanced
# Cart remote
npx create-react-app cart --template typescript
cd cart && npm install @module-federation/enhanced
# Catalog remote
npx create-react-app catalog --template typescript
cd catalog && npm install @module-federation/enhanced

Step 2: Configure the Shell (Host)

In your shell app’s webpack.config.js (or next.config.js for Next.js):

// next.config.js (Shell)
const { NextFederationPlugin } = require('@module-federation/nextjs-mf');
module.exports = {
webpack(config, options) {
config.plugins.push(
new NextFederationPlugin({
name: 'shell',
remotes: {
cart: `cart@${process.env.CART_URL}/remoteEntry.js`,
catalog: `catalog@${process.env.CATALOG_URL}/remoteEntry.js`,
},
shared: {
react: { singleton: true, requiredVersion: '^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
},
filename: 'static/chunks/remoteEntry.js',
})
);
return config;
},
};

Key points:

  • singleton: true ensures only ONE copy of React runs — critical for hooks
  • requiredVersion enforces compatibility checks at load time
  • URLs come from environment variables for easy environment switching

Step 3: Configure the Cart Remote

// webpack.config.js (Cart remote)
const { ModuleFederationPlugin } = require('@module-federation/enhanced');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'cart',
filename: 'remoteEntry.js',
exposes: {
'./CartWidget': './src/components/CartWidget',
'./CartPage': './src/pages/CartPage',
},
shared: {
react: { singleton: true, requiredVersion: '^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
},
}),
],
};

Step 4: Consuming Remotes in the Shell

// shell/src/app/layout.tsx
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
// Lazy-loaded remote component
const CartWidget = dynamic(
() => import('cart/CartWidget').catch(() => import('./FallbackCart')),
{
ssr: false,
loading: () => <div className="cart-skeleton" />,
}
);
export default function RootLayout({ children }) {
return (
<html>
<body>
<nav>
<Suspense fallback={<span>Loading cart...</span>}>
<CartWidget />
</Suspense>
</nav>
{children}
</body>
</html>
);
}

The .catch() fallback is non-negotiable in production. If the Cart service is down, your entire shell should not crash.

Step 5: Sharing State Between Remotes

Sharing State Between Remotes

This is where most teams trip up. You have three options:

Option A: Shell-Owned Context (Recommended)

// shell/src/context/AppContext.tsx
import { createContext, useContext, useState } from 'react';
const AppContext = createContext(null);
export function AppProvider({ children }) {
const [cartCount, setCartCount] = useState(0);
const [user, setUser] = useState(null);
  return (
<AppContext.Provider value={{ cartCount, setCartCount, user, setUser }}>
{children}
</AppContext.Provider>
);
}
// Expose this context via Module Federation too
// so remotes can subscribe to it
export const useApp = () => useContext(AppContext);

Expose AppContext as a federated module from the shell, and remotes import it. The shell owns the state, remotes just read/write.

Option B: Event Bus

// shared/eventBus.ts (exposed from shell)
type EventMap = {
'cart:updated': { count: number };
'user:logged-in': { userId: string };
};
class EventBus {
private listeners = new Map<string, Function[]>();
  emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {
this.listeners.get(event)?.forEach(fn => fn(data));
}
  on<K extends keyof EventMap>(event: K, fn: (data: EventMap[K]) => void) {
if (!this.listeners.has(event)) this.listeners.set(event, []);
this.listeners.get(event)!.push(fn);
return () => this.off(event, fn); // cleanup
}
  off(event: string, fn: Function) {
const list = this.listeners.get(event) || [];
this.listeners.set(event, list.filter(f => f !== fn));
}
}
export const bus = new EventBus();

Option C: URL/Query Params

For simple cross-remote communication that survives page refreshes. Works well for filters, selected IDs, pagination state.

Step 6: TypeScript Types Across Remotes

Module Federation 2.0’s killer feature: automatic type sharing.

npm install @module-federation/dts-plugin
// Cart remote webpack config
const { DtsPlugin } = require('@module-federation/dts-plugin');
plugins: [
new ModuleFederationPlugin({ ... }),
new DtsPlugin({
host: {
typesFolder: '@mf-types',
},
}),
]
// Shell webpack config
plugins: [
new NextFederationPlugin({ ... }),
new DtsPlugin({
remote: {
typesFolder: '@mf-types',
},
}),
]

Now when you import('cart/CartWidget'), TypeScript knows exactly what props it accepts. No more any.

Step 7: Production Deployment on AWS

┌──────────────────────────────────────────────────┐
│ CloudFront CDN │
│ shell.yourdomain.com │
│ Behaviors: │
│ /cart/* → Cart S3 bucket │
│ /catalog/* → Catalog S3 bucket │
│ /* → Shell S3 bucket │
└──────────────────────────────────────────────────┘
│ │ │
┌────▼────┐ ┌─────▼────┐ ┌──────▼──────┐
│ Shell │ │ Cart │ │ Catalog │
│ S3+CF │ │ S3+CF │ │ S3+CF │
└─────────┘ └──────────┘ └─────────────┘

Each remote is independently deployed to its own S3 bucket + CloudFront distribution. The shell references them via environment variables injected at build time.

# CI/CD for Cart remote (GitHub Actions)
- name: Deploy Cart Remote
run: |
npm run build
aws s3 sync ./build s3://${{ secrets.CART_BUCKET }} --delete
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CART_CF_ID }} \
--paths "/remoteEntry.js" "/static/*"

Invalidate remoteEntry.js on every deploy. This is the manifest file — if it's stale, the shell loads old code.

Step 8: Handling Remote Failures Gracefully

// shell/src/components/RemoteBoundary.tsx
import { Component, ReactNode } from 'react';
interface Props {
fallback: ReactNode;
remoteName: string;
children: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class RemoteBoundary extends Component<Props, State> {
state: State = { hasError: false };
  static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
  componentDidCatch(error: Error) {
// Log to your monitoring (Datadog, CloudWatch, etc.)
console.error(`Remote ${this.props.remoteName} failed:`, error);
}
  render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
// Usage
<RemoteBoundary remoteName="cart" fallback={<MinimalCartIcon />}>
<Suspense fallback={<CartSkeleton />}>
<CartWidget />
</Suspense>
</RemoteBoundary>

Common Pitfalls (and How to Avoid Them)

Common Pitfalls (and How to Avoid Them)
Common Pitfalls (and How to Avoid Them)

PitfallSymptomFixMultiple React instancesHooks throw “invalid hook call”singleton: true on all shared libsStale remoteEntry.jsUsers see old remote codeInvalidate CF on every deployCSS collisionsRemote styles bleed into shellCSS Modules or Shadow DOM per remoteNo error boundaryOne remote down = white screenWrap every remote in RemoteBoundaryType driftTS errors on remote importsEnable DtsPlugin on all remotes

Performance: What to Expect

With proper setup, Module Federation adds minimal overhead:

  • Initial load: Shell loads instantly, remotes lazy-load on route entry
  • Shared deps: React loads once (~130KB gzipped), not per-remote
  • Cache efficiency: Each remote has its own cache fingerprint — Cart deploys don’t invalidate Catalog cache

In production benchmarks, a 3-remote setup adds ~50–80ms to first meaningful paint compared to a monolith — well within acceptable range for the deployment independence you gain.

When NOT to Use Micro Frontends

Micro frontends add real complexity. Don’t use them if:

  • You have one team building the frontend
  • Your app is under 50k lines of code
  • You don’t have independent deployment requirements
  • Your team doesn’t have strong DevOps maturity

The best architecture is the one your team can actually maintain.

Wrapping Up

Module Federation 2.0 solves the real problems that made first-gen micro frontends painful. With proper singleton config, TypeScript type sharing, error boundaries, and per-remote CloudFront deployments, you get genuine team independence without sacrificing user experience.

The key principles that make it work in production:

  1. Shell owns shared state — remotes are guests
  2. Every remote gets an error boundary — resilience by default
  3. singleton: true for all shared libraries — one React to rule them all
  4. Invalidate remoteEntry.js on every deploy — stale manifests kill users

If you found this useful, follow for more production-grade frontend and AWS content published every other day.

Have you shipped micro frontends in production? What was your biggest pain point? Drop it in the comments — I read every one.

I’m a Full-Stack Developer specializing in React, Next.js, and Node.js.
👉 Connect with me: https://www.linkedin.com/in/chiragmehta900/

Author card for Chirag Mehta, Full-Stack Developer specializing in React, Next.js and Node.js. Links: github.com/chiragmehta900, linkedin.com/in/chiragmehta900, medium.com/@chiragmehta900.
Chirag Mehta

Originally published on Medium

Clap, comment or follow along there

Read on Medium →