Micro-Frontends with React: Module Federation in 2026
Module Federation changed how teams ship React apps. Here's what micro-frontends actually look like in 2026, from Webpack 5 config to runtime sharing strategies.
Why Micro-Frontends Are Gaining Ground in 2026
Honestly, micro-frontends aren't a silver bullet — and anyone who sold them to you as one probably also sold you a blockchain solution in 2019. But in 2026, with teams scaling past 50 engineers and deployment bottlenecks getting brutal, splitting a frontend monolith finally makes sense for a lot of organizations.
The core idea is simple: you break your frontend into independently deployable units, each owned by a separate team, each shippable on its own schedule. What changed everything was Webpack 5's Module Federation plugin, which arrived in 2020 but has matured enormously since. Now, with Rspack 1.x matching Webpack's federation API at 5-10x the build speed, the tooling has genuinely caught up with the ambition.
React sits in a weird spot here. It's simultaneously the best and worst choice for micro-frontends. Best because the ecosystem is massive and everyone already knows it. Worst because React itself isn't designed to be shared across module boundaries without careful version pinning. We'll get into that.
Module Federation Basics: Hosts, Remotes, and Shared Singletons
Module Federation works on a host/remote model. The host application loads remote modules at runtime — not at build time. This means your checkout team can deploy their CheckoutWidget independently, and the shell app picks it up on the next page load without a rebuild.
The most important concept to understand is the shared configuration. If you don't pin your shared dependencies correctly, you'll end up with two copies of React running simultaneously and a cryptic "Hooks can only be called inside a function component" error that'll cost you an afternoon.
Here's a minimal webpack.config.js for a remote app exposing a ProductCard component:
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
mode: 'production',
output: {
publicPath: 'auto',
},
plugins: [
new ModuleFederationPlugin({
name: 'productApp',
filename: 'remoteEntry.js',
exposes: {
'./ProductCard': './src/components/ProductCard',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.3.0',
},
'react-dom': {
singleton: true,
requiredVersion: '^18.3.0',
},
},
}),
],
};The singleton: true flag is non-negotiable for React. It tells Module Federation to always use the highest compatible version instead of loading multiple copies. requiredVersion acts as a safety net — if a remote ships React 19 and your host expects React 18.3.0, federation will warn rather than silently breaking.
Setting Up the Host Shell with Dynamic Remote Loading
The shell (or host) app is usually a thin wrapper. It handles routing, global auth state, the navigation shell, and dynamically loads remote modules as the user navigates. Think of it as the operating system — it doesn't do much on its own.
Dynamic imports are what make this work at runtime. You declare remotes either statically in webpack config or dynamically via __webpack_init_sharing__ and __webpack_share_scopes__ APIs. Static declaration is simpler; dynamic is more flexible if you need to swap remotes based on feature flags or A/B tests.
// Host shell: loading a remote component lazily
import React, { Suspense, lazy } from 'react';
// TypeScript: declare module to satisfy the compiler
declare module 'productApp/ProductCard' {
const ProductCard: React.FC<{ productId: string }>;
export default ProductCard;
}
const ProductCard = lazy(() => import('productApp/ProductCard'));
export function ProductPage({ productId }: { productId: string }) {
return (
<Suspense fallback={<div className="h-48 w-full animate-pulse bg-white/10 rounded-xl" />}>
<ProductCard productId={productId} />
</Suspense>
);
}The Suspense fallback here uses a glassmorphism-style skeleton — bg-white/10 with animate-pulse from Tailwind v4.0.2. It matters because network fetches for remote entries can take 100-400ms on a cold load, and you don't want a blank flash. If you're building design-consistent shells, Empire UI's theme toggle component handles dark/light switching across federated apps cleanly since it's a stateless singleton.
Handling Shared State Across Federated React Apps
This is where most micro-frontend projects run into serious trouble. Each remote module has its own React context tree. If your auth context lives in the shell, remote components can't access it via useContext — at least not without deliberate design.
There are three practical patterns. First, pass everything as props — works fine for simple cases, gets verbose fast. Second, use a shared state library like Zustand or Jotai that you declare in shared config and mark as singleton. Third, use a pub/sub event bus via window custom events — low tech but surprisingly effective for cross-team coordination without coupling.
Zustand works best in 2026 because its store instances are just closures. Declare zustand in shared config with singleton: true, create your store in the shell, and any remote that imports from the same package gets the same store instance. Contrast this with Redux, where the store has to be explicitly passed via a Provider — more ceremony, same outcome.
One thing that trips people up: React toast notifications are a perfect example of shared singleton state. Your useToast hook needs one shared queue, not separate queues per micro-frontend. Put the Toaster component in your shell and expose only the useToast hook via a shared utility package.
CSS Isolation: Avoiding Style Collisions Between Remotes
CSS in micro-frontends is genuinely painful. Two remotes, both using Tailwind, both generating a .container class with different max-widths — that's a fight your browser will resolve in unpredictable ways depending on load order.
The cleanest solution in 2026 is CSS Modules at the component level combined with a Tailwind prefix per remote. In your tailwind.config.ts for the product remote, set prefix: 'product-'. Now product-flex and product-text-sm won't collide with the shell's unprefixed utilities. Yes, it means your class names get verbose. That's the tradeoff.
/* globals.css for the product remote — scoped via data attribute */
[data-remote="product"] {
/* Reset only what you own */
box-sizing: border-box;
font-family: inherit; /* inherit from shell */
color: inherit;
}
[data-remote="product"] .product-card {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
padding: 16px;
gap: 8px;
display: flex;
flex-direction: column;
}The data-remote attribute approach lets you scope your remote's styles without Shadow DOM complexity. Wrap your remote's root element in <div data-remote="product"> and all your CSS specificity fights stay contained. For deeper context on when CSS Modules make more sense than Tailwind utilities, this comparison covers the tradeoffs honestly.
Performance: What Module Federation Actually Costs You
Let's talk numbers. A remoteEntry.js file is typically 2-15kb gzipped — small. But that's just the manifest. The actual remote chunk containing your component can be 50-300kb depending on what it imports. On a first visit, the user's browser fetches the shell bundle, then separately fetches each remote's entry file, then fetches the remote chunks on demand.
The waterfall is the problem. If your shell app has 5 remotes visible above the fold, that's potentially 5 sequential or parallel fetches before the page is interactive. Promise.all on dynamic imports helps — kick off all remote fetches simultaneously rather than waiting for each one. Also, set Cache-Control: public, max-age=31536000, immutable on your remoteEntry.js files... but only if you include a content hash in the filename.
Is the extra network overhead worth it? That depends entirely on your team structure. If you have 8 teams fighting over a single deployment pipeline, the autonomy gains dwarf the 200ms extra load. If you're a team of 4, the complexity cost is brutal for minimal gain. For React performance tuning in a single-app context before you jump to federation, this performance guide covers code splitting and lazy loading patterns that get you 80% of the benefit.
Rspack is worth serious attention here. Swapping Webpack for Rspack in your host and remotes reduces cold build times from 45s to 8s on a typical mid-size app. Rspack 1.x is API-compatible with Webpack 5's Module Federation, so the migration is mostly a package.json swap and some config renaming.
TypeScript Across Module Boundaries
TypeScript and Module Federation have an awkward relationship. By default, remotes don't expose their types — the host has no idea what props ProductCard accepts until runtime. You fix this with a few approaches, none perfect.
The @module-federation/typescript plugin generates type declaration files from your exposed modules and drops them in a shared location. Your host then loads these types at dev time. It adds a step to your CI pipeline but it's worth it — catching a prop rename at type-check time beats catching it in production.
// vite.config.ts (if you're using Vite + @originjs/vite-plugin-federation)
import federation from '@originjs/vite-plugin-federation';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
federation({
name: 'checkoutApp',
filename: 'remoteEntry.js',
exposes: {
'./CheckoutForm': './src/CheckoutForm.tsx',
},
shared: ['react', 'react-dom'],
}),
],
build: {
target: 'esnext',
minify: false, // required for federation chunks
cssCodeSplit: false,
},
});Note the minify: false requirement for Vite federation — this trips people up. Minification can mangle the module names that federation relies on for chunk matching. For general TypeScript patterns in React components, these TypeScript tips cover discriminated unions and generic component patterns that work well within federated module boundaries.
When to Use Micro-Frontends (and When to Walk Away)
Here's the thing: most apps don't need micro-frontends. If your entire frontend team fits in one standup call and you're not fighting deployment coordination pain, you're adding complexity for no gain.
The signal that you actually need federation is when deployment becomes a bottleneck. When team A can't ship because team B broke the shared build. When you have genuinely independent product domains — checkout, search, catalog, account — owned by separate teams with separate release cycles. That's when the coordination overhead of a monolith starts costing more than the setup overhead of federation.
What does federation not solve? Shared design systems still need a centralized component library. Accessibility still needs to be considered holistically. And if your teams share a lot of domain logic, the "independence" of micro-frontends is partially illusory — you'll just move the coupling from the build to runtime contracts.
Start with a monorepo with clear module boundaries before jumping to runtime federation. Tools like Nx and Turborepo give you team autonomy and fast builds without the runtime complexity of Module Federation. Federation is for when even that isn't enough. It's a tool for a specific scaling problem, not a default architecture.
FAQ
Technically possible but not recommended. With singleton: true in shared config, Module Federation picks the highest compatible version. React 19 is mostly backward-compatible with React 18 patterns, but hooks behavior around transitions changed enough that a remote built assuming React 18 semantics can behave unexpectedly when run under React 19. Pin to the same major version across your remotes whenever you can.
Keep auth state in the shell. Store your JWT or session token in memory (not localStorage — XSS risk) within the shell's auth module, declare it as a shared singleton, and expose a useAuth hook. Each remote imports useAuth from the shared package and gets the same in-memory reference. Never let individual remotes manage their own auth — you'll end up with 5 different token refresh strategies fighting each other.
iframes give you complete isolation — different origin, separate JS heap, no shared state, no style collisions. That's also their limitation: cross-frame communication is clunky (postMessage), performance is heavier, and accessibility across frame boundaries is a documented mess. Module Federation shares the JS runtime, which means shared React, shared state if configured, same CSS cascade. iframes are appropriate for true third-party embeds (Stripe, payment forms). Federation is for same-product teams needing deployment independence.
Yes, with caveats. The @module-federation/nextjs-mf package supports Next.js App Router as of mid-2025. Server Components and federation don't mix cleanly yet — federated remotes are client-only. You can't expose a Server Component from a remote and have the host render it on the server. Stick to client components in your exposed modules and you'll avoid most of the pain.
Mock the remote entry files in your host's test environment. Create a __mocks__ directory with a stub remoteEntry.js that exports dummy versions of your exposed components. For integration tests, use docker-compose to spin up lightweight remote containers exposing only the built static assets. Contract testing with Pact is worth considering for teams with strict SLAs — it lets remotes and hosts verify their API contracts independently without full integration suites.
Yes. Rspack 1.x is designed to be a drop-in replacement for Webpack 5 including the Module Federation runtime. A Rspack-built remote can be consumed by a Webpack 5 host and vice versa. The runtime protocol is compatible. This means you can migrate remotes one at a time — swap one team to Rspack for faster builds and leave the others on Webpack without breaking anything.