Micro-Frontends in React: Module Federation, Single-SPA
Split your React monolith into independently deployable micro-frontends using Module Federation and Single-SPA — here's the real tradeoff breakdown.
What Micro-Frontends Actually Are (and Aren't)
The idea is straightforward: break your frontend the same way microservices break your backend. Each team owns a vertical slice — routing, UI, deploy pipeline, everything. No waiting on a shared release train. No stepping on each other's webpack configs. Sounds great on paper.
In practice, it's genuinely useful at scale, and genuinely painful at small scale. If you have two teams sharing one repo, micro-frontends will make your life harder, not easier. The pattern pays off when you've got four or more independent product teams, each moving at a different cadence — think a checkout team, a product catalog team, a marketing team — all needing to ship on Friday without coordinating a mega-deploy.
Honestly, most "micro-frontend" articles skip the hard part: you're trading deploy independence for runtime complexity. Things that used to be a single bundle import are now cross-app contracts. You'll spend real engineering hours on that. Budget for it.
There are two dominant approaches in the React ecosystem right now. Module Federation, introduced with webpack 5 in 2020, handles the bundler layer and lets apps share and consume remote modules at runtime. Single-SPA takes a higher-level orchestration approach, managing app lifecycle and routing. You can even combine them — and plenty of production setups do.
Module Federation: The Webpack 5 Way
Module Federation lets one webpack build expose modules, and another consume them — at runtime, not build time. That distinction matters a lot. The host app doesn't bundle the remote code. It fetches it from a URL when it's needed. Each app ships its own remoteEntry.js, and you wire them together in webpack.config.js.
Here's the bare minimum for a remote app (say, your ProductCard component):
``js
// apps/product/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'product',
filename: 'remoteEntry.js',
exposes: {
'./ProductCard': './src/ProductCard',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
],
};
`
And in your host (shell) app:
`js
// apps/shell/webpack.config.js
new ModuleFederationPlugin({
name: 'shell',
remotes: {
product: 'product@http://localhost:3001/remoteEntry.js',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})
`
Then inside your shell React code:
`jsx
const ProductCard = React.lazy(() => import('product/ProductCard'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ProductCard id="abc123" />
</Suspense>
);
}
``
The singleton: true flag on React is non-negotiable. Without it, each remote pulls in its own React copy, and you'll hit the "Hooks can only be called inside a function component" error that makes you question your life choices. Make sure every app — host and remotes — pins the same React version (18.3.x as of mid-2026 is the stable target).
Worth noting: the shared config supports requiredVersion, which is your friend in mixed-version environments. Set it to '>=18.0.0' if you need that kind of flexibility, but the tighter you pin, the fewer runtime surprises you get.
One more thing — in production, replace localhost:3001 with your CDN URL. Each micro-frontend deploys independently and serves its remoteEntry.js from its own origin. Your CI just needs to know where the latest one lives. Some teams store these URLs in environment variables; others use a manifest registry approach where the shell fetches the current URL at runtime. The registry approach is safer for zero-downtime deploys.
Single-SPA: Routing and Lifecycle Orchestration
Single-SPA is a different beast. Instead of operating at the bundler level, it manages the mounting and unmounting of entire apps (it calls them "parcels" and "applications"). You register each micro-frontend with a route pattern, and Single-SPA handles rendering the right one when the URL matches.
Setup looks like this in the root config:
``js
import { registerApplication, start } from 'single-spa';
registerApplication({
name: '@company/product',
app: () => import('@company/product'),
activeWhen: ['/products'],
});
registerApplication({
name: '@company/checkout',
app: () => import('@company/checkout'),
activeWhen: ['/checkout'],
});
start();
`
Each app exports the Single-SPA lifecycle hooks:
`js
// apps/product/src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import singleSpaReact from 'single-spa-react';
import App from './App';
const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: App,
errorBoundary(err, info, props) {
return <div>Something broke in product app.</div>;
},
});
export const { bootstrap, mount, unmount } = lifecycles;
``
The big selling point of Single-SPA is that different apps can run different frameworks simultaneously — React 18, Vue 3, some legacy Angular app from 2019 that nobody wants to touch. If you're migrating a legacy codebase, this is genuinely valuable. You can strangle the old app incrementally rather than doing a risky big-bang rewrite.
In practice, the activeWhen routing can get complex. Single-SPA gives you a function form too: activeWhen: (location) => location.pathname.startsWith('/products'). That's cleaner for nested routes. Also, be aware that Single-SPA doesn't handle CSS isolation out of the box — if your apps share global styles, you'll need CSS modules, Shadow DOM, or some other scoping strategy.
Quick aside: teams running Single-SPA in 2026 almost always pair it with import maps for production. Instead of bundling remote URLs at build time, you serve an import map JSON that maps bare specifiers to CDN URLs. Update the import map, and all consumers pick up the new version on next page load — no redeployment of the shell required.
Shared State Across Micro-Frontends
This is where things get genuinely hard. Your product app needs to know if the user is logged in. Your checkout app needs the cart. Your analytics app needs the user ID. How do you share that without making every app depend on a central store?
The clean answer is: you don't share React state. React state is local to a React tree. When two apps are separate webpack builds, they have separate React trees. Full stop. What you actually share is one of: a URL parameter, a custom event, a shared module (via Module Federation's shared config), or a browser primitive like localStorage/sessionStorage.
Custom events are underrated here. They're native, they work across any framework, and they have zero coupling:
``js
// Emitting from the product app
window.dispatchEvent(new CustomEvent('cart:updated', { detail: { itemCount: 3 } }));
// Listening in the shell or checkout app
window.addEventListener('cart:updated', (e) => {
setCartCount(e.detail.itemCount);
});
`
For more complex state, you can expose a shared store module via Module Federation's shared` config — a Zustand store, for instance, marked as a singleton. Every app that imports it gets the same instance. Works well. Just don't try to share Redux across module federation boundaries unless you enjoy debugging serialization issues.
Look, the real constraint is your team contract, not your tooling. Decide upfront what the shell owns (auth tokens, user preferences, global nav state) and what each micro-frontend owns (its own feature state). Write that down. Enforce it in code review. That discipline is worth more than any clever shared-state library.
Performance: The Numbers You Need to Know
Micro-frontends have a real performance cost if you're not careful. Each app bundle crosses the network separately, and if you're naive about it, you end up shipping React 18 four times — once per app. That's why singleton: true in your shared config isn't optional, it's the price of admission.
Even with deduplication, you're still paying an overhead. A well-tuned monolith SPA might ship 180kb of compressed JS. A naive micro-frontend setup with four apps can hit 400kb+ because of per-app overhead: runtime chunks, the Module Federation container code, per-app Suspense boundaries. Worth measuring before you go to production.
Import maps + HTTP/2 push help a lot. With HTTP/2, multiple small chunks load in parallel rather than sequentially, so the overhead shrinks. Setting aggressive cache headers on remote entries helps too — the remoteEntry.js can be content-hashed and cached for a year if you're deploying new versions at a new URL each time.
For component-level performance within each micro-frontend, you're back to normal React optimization: React.memo, useMemo, useCallback, lazy loading, the usual. If you want a visual reference for how performant UI components can look without sacrificing aesthetics, browse components on Empire UI — everything there is built with render performance as a constraint, not an afterthought.
One more thing — Lighthouse scores can mislead you with micro-frontends. The initial paint is fast because the shell is tiny. But TTI (Time to Interactive) can be brutal if you're loading three remote entries on first render. Measure TTI, not just FCP. Set a TTI budget before your team decides they've shipped something fast.
Testing and CI for Micro-Frontend Teams
Testing across app boundaries is the part of micro-frontends that most articles skip, probably because it's unglamorous. Unit tests are fine — each app tests itself. Integration is where it gets weird. How do you test that the ProductCard your team exports actually renders correctly inside the shell someone else maintains?
Contract testing is the answer. Libraries like Pact let you define what the host expects from a remote (the "consumer contract") and verify that the remote actually satisfies it ("provider verification"). It's more setup than you want on week one, but by month three, when a remote team quietly changes a prop name, you'll be glad you have it.
For CI pipelines, the pattern that works is: each micro-frontend has its own pipeline that builds, tests, and deploys independently. The shell has an integration test suite that runs against the latest deployed versions of all remotes. That integration suite is the safety net. It shouldn't run on every push to every app — just when a remote ships a new version or when the shell changes.
Storybook is worth mentioning here. Each micro-frontend team should maintain their own Storybook for their exposed components. It's the documented contract for what they're exporting. If you're building shared design components that might span micro-frontends, Empire UI's component patterns are a solid reference for how to structure component APIs that stay stable across consumers.
Worth noting: snapshot tests across module federation boundaries are a trap. The snapshot is captured with a specific version of the remote. Next deploy, the remote changes, and now your snapshot is stale. Use visual regression testing (Chromatic, Percy) or behavioral tests (Playwright, Cypress) instead.
Module Federation vs Single-SPA: Which One Should You Pick?
Here's the direct answer: if your stack is all React (or all one framework) and your primary need is independent deploys with code sharing, pick Module Federation. If you're managing multiple frameworks — migrating from Angular, experimenting with Vue, or running a company-wide platform that teams can plug different stacks into — pick Single-SPA.
You can also combine them. Use Single-SPA for top-level routing and lifecycle management, and Module Federation for sharing granular components and utilities across the apps Single-SPA orchestrates. This is what large enterprise platforms do. It's more complexity, but the two tools aren't competing — they're solving different layers of the same problem.
That said, don't reach for either of these until you've genuinely outgrown a monorepo with shared packages. Tools like Turborepo and Nx give you a lot of the "independent teams moving fast" benefits without the runtime complexity. The gap between "fast monorepo" and "micro-frontends" has narrowed significantly since 2022. For most teams under 20 frontend engineers, the monorepo wins.
If you're building the shell app and want your navigation and top-level layout to feel polished while teams figure out their micro-frontend slices, Empire UI has production-ready components that drop straight into a React host — including navigation patterns and glassmorphism components that work independently of whatever styling framework the remote apps are using. The shell is often the most user-visible part; it's worth making it look good.
FAQ
Not across app boundaries — React context is scoped to a single React tree. Share state via custom events, URL, or a singleton module exposed through Module Federation's shared config instead.
Yes. The @originjs/vite-plugin-federation plugin brings Module Federation semantics to Vite. It's not 1:1 with webpack's implementation, so test your shared config carefully, especially singleton handling.
The shell app owns auth — it stores the token and exposes user info via a shared module or custom events. Remote apps should never manage their own auth flow; they read from whatever the shell provides.
Realistically, four or more frontend teams working on separate product areas with conflicting release schedules. Below that, a well-structured monorepo almost always wins on simplicity and velocity.