Micro Frontends: The Ultimate Guide — React, Vite, Next.js, React Native & Module Federation
"Microservices for the frontend." — said by someone who had not yet tried to share authentication state across three teams' apps in production.
Micro frontends are one of the most hyped, most misunderstood, and most misused architectures in modern frontend development. They're also genuinely the right answer for some teams — and the entirely wrong answer for most others.
This guide is the one I wish I had when I first hit the wall on a 200-person frontend monorepo. We'll go from "what is this even" to building real micro frontends in React + Vite, Next.js, and React Native — with Module Federation, multi-zones, super apps, and every trade-off explained honestly.
No hype, no bias. Just the architecture, the code, and the scars.
Table of Contents
- What Are Micro Frontends?
- Why Micro Frontends Exist — The Real Problem They Solve
- The Five Core Principles
- When You Should (and Shouldn't) Use Them
- The Composition Strategies
- Module Federation — Deep Dive
- React + Vite + Module Federation — Full Example
- Next.js Micro Frontends — Multi-Zones & Federation
- Mobile Micro Frontends — React Native, Expo & Super Apps
- Routing Across Micro Frontends
- Shared State & Communication
- Shared Dependencies — The #1 Pain Point
- Styling Isolation
- Deployment Strategies
- Testing Micro Frontends
- Real-World Architectures
- The Honest Pitfalls List
- The Tools Landscape
- Decision Framework
- The Future of Micro Frontends
What Are Micro Frontends?
Micro frontends are an architectural style where a frontend application is decomposed into independently buildable, deployable, and ownable pieces — each typically owned by a different team — that are composed together at build time, server side, or run time into a single user-facing experience.
If that sounds like microservices, you're not wrong. The pattern is the spiritual heir to the same idea: take one giant codebase, split it along team lines, and let each team ship on their own schedule.
+---------------------------------------------------------------+
| MONOLITH FRONTEND |
| +---------+ +---------+ +---------+ +---------+ |
| | Header | | Search | | Product | | Checkout| |
| +---------+ +---------+ +---------+ +---------+ |
| ALL OWNED BY ONE TEAM, ONE REPO, ONE DEPLOY |
+---------------------------------------------------------------+
vs
+---------------------------------------------------------------+
| MICRO FRONTEND SHELL |
| +-----------+ +-----------+ +-----------+ +-----------+ |
| | Header | | Search | | Product | | Checkout | |
| | (Team A) | | (Team B) | | (Team C) | | (Team D) | |
| | repo A | | repo B | | repo C | | repo D | |
| | deploy A | | deploy B | | deploy C | | deploy D | |
| +-----------+ +-----------+ +-----------+ +-----------+ |
| ASSEMBLED AT BUILD/SERVER/RUN TIME INTO ONE UI |
+---------------------------------------------------------------+
The key word in that definition is independently. If "team A has to coordinate a release with team B", you don't have micro frontends — you have a distributed monolith with extra steps.
Why Micro Frontends Exist — The Real Problem They Solve
Let's be brutally honest. Micro frontends do not exist because of a technical problem. They exist because of an organizational one.
The Symptom
You have a frontend codebase. It started small. Now there are 60 engineers committing to it. Pull requests sit in review for 3 days. CI takes 45 minutes. The release train runs once a week and a single broken test blocks 14 features. Everyone is afraid to refactor anything. Standups are 80% coordination overhead.
Sound familiar? That's the moment people start Googling "micro frontends".
The Real Cause
Conway's Law: "Any organization that designs a system will produce a design whose structure is a copy of the organization's communication structure."
If your org chart has 8 product squads but your codebase has 1 frontend app, those 8 squads are going to step on each other forever. The build is shared. The deploy is shared. The runtime is shared. There's no isolation, so there's no autonomy.
Micro frontends are an attempt to make the architecture match the org chart — to give each squad its own frontend the way they already have their own backend service.
What MFE Actually Buys You
- Independent deploys — Team A ships at 2pm, Team B ships at 2:01pm, neither blocks the other.
- Independent tech choices — Team A can be on React 19, Team B can still be on React 17 during a migration. (This is also a curse, see later.)
- Independent CI/CD — No more 45-minute monorepo builds.
- Smaller blast radius — A bug in Team C's checkout doesn't crash Team A's homepage.
- Easier onboarding — New hires only need to learn one squad's slice.
What MFE Does Not Buy You
- It does not make your app faster. (Usually the opposite.)
- It does not make your code cleaner. (Usually the opposite, at first.)
- It does not solve bad organizational communication. (It exposes it.)
- It is not free. The complexity tax is real and permanent.
The Five Core Principles
Cam Jackson's martinfowler.com article laid out what most people now consider the canonical principles. Here they are, with the things people miss:
1. Be Technology Agnostic
Each team should be able to choose its own stack. In practice, don't. Pick one framework as the default and only deviate when there's a compelling reason. Mixing React, Vue, and Svelte in one app is a recipe for a 5MB shared bundle and a debugging nightmare.
2. Isolate Team Code
No shared runtime state by default. No global CSS leaking between teams. Build for the assumption that other teams' code is a black box.
3. Establish Team Prefixes
Use naming conventions for CSS, events, local storage, cookies, etc. — team-checkout-cart-open, team-search-results — so collisions are impossible.
4. Favor Native Browser Features
Custom events, the URL, postMessage, broadcast channels — these all exist for a reason and they cost zero coordination overhead. Reach for these before a custom pub/sub bus.
5. Build a Resilient Site
A single broken micro frontend should never take down the whole page. Use error boundaries. Use timeouts. Use fallbacks. Treat every remote like an unreliable network call, because that's exactly what it is.
When You Should (and Shouldn't) Use Them
This is the section nobody writes honestly. Here's the honest version.
You Probably Should NOT Use Micro Frontends If…
- You have fewer than 3 product teams sharing the frontend. You'll spend more on architecture than you save in autonomy.
- Your app is performance-critical for first-load (landing pages, marketing sites, e-commerce funnels). The extra HTTP requests and larger combined bundles will hurt your Core Web Vitals.
- You have heavy cross-team UX flows — checkout flows, wizards, dashboards where every section talks to every other section. Forcing these across MFE boundaries is masochism.
- You're building a mobile-first product. Mobile MFE is real but it's hard mode (more on that later).
- You think MFE will fix bad code. It will not. It will give bad code more places to hide.
You Probably SHOULD Use Micro Frontends If…
- You have 5+ product teams all blocked on a shared frontend.
- Your product is naturally page- or section-based with weak coupling between sections (think: an enterprise dashboard with 12 mostly independent modules, or a marketplace with seller tools, buyer tools, admin tools).
- You're doing a legacy migration — you can use MFE to incrementally replace pieces of an old app without a big-bang rewrite. This is genuinely the killer use case.
- You're building a platform / super app where third parties (or other internal teams) ship modules into your shell.
The Honest Heuristic
If you're asking "should we do micro frontends?", the answer is almost always no.
The teams that should do them are the teams whose pain has already gotten so bad that they didn't have to ask.
The Composition Strategies
Once you've decided to do MFE, the next question is: how do the pieces actually get glued together? There are five real strategies. Each one trades off something different.
+---------------------------------------------------------------+
| MICRO FRONTEND COMPOSITION STRATEGIES |
+---------------------------------------------------------------+
| |
| 1. BUILD-TIME (npm packages) |
| Each team publishes a package, host installs them. |
| [+] Simple, type-safe |
| [-] Requires host redeploy on every change. NOT REAL MFE.|
| |
| 2. SERVER-SIDE COMPOSITION (SSI / ESI / Tailor / Podium) |
| A server stitches HTML fragments from multiple services. |
| [+] Great for SEO, fast first paint |
| [-] Server complexity, harder client-side interactivity |
| |
| 3. IFRAMES |
| The OG. Each MFE in its own iframe. |
| [+] Bulletproof isolation |
| [-] Routing, sizing, deep linking, SSO are all painful |
| |
| 4. WEB COMPONENTS |
| Each MFE ships as a custom element. The shell uses tags. |
| [+] Standards-based, framework-agnostic |
| [-] Shadow DOM friction with React, styling pain |
| |
| 5. RUNTIME JS (Module Federation / single-spa / SystemJS) |
| The shell loads remote JS bundles at runtime. |
| [+] True independence + shared dependencies |
| [-] Most complex, biggest perf footgun |
| |
+---------------------------------------------------------------+
Let's go quickly through each, then go deep on the one you'll actually use: Module Federation.
1. Build-Time Integration (NPM Packages)
Each team publishes their feature as an npm package. The host app npm installs them and ships them as part of its bundle.
// host package.json
{
"dependencies": {
"@acme/header": "^1.2.0",
"@acme/checkout": "^3.4.0",
"@acme/product": "^2.1.0"
}
}
The catch: to ship a checkout fix, the checkout team has to publish a new version, the host team has to bump it, and the host has to redeploy. That's not independent — that's a coordinated release with extra steps. This is not real micro frontends, but it can be a fine starting point.
2. Server-Side Composition
A server fetches HTML fragments from multiple backend services and stitches them together before sending the response to the browser. Tools like Tailor (Zalando), Podium (Finn.no), Edge Side Includes (Akamai/Varnish), and Next.js's own streaming SSR all live in this family.
<!-- The composed page -->
<html>
<body>
<!--# include virtual="/header" -->
<!--# include virtual="/product" -->
<!--# include virtual="/footer" -->
</body>
</html>
Strengths: great SEO, fast first paint, works with any backend stack.
Weaknesses: complex infra, client-side interactivity has to be re-hydrated per fragment, debugging is harder.
3. Iframes
Yes, iframes. Don't laugh — iframes are the only composition strategy with truly bulletproof isolation. No CSS leaking, no JS conflicts, no shared globals. Spotify's desktop app famously used iframes for years.
The pain is everything around the iframe: routing, deep links, sizing, scroll restoration, single sign-on, sharing user state, focus management, accessibility. You can solve every one of these but each one costs you a week.
Use iframes when isolation is non-negotiable — for example, when you're embedding third-party modules you don't trust.
4. Web Components
Each MFE compiles to a custom element. The shell uses it like any other HTML tag.
<header-mfe></header-mfe>
<product-mfe sku="xyz"></product-mfe>
<checkout-mfe></checkout-mfe>
This is the most "framework agnostic" approach. It works. It's standards-based. But shadow DOM has friction with most frameworks (especially anything that wants to portal modals), styling becomes a saga, and you give up the ergonomics of your favorite framework's component model at the boundary.
5. Runtime JS Composition (The One You'll Probably Use)
The shell loads remote JavaScript bundles at run time. The two big approaches are:
- single-spa — an opinionated framework for orchestrating multiple SPAs in one shell.
- Webpack Module Federation (and its successors in Vite, Rspack, Next.js, Re.Pack) — a more granular approach where you can share individual modules, not just whole apps.
Module Federation has eaten the world. Let's go deep.
Module Federation — Deep Dive
Module Federation was introduced in Webpack 5 by Zack Jackson in 2020. The core idea is dead simple but the implications are huge:
Any build can dynamically load code from any other build at run time, with shared dependencies properly deduplicated.
The Concepts
+-----------------+ +-----------------+
| HOST | | REMOTE |
| (the shell) |<-------| (a feature) |
| | | |
| Loads remotes | | Exposes modules |
| Defines shared | | Declares shared |
+-----------------+ +-----------------+
^ ^
| |
+-----------+ +----------+
| |
v v
+---------------------+
| SHARED |
| (react, react-dom, |
| react-router...) |
+---------------------+
Loaded once, used by both
There are three pieces of vocabulary you need:
- Host: an app that consumes modules from other apps.
- Remote: an app that exposes modules for other apps to consume.
- Shared: dependencies (like React) that should be loaded once and shared across host and remotes — not bundled twice.
A single app can be both a host and a remote at the same time. That's where the power comes from — it's a peer-to-peer network of bundles, not a strict hub-and-spoke.
What Gets Generated
When you build a remote, Module Federation produces a remoteEntry.js file. That file is the manifest of what the remote exposes — it's tiny, and the actual code chunks are loaded lazily on demand.
remote-app/dist/
├── remoteEntry.js <- The manifest. Loaded by the host first.
├── src_Button_tsx.js <- The actual exposed module.
├── src_Header_tsx.js
└── vendor.js
The host points at https://remote.example.com/remoteEntry.js, fetches the manifest, then asks for individual exposed modules as the user navigates.
The Critical Concept: shared
This is where 90% of all Module Federation bugs live.
When you mark react as shared, Module Federation runs a tiny negotiation at run time:
- Host has react@18.2.0.
- Remote also has react@18.2.0.
- Both agree to use the host's copy. One React, problem solved.
But:
- Host has react@18.2.0.
- Remote has react@17.0.2.
- Without a constraint, you now have two Reacts on the same page, which means hooks break, contexts don't cross boundaries, and your dev tools lie to you.
The fix is singleton: true and requiredVersion:
shared: {
react: {
singleton: true, // There can be only one
requiredVersion: '^18.0.0',
strictVersion: true, // Throw, don't silently dual-load
},
'react-dom': { singleton: true, requiredVersion: '^18.0.0', strictVersion: true },
}
Memorize this. Anything stateful (React, Redux, Zustand, react-router, react-query) MUST be a singleton across federated boundaries, or you will lose hours to ghost bugs.
Eager vs Lazy
By default, federated modules load lazily — that's the whole point. But the host's index.tsx runs before the federation runtime is ready, so you usually need a "bootstrap" pattern:
// index.tsx
import('./bootstrap');
// bootstrap.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(<App />);
That dynamic import() gives Module Federation a chance to initialize the shared scope before any of your code touches React. Skip this and you'll get cryptic "Shared module is not available for eager consumption" errors.
React + Vite + Module Federation — Full Example
Time for code. Let's build the smallest possible end-to-end MFE: a Vite + React shell that loads a remote Vite + React Button at run time.
We'll use @module-federation/vite (the official plugin from the Module Federation team — there's also @originjs/vite-plugin-federation which is the older community option, but the official one is now the recommended path).
Project Structure
mfe-demo/
├── shell/ (host)
│ ├── src/
│ │ ├── main.tsx
│ │ ├── bootstrap.tsx
│ │ └── App.tsx
│ ├── vite.config.ts
│ └── package.json
└── remote-button/ (remote)
├── src/
│ ├── main.tsx
│ ├── bootstrap.tsx
│ └── Button.tsx
├── vite.config.ts
└── package.json
The Remote — remote-button/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';
export default defineConfig({
plugins: [
react(),
federation({
name: 'remoteButton',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button.tsx',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
build: {
target: 'esnext',
modulePreload: false,
cssCodeSplit: false,
},
server: { port: 5174 },
preview: { port: 5174 },
});
The Remote — remote-button/src/Button.tsx
import { useState } from 'react';
export default function Button({ label }: { label: string }) {
const [count, setCount] = useState(0);
return (
<button
onClick={() => setCount((c) => c + 1)}
style={{
padding: '10px 20px',
background: '#6366f1',
color: 'white',
border: 'none',
borderRadius: 8,
}}
>
{label} — clicked {count} times
</button>
);
}
The Host — shell/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';
export default defineConfig({
plugins: [
react(),
federation({
name: 'shell',
remotes: {
remoteButton: 'http://localhost:5174/assets/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
build: { target: 'esnext' },
server: { port: 5173 },
});
The Host — shell/src/main.tsx
// Bootstrap pattern — required for Module Federation
import('./bootstrap');
The Host — shell/src/bootstrap.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(<App />);
The Host — shell/src/App.tsx
import { lazy, Suspense } from 'react';
// TypeScript needs to be told this module exists
// (we'll fix that with a .d.ts in a sec)
const RemoteButton = lazy(() => import('remoteButton/Button'));
export default function App() {
return (
<div style={{ padding: 40 }}>
<h1>Shell App</h1>
<p>The button below is loaded from a different deployment at runtime:</p>
<Suspense fallback={<div>Loading button…</div>}>
<RemoteButton label="Click me" />
</Suspense>
</div>
);
}
TypeScript: Telling It About the Remote
Federated imports are dynamic strings — TypeScript has no idea they exist. Add a type declaration:
// shell/src/types/remotes.d.ts
declare module 'remoteButton/Button' {
const Button: React.ComponentType<{ label: string }>;
export default Button;
}
Running It
# Terminal 1
cd remote-button && npm run build && npm run preview
# Terminal 2
cd shell && npm run dev
Open http://localhost:5173 and you'll see the shell app with the button rendered from a completely separate build on :5174. Update the button's code, rebuild only the remote, refresh the shell — and the new button is live without rebuilding the host.
That's the magic moment. Independent deploys, true and live.
Common Vite + MF Gotchas
-
Dev mode: Module Federation in Vite works best when remotes are built and previewed (
vite build && vite preview), not just run viavite dev. Dev-mode HMR for remotes is improving but still flaky. -
CORS: the host has to be able to fetch
remoteEntry.js. Remotes need permissive CORS in production. -
Asset paths: make sure the remote's
baseis set correctly when deployed to a subpath/CDN, otherwise its lazy chunks 404. - CSS: federated CSS is the wild west. The safest path is to scope all styles via CSS Modules or styled-components and let each remote ship its own.
Next.js Micro Frontends — Multi-Zones & Federation
Next.js MFE is a different beast because Next isn't just a bundler — it's a full SSR framework with its own router, its own data fetching, and (in App Router) its own server component model. There are two real approaches.
Approach 1: Multi-Zones (the simple way)
Multi-zones is built into Next.js itself. The idea: you run multiple separate Next.js apps, each handling a different URL prefix, and a top-level reverse proxy (or rewrites config) stitches them together so the user sees one domain.
// next.config.js (the "main" zone)
module.exports = {
async rewrites() {
return [
{ source: '/blog', destination: 'https://blog-zone.vercel.app/blog' },
{ source: '/blog/:path*', destination: 'https://blog-zone.vercel.app/blog/:path*' },
{ source: '/store', destination: 'https://store-zone.vercel.app/store' },
{ source: '/store/:path*', destination: 'https://store-zone.vercel.app/store/:path*' },
];
},
};
Each zone is a normal Next.js app — its own repo, its own deploy, its own data layer. The user's browser hits example.com/blog and the main zone's edge transparently proxies to the blog zone.
When this is enough:
- Your app is naturally split by URL section.
- You don't need to share runtime React state between zones.
- You're okay with a full page navigation when crossing zones (Next 13+ does soft-nav within a zone, hard-nav across zones).
For 80% of teams asking about Next.js MFE, this is what they actually want. Multi-zones is the boring, working answer. Use it first.
Approach 2: Module Federation in Next.js
When multi-zones isn't enough — you need to share components across teams, not just whole pages — you reach for @module-federation/nextjs-mf (Zack Jackson's plugin).
// next.config.js (host)
const NextFederationPlugin = require('@module-federation/nextjs-mf');
module.exports = {
webpack(config, options) {
config.plugins.push(
new NextFederationPlugin({
name: 'shell',
remotes: {
checkout: `checkout@${process.env.CHECKOUT_URL}/_next/static/${
options.isServer ? 'ssr' : 'chunks'
}/remoteEntry.js`,
},
shared: {
react: { singleton: true, requiredVersion: false },
},
extraOptions: {
exposePages: false,
enableImageLoaderFix: true,
enableUrlLoaderFix: true,
},
})
);
return config;
},
};
// next.config.js (remote)
const NextFederationPlugin = require('@module-federation/nextjs-mf');
module.exports = {
webpack(config, options) {
config.plugins.push(
new NextFederationPlugin({
name: 'checkout',
filename: 'static/chunks/remoteEntry.js',
exposes: {
'./CartWidget': './components/CartWidget.tsx',
'./CheckoutForm': './components/CheckoutForm.tsx',
},
shared: {},
})
);
return config;
},
};
The Catch with Next.js + MF
Next.js does SSR, which means federated components have to be loadable on the server and the client, with the same code path producing the same HTML on both. That's significantly harder than browser-only MFE because:
- The server has no
window, so the remote'sremoteEntry.jshas to be loaded via Node-side fetch. - Hydration mismatches across federated boundaries are the most painful debugging experience in web dev.
- Streaming SSR + federated components is still rough.
-
App Router (Next 13+) has limited federation support today. As of writing,
@module-federation/nextjs-mfhas the most reliable support in the Pages Router. App Router support is shipping but more fragile — check the docs before you bet on it.
Pragmatic recommendation for 2026: if you need MFE in Next.js, default to multi-zones. If you absolutely need federated components, run them as client components only ('use client' at the boundary), and accept that you're trading some SSR benefits for the federation.
Mobile Micro Frontends — React Native, Expo & Super Apps
Mobile MFE is a different planet. On the web, you can fetch a JS bundle from a CDN at run time and execute it. On mobile, the OS would very much prefer you not do that — Apple's App Store rules around dynamic code loading are particularly strict.
But it can be done, and there are real reasons to do it: super apps. WeChat, Gojek, Careem, Grab, Alipay, and Rappi all run dozens of independent feature teams shipping into one shell app. Each team has its own release cycle, its own QA, its own analytics — but it all looks like one app to the user.
React Native + Re.Pack + Module Federation
The current best-in-class approach is Re.Pack by Callstack — a Webpack-based bundler for React Native (an alternative to Metro) that supports Module Federation natively.
// rspack.config.mjs (host)
import { ModuleFederationPluginV2 } from '@module-federation/enhanced/rspack';
export default {
plugins: [
new ModuleFederationPluginV2({
name: 'host',
remotes: {
miniApp: 'miniApp@http://localhost:9000/miniApp.container.bundle',
},
shared: {
react: { singleton: true, eager: true },
'react-native': { singleton: true, eager: true },
},
}),
],
};
// MiniApp.js (remote)
import { Text, View } from 'react-native';
export default function MiniApp() {
return (
<View>
<Text>I'm shipped independently from the host</Text>
</View>
);
}
The host fetches the remote bundle at run time, evaluates it inside the JS engine (Hermes/JSC), and renders it. From the user's perspective, it's one app. From the team's perspective, the mini app team can ship updates without going through the App Store review for the shell — only the shell update needs review, not the mini app update.
Expo and OTA Updates
Expo doesn't natively do Module Federation, but it provides something adjacent: EAS Update, an OTA update channel. You can ship JS-only updates over the air after App Store review, which gives you a coarse form of independent deployment.
eas update --branch production --message "Fix checkout bug"
This is not micro frontends — it's a single-team OTA channel. But for many "we just need faster ship cycles" cases, it's the right answer instead of true MFE on mobile.
Why Mobile MFE Is Hard Mode
- Apple's rules. App Store guideline 3.3.2 historically forbade executing remotely-downloaded code that "changes features or functionality". The interpretation has loosened (React Native and CodePush have been allowed for years), but you're still walking a line. Read the latest guidelines and don't ship something that looks like an app store of its own.
- No CDN-fast loads. Mobile networks are unreliable. You have to cache aggressively, support offline, and fall back gracefully.
- Native modules. If two mini apps need different versions of a native module, you're toast. Native dependencies have to be unified at the host level.
- Bundle size. Every shared dependency duplication is a megabyte of extra app size.
- Debugging. You have not lived until you've debugged a federated React Native app on a low-end Android device with no source maps.
When Mobile MFE Is Worth It
You are building a super app with 5+ independent product teams shipping into one binary, you have the engineering org to support a custom mobile build infra, and your business model genuinely depends on the modularity. Otherwise, just use Expo + EAS Update and call it a day.
Routing Across Micro Frontends
Routing is the area where MFE gets interesting fast. Three patterns dominate.
1. Single Shell Router
The shell owns the router. Each MFE is a leaf component the shell mounts at a route.
// shell
<Router>
<Route path="/products/*" element={<ProductMFE />} />
<Route path="/checkout/*" element={<CheckoutMFE />} />
<Route path="/account/*" element={<AccountMFE />} />
</Router>
Pros: simple, predictable, the URL is the source of truth.
Cons: the shell has to know about every MFE's top-level routes. Adding a new MFE requires a shell change.
2. Distributed Routing
Each MFE has its own router. The shell delegates to whichever MFE matches the prefix.
// shell — delegates anything under /checkout/* to the checkout MFE
<Route path="/checkout/*" element={<CheckoutMFE />} />
// inside CheckoutMFE
<Routes>
<Route path="/" element={<Cart />} />
<Route path="/payment" element={<Payment />} />
<Route path="/confirm" element={<Confirm />} />
</Routes>
Pros: new sub-routes don't require a shell change.
Cons: each router has to be configured to use the same router type and base path; deep linking and 404s get tricky.
3. single-spa Style "Activity Functions"
single-spa registers each app with an activity function — a predicate that says "this app should be active when…".
registerApplication({
name: '@team/checkout',
app: () => import('@team/checkout'),
activeWhen: (location) => location.pathname.startsWith('/checkout'),
});
The shell never knows about routes per se — it just runs whichever apps match the current location. This is the most decoupled approach but it has the steepest learning curve.
The Underrated Answer: Use the URL as the Integration Layer
Instead of trying to share router state across MFEs, push everything into the URL. Filters, modals, selected items — all in query params. Now any MFE can read its state from the URL without needing a shared store, and deep linking just works.
This single principle eliminates 60% of the "shared state" problems people have with MFE.
Shared State & Communication
The single most painful question in MFE: how do micro frontends talk to each other?
The wrong answer is "a shared Redux store". You just turned your MFEs into a distributed monolith again — every team needs to know the shape of the store, every state change risks breaking everyone, and you've coupled the deploys together.
The right answer is the loosest possible coupling that solves your actual problem. In order from best to worst:
1. The URL (best)
If the state can live in the URL, put it in the URL. Every MFE can read it independently with useSearchParams.
2. Custom Events
Native browser events. Zero dependencies. Loosely coupled.
// Cart MFE publishes:
window.dispatchEvent(new CustomEvent('cart:item-added', { detail: { sku } }));
// Header MFE listens:
useEffect(() => {
const handler = (e) => updateBadge(e.detail);
window.addEventListener('cart:item-added', handler);
return () => window.removeEventListener('cart:item-added', handler);
}, []);
Use a team prefix (cart:, auth:) to avoid collisions.
3. BroadcastChannel
Like custom events, but works across tabs too. Great for "logout in one tab logs out everywhere" patterns.
const channel = new BroadcastChannel('auth');
channel.postMessage({ type: 'logout' });
4. A Tiny Pub/Sub Bus
If custom events feel too anonymous, ship a tiny shared event bus as a federated module. Keep its API frozen — it's a contract.
5. Shared Store (last resort)
If you absolutely need a shared reactive store (Zustand, Redux), expose it as a singleton from the shell. Lock down its public API like you would a public REST API. Treat schema changes like breaking API changes.
The Anti-Pattern: Direct Imports From Other MFEs
The moment one MFE imports a file from another MFE's source, you've recoupled the deploys. Don't do it. Communicate via the boundary.
Shared Dependencies — The #1 Pain Point
I'm going to say this loudly because it's the #1 thing that breaks MFE projects:
Two copies of React on the same page will silently destroy your application.
Hooks throw "invalid hook call". Contexts don't propagate across the boundary. Portals end up in the wrong tree. The dev tools lie about which fiber is which. You will lose days.
The Rules
- Anything stateful must be a singleton. React, react-dom, react-router, react-query, redux, zustand, jotai, recoil, styled-components, emotion, framer-motion, react-i18next — all of these break if there are two copies.
-
Use
singleton: trueandstrictVersion: trueon every singleton in your federation config. Yes, that means cross-team upgrades have to be coordinated. That's the price. -
Pin the version range carefully.
^18.0.0says "any 18.x is fine".~18.2.0is stricter. The tighter you are, the safer — and the more coupled your teams become. Pick where on this spectrum your org wants to live. - Audit your shared graph. Periodically run a build report to see exactly what's being shared and what isn't. Drift is silent and lethal.
The Honest Trade-off
The dirty secret: "tech agnostic" micro frontends are mostly a lie. Once you mark React as singleton, every team has to be on a compatible React version. Once you mark react-router as singleton, every team has to be on a compatible router version. The freedom to "just use whatever framework you want" evaporates the moment you need shared state.
The realistic version is: all teams agree on a small core (React, react-dom, router, maybe a state lib) and can vary independently outside that core. That's good enough, and far less painful than the dream.
Styling Isolation
CSS leakage between MFEs is the second-most-common bug class after duplicated React.
Strategies, Ranked
- CSS Modules — locally scoped class names by default. Boring, works, recommended.
- CSS-in-JS with scoped class generation (styled-components, emotion, vanilla-extract) — works, but watch for SSR + federation hydration issues.
- Tailwind with team prefixes — viable, but requires every team to use the same Tailwind config or you'll have conflicting utility classes.
- Shadow DOM — bulletproof isolation but breaks portals, modals, design systems, and most React libraries you care about.
- Plain CSS with team-prefixed class names — works if your teams have discipline. They don't.
The Things You'll Learn the Hard Way
-
!importantalways wins, and someone will use it. - A reset stylesheet loaded twice is its own special hell.
- Fonts loaded in two MFEs with slightly different weights/subsets will FOUT for both of them.
- Design tokens (colors, spacing) should live in one shared source of truth, not in each MFE's CSS.
Deployment Strategies
The whole point of MFE is independent deploys. Here's how to actually do them.
Each MFE Is Its Own Deploy Unit
- Its own repo (or its own folder + CI pipeline in a monorepo).
- Its own build, its own CI, its own preview environments.
- Its own CDN bucket / hosting.
- Its own versioning.
The host doesn't redeploy when a remote ships. That's the whole game.
Versioning: Latest vs Pinned
Two philosophies:
-
"Latest" model: the host always points at
https://remote.example.com/latest/remoteEntry.js. New remote ships, host picks it up immediately. Fast iteration, scary blast radius. -
"Pinned" model: the host points at
https://remote.example.com/v1.4.2/remoteEntry.js. Host has to bump the version to roll forward. Safer, slower, partially defeats the point.
The middle ground: use the latest model with a manifest service that lets you roll back instantly by flipping the "current" pointer. CloudFront, S3, or a tiny "versions API" can all serve this.
Canary Releases
Roll out a new remote to 5% of users by serving a different remoteEntry.js URL based on a feature flag or user cohort. Most CDNs and edge platforms (Cloudflare Workers, Vercel Edge Functions) make this trivial.
Cache Busting
remoteEntry.js should be served with Cache-Control: no-cache, must-revalidate. The chunks it references should be content-hashed and served with Cache-Control: public, max-age=31536000, immutable. Two simple rules; getting them wrong is what causes "the new version isn't loading" tickets.
Testing Micro Frontends
Unit Tests
Each MFE tests its own components in isolation. Same as any normal app. The federation boundary doesn't affect this layer.
Contract Tests
This is the layer that matters. You need to verify that the contract between host and remote stays stable across deploys. Two flavors:
-
Type contracts — generate
.d.tsfiles for every exposed module and publish them as a package. The host imports the types and TypeScript catches breaking changes at build time. - Runtime contracts — write tests in the host that import the real remote and assert it has the expected exports and prop shapes. Run these in CI on every host and remote build.
@module-federation/dts-plugin is the current best tool for auto-generating type contracts.
Integration / E2E
Run the full composed app (shell + all remotes) in a staging environment and run Playwright/Cypress against it. This is where you catch the bugs that only happen across boundaries — hydration mismatches, shared state collisions, duplicate React.
The Rule
Anything that crosses a federation boundary must have a contract test.
Skip this and you ship breakage to other teams every Tuesday.
Real-World Architectures
Let's look at how serious teams actually ship MFE.
Spotify
Famously used iframes for years (the "Spotile" pattern) because the team-isolation guarantee was worth the pain. They've since moved much of the desktop client to a more integrated approach but the iframe-based platform proved that even hated technology can scale if it solves the right problem.
IKEA
Uses a server-side composition approach for product pages — different teams own the gallery, the description, the reviews, and the buy box, and each ships independently. The page is stitched together at the edge.
Zalando
Built and open-sourced Tailor and Mosaic9 — server-side composition tooling that lets dozens of squads ship their own templates into a unified product page. Their SaaS-scale catalog made this a hard requirement.
DAZN
Talked publicly about going from a single-team frontend to a 200+ engineer MFE setup. They use a shell + Module Federation pattern to give each squad full ownership of its slice while keeping the global navigation, auth, and player consistent.
Microsoft (Office on the Web)
Office Online is famously a federation of independently-shipped apps (Word, Excel, PowerPoint) sharing a common shell, identity layer, and design system.
The Common Thread
None of these companies started with micro frontends. They started with monoliths, hit the team-scaling wall, and then refactored to MFE because the org pain was worse than the architectural pain.
The Honest Pitfalls List
I've watched teams adopt MFE and regret it. Here are the failure modes, ranked by how often I see them.
1. Duplicated React (covered above, but it's #1 for a reason)
You will get hit by this within the first month. Read the singleton section twice.
2. The "Distributed Monolith"
Teams nominally have separate MFEs but every change requires coordinated deploys across 4 of them. You have all the complexity of MFE with none of the autonomy.
3. Bundle Size Explosion
Each MFE ships its own copy of utility libs (lodash, date-fns, axios, the design system). Total page weight doubles or triples. Lighthouse cries.
4. Performance Death by 1000 Chunks
The host loads remoteEntry.js, which loads chunks, which load more chunks, which load more chunks. By the time the page renders, you've made 30 network requests. Your TTI tanks.
5. Type Safety at the Boundary
Federated imports are dynamic strings. Without .d.ts generation and contract tests, refactors silently break consumers.
6. Debugging Across Boundaries
Source maps for federated chunks are improving but still painful. Stack traces span multiple builds with different commit hashes. Sentry can handle this; your weekend debugging session might not.
7. The "Shared Design System" Wars
Every team wants to evolve the design system at their own pace. The design system team becomes a bottleneck. You either ship multiple versions (more bundle bloat) or coordinate every change (more coupling).
8. Org Overhead
Each MFE needs CI, CD, on-call rotation, monitoring, deploy pipelines, preview environments. You just multiplied your platform team's workload by N.
9. Onboarding Confusion
"Where does this code live?" becomes a 10-minute conversation instead of a 10-second one.
10. The "Why Are We Doing This Again" Quarterly Review
You will have this meeting. Make sure the answer is still good.
The Tools Landscape
Your options as of 2026:
| Tool | Best For | Notes |
|---|---|---|
| Webpack Module Federation | The OG. Mature, battle-tested. | The reference implementation. Everything else copies it. |
Module Federation 2.0 (@module-federation/enhanced) |
The current state of the art. | Adds runtime API, manifest, type generation. Use this on new projects. |
@module-federation/vite |
Vite-based projects. | Official Vite plugin. The recommended Vite path. |
@originjs/vite-plugin-federation |
Vite-based projects (community). | Older, still works, less actively maintained. |
@module-federation/nextjs-mf |
Next.js with federation needs. | Pages Router solid, App Router still maturing. |
| Next.js Multi-Zones | URL-segmented Next.js apps. | Built-in. The boring right answer for most Next teams. |
| Re.Pack | React Native. | Webpack-based RN bundler with first-class MF support. |
| single-spa | Multi-framework or whole-app composition. | Older but still excellent for orchestrating whole SPAs. |
| Piral | Plugin-style portal apps. | .NET-friendly, popular in enterprise. |
| qiankun | China-heavy stacks; Vue + React mixing. | Built on single-spa, very popular in Asia. |
| Bit | Component-as-a-package approach. | Build-time MFE done well. |
| Native Federation (Angular) | Angular shops. | Standards-based (ESM + import maps), no Webpack required. |
| Rspack | Webpack-compatible, Rust-based. | Drop-in faster Webpack with first-class MF support. |
| Turbopack | Next.js future. | MF support is still landing — not yet a primary choice. |
The Pragmatic Defaults for 2026
-
React + Vite host:
@module-federation/vite+ Module Federation 2.0 - Next.js with section split: Next.js Multi-Zones
-
Next.js with shared components across teams:
@module-federation/nextjs-mf(Pages Router) — and watch the App Router space closely - React Native super app: Re.Pack + Module Federation 2.0
- Multi-framework legacy migration: single-spa
- Component-level reuse without runtime federation: Bit
Decision Framework
Five questions. Answer them honestly.
1. How many teams own the frontend?
- 1–2 teams → No MFE. Use a monorepo.
- 3–4 teams → Consider it, but a clean monorepo is still probably better.
- 5+ teams → MFE starts paying for itself.
2. How coupled are your features?
- Tightly coupled (every screen uses every other screen's state) → No MFE. It will hurt.
- Loosely coupled (clear sectional boundaries) → MFE is a fit.
3. How performance-critical is the first paint?
- Marketing site / e-commerce / SEO-driven → Be very careful. SSR + MFE is hard. Multi-zones may be enough.
- Internal tool / dashboard / authenticated app → Performance is less critical, MFE is more viable.
4. Do you have platform engineering capacity?
- No dedicated platform/infra team → Don't. MFE has a permanent operational tax.
- Yes → Proceed.
5. What's the actual problem you're solving?
- "Our deploys are slow" → Fix the build first, MFE second.
- "Our teams can't ship without blocking each other" → MFE is correct.
- "We want to use different frameworks per team" → Reconsider your life choices, then MFE.
The Decision Tree
Do you have 5+ frontend teams?
|
+-------------+-------------+
| |
NO YES
| |
Monorepo + clean Are features loosely
boundaries. Done. coupled by section?
|
+-------------+-------------+
| |
NO YES
| |
Refactor for Is SSR + SEO
isolation first. critical?
|
+-------------+-------------+
| |
YES NO
| |
Next.js Module Federation
Multi-Zones (Vite or Webpack)
The Future of Micro Frontends
A few things worth watching as 2026 unfolds:
1. Module Federation 2.0
The current cutting edge. Adds a runtime API (load remotes by URL at run time), manifest format, automatic type generation, and a much better dev experience. Use it on new projects.
2. React Server Components Across Boundaries
RSC is fundamentally hostile to runtime federation today — server components serialize to a wire format that the host has to know about. The future likely involves RSC-aware federation or a complete rethink of how server-rendered fragments compose. This space is moving fast.
3. Edge Composition
Cloudflare, Vercel, Netlify, and Fastly are all making it cheap to compose HTML at the edge. Server-side composition may quietly come back into fashion, with edge functions doing the stitching.
4. Native Federation (Standards-Based)
Angular's "Native Federation" uses ESM and import maps instead of Webpack. As browsers improve native ESM support and import map adoption grows, this could become the standards-based future of MFE — bundler-agnostic by design.
5. Type Safety at the Boundary
Auto-generated .d.ts for federated modules is now table stakes. Expect better tooling around versioned type contracts, breaking-change detection, and dependency graphs across federated boundaries.
TL;DR
- Micro frontends are an organizational tool, not a technical one. They exist because Conway's Law won.
- Most teams should not use them. If you have fewer than 5 product teams, a clean monorepo is almost always the right answer.
- Module Federation is the dominant runtime approach in 2026 — for React + Vite, for Next.js (alongside multi-zones), and for React Native (via Re.Pack).
- Singletons for stateful libraries are not optional. Two Reacts on one page will ruin your week.
- Independent deploys are the entire point. If you don't have them, you have a distributed monolith with extra steps.
- Mobile MFE is hard mode. Use it only if you're building a true super app.
-
Test the boundaries with contract tests. Type the boundaries with auto-generated
.d.ts. - Use the URL as the integration layer wherever you can. It eliminates 60% of the "shared state" problems.
- Performance gets worse before it gets better — plan for bundle deduplication and aggressive caching from day one.
- The right answer is usually the boring one. Multi-zones, monorepos, and a strong design system will solve most of the problems people reach for MFE to fix.
If you walked away from this guide thinking "maybe we don't need micro frontends after all", I've done my job.
If you walked away thinking "okay, we definitely do, and now I know how to start" — even better. You're going in with your eyes open.
Connect With Me
If this guide helped you, follow me on LinkedIn for more deep dives into frontend architecture, system design, and the trade-offs nobody talks about: linkedin.com/in/ishaanpandey
Got questions, war stories, or strong opinions about whether your team should adopt MFE? I want to hear them. Drop me a message.
Top comments (0)