EmpireUI
Get Pro
← Blog9 min read#micro frontends#module federation#webpack 5

Micro-Frontends With Module Federation: Webpack 5 Setup and Pitfalls

Module Federation in Webpack 5 lets you share React components across independently deployed apps — but the setup has real gotchas. Here's the practical guide.

Multiple connected browser windows showing modular frontend architecture

What Module Federation Actually Is (And What It Isn't)

Module Federation shipped with Webpack 5 in 2020, and it's still the most practical way to share JavaScript modules — including full React component trees — across independently deployed applications at runtime. Not at build time. At runtime. That distinction matters more than most articles bother to explain.

Before Webpack 5, micro-frontend teams stitched things together with iframes, npm packages, or monorepo builds that defeated the whole point of independent deployment. Module Federation changes the contract: one app (the remote) exposes a module, another app (the host) consumes it, and the sharing happens through a manifest file fetched at page load. No shared npm publish step, no coordinated deploys.

That said, Module Federation isn't a silver bullet. It's a federation of JavaScript modules, not a micro-frontend framework. You still have to figure out routing, shared state, authentication hand-off, and CSS isolation yourself. What it solves brilliantly is the *bundle sharing problem* — preventing both apps from loading two copies of React, for instance, which would otherwise balloon your page weight and cause context mismatches.

Honestly, the mental model that clicks fastest is this: think of a remote's exposes config as a dynamic npm publish, and a host's remotes config as a lazy npm install that happens in the browser. Once that lands, the rest of the API makes intuitive sense.

Webpack 5 Configuration: Host and Remote From Scratch

You need at least two separate Webpack 5 apps. Let's say shell is the host and ui-kit is the remote that exposes your design system components. In 2025 most teams use webpack.config.js (CJS or ESM), though some Vite-based setups use @originjs/vite-plugin-federation — we're sticking with canonical Webpack 5 here.

Start with the remote (ui-kit) config. The ModuleFederationPlugin is built into Webpack 5 — no extra install: ``js // ui-kit/webpack.config.js const { ModuleFederationPlugin } = require('webpack').container; const deps = require('./package.json').dependencies; module.exports = { mode: 'production', output: { publicPath: 'https://cdn.mycompany.com/ui-kit/', // MUST be absolute filename: '[name].js', }, plugins: [ new ModuleFederationPlugin({ name: 'ui_kit', // underscore, not hyphen filename: 'remoteEntry.js', exposes: { './Button': './src/components/Button', './Card': './src/components/Card', }, shared: { react: { singleton: true, requiredVersion: deps.react }, 'react-dom': { singleton: true, requiredVersion: deps['react-dom'] }, }, }), ], }; ``

Now the host (shell) config: ``js // shell/webpack.config.js const { ModuleFederationPlugin } = require('webpack').container; const deps = require('./package.json').dependencies; module.exports = { mode: 'production', output: { publicPath: 'https://cdn.mycompany.com/shell/', }, plugins: [ new ModuleFederationPlugin({ name: 'shell', remotes: { // key = alias used in import, value = name@URL ui_kit: 'ui_kit@https://cdn.mycompany.com/ui-kit/remoteEntry.js', }, shared: { react: { singleton: true, requiredVersion: deps.react }, 'react-dom': { singleton: true, requiredVersion: deps['react-dom'] }, }, }), ], }; ``

Consumption in the host looks like a standard dynamic import, just with a federated module path: ``tsx // shell/src/App.tsx import React, { Suspense, lazy } from 'react'; const RemoteButton = lazy(() => import('ui_kit/Button')); export default function App() { return ( <Suspense fallback={<div>Loading component…</div>}> <RemoteButton variant="primary">Click me</RemoteButton> </Suspense> ); } ``

Worth noting: TypeScript won't know about 'ui_kit/Button' at compile time. You need a declaration file in the host: ``ts // shell/src/declarations.d.ts declare module 'ui_kit/Button' { import { ComponentType } from 'react'; const Button: ComponentType<{ variant?: string; children?: React.ReactNode }>; export default Button; } ` This is annoying boilerplate. Some teams auto-generate it with @module-federation/typescript` — worth evaluating if you have many remotes.

The Pitfalls That Will Actually Bite You

The `singleton: true` trap. If you forget singleton: true on React, you'll get two React instances in memory, and hooks will silently break — you'll see "Invalid hook call" errors with no obvious stack trace pointing at Module Federation. Always declare React and ReactDOM as singletons. Same goes for any context-based library: react-router-dom, zustand, react-query.

`publicPath` must be absolute and match your CDN exactly. This is the #1 deployment problem. If you use publicPath: 'auto' in development but publicPath: 'https://cdn.mycompany.com/ui-kit/' in production, your remoteEntry.js will request chunk files relative to whichever domain the browser thinks is the base — and they'll 404. Set it explicitly in every environment. Use an environment variable and never hardcode it: ``js output: { publicPath: process.env.CDN_ORIGIN + '/ui-kit/', } ``

Version skew between shared packages. Say your host requires react@18.2.0 and your remote ships react@18.3.0. With strictVersion: false (the default), Webpack picks the highest compatible version — usually fine. With strictVersion: true, it throws at runtime instead of silently using a mismatched version. In practice, set strictVersion: true in CI and strictVersion: false in dev so you catch mismatches before prod without blocking local iteration.

Bootstrap async boundary. Module Federation requires your app's entry point to be async — any synchronous top-level import of a federated module will fail. Wrap your main entry: ``js // shell/src/index.ts ← thin wrapper import('./bootstrap'); // async boundary // shell/src/bootstrap.tsx import React from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; createRoot(document.getElementById('root')!).render(<App />); `` Skipping this step causes a hard crash with a cryptic "Shared module is not available for eager consumption" error. It's confusing the first time you hit it.

CSS conflicts. Module Federation handles JavaScript isolation but does nothing for CSS. If your remote uses global class names or injects stylesheets into <head>, those styles will bleed into the host. Use CSS Modules, @layer, or a CSS-in-JS solution in your remote components. If you want a proven set of pre-isolated, composable UI components to use as remotes, Empire UI ships every component with scoped styles — a good starting point for your remote ui-kit.

Sharing State Across Federated Apps

Here's a question worth sitting with: where does your auth token live when you have four independent apps? The naive answer is localStorage, and it mostly works — all apps on the same origin can read it. But if you're running remotes on separate origins (different subdomains, say), you need a dedicated auth micro-service that sets a cookie with SameSite=None; Secure or a shared token endpoint the host injects into each remote's initialization.

For non-auth state (user preferences, feature flags, current workspace), a federated store module works well. Expose a tiny Zustand or Jotai store from one remote, import it as a singleton into others. Because you declared it as singleton: true in shared, every federated app ends up referencing the exact same module instance — which means they share the same runtime store object. It's simple and it works. ``js // shared-store/webpack.config.js exposes: { './useAppStore': './src/useAppStore', }, shared: { zustand: { singleton: true, requiredVersion: deps.zustand }, } ``

Quick aside: event buses are another common pattern. The host posts a custom window event, remotes listen. It's ugly but it dodges any module-sharing complexity entirely. For loose coupling between genuinely independent teams, it's sometimes the right call — no shared dependency to coordinate, no module version to match.

In practice, the cleanest architectures I've seen push ephemeral UI state into each remote and only share identity (who is logged in) and navigation state (current route) at the host level. Everything else should be local. Don't recreate a monolith's shared Redux store inside your micro-frontend shell — you'd be defeating the independence that made you choose this architecture in the first place.

Routing in a Federated Shell

Each remote app typically owns its own routes. The shell defines top-level route segments and lazily mounts remote apps at those paths. React Router v6 (released in late 2021, now standard) handles this cleanly with nested routes and Outlet. ``tsx // shell/src/routes.tsx import { lazy, Suspense } from 'react'; import { Routes, Route } from 'react-router-dom'; const DashboardApp = lazy(() => import('dashboard/App')); const BillingApp = lazy(() => import('billing/App')); export function AppRoutes() { return ( <Routes> <Route path="/dashboard/*" element={ <Suspense fallback={<Spinner />}><DashboardApp /></Suspense> } /> <Route path="/billing/*" element={ <Suspense fallback={<Spinner />}><BillingApp /></Suspense> } /> </Routes> ); } ``

The /* wildcard on each parent route is critical — without it, React Router won't forward sub-paths to the remote app's own router. The remote app then declares its own <Routes> starting from its root, and React Router in both apps sees the same window.history instance (since they share a singleton react-router-dom).

One more thing — don't put <BrowserRouter> in every remote. Only the shell should own the router provider. Remotes should use useRoutes or declare <Routes> without wrapping them in a router. If a remote ships its own <BrowserRouter>, you'll end up with nested routers and navigation that silently breaks only on page refresh.

For design inspiration on how a polished shell navigation might look across different visual styles, check out Empire UI's component library — the nav bar components in particular handle active-route highlighting and nested menus in ways that translate directly to federated routing patterns.

CI/CD and Independent Deployment

The whole value proposition of micro-frontends is that team A can deploy their remote without coordinating with team B's host. But you need to protect against breaking changes in exposed modules. The non-negotiable piece is a consumer-driven contract test: the host's test suite imports the remote's exposed modules and asserts their API shape. Run this in CI before merging remote changes.

A minimal contract test looks like this: ``ts // shell/__tests__/remote-contracts.test.ts // Uses the built remote bundle directly — no dev server needed import Button from 'ui_kit/Button'; import { render } from '@testing-library/react'; test('Button remote exposes a renderable component', () => { const { getByText } = render(<Button variant="primary">Test</Button>); expect(getByText('Test')).toBeInTheDocument(); }); ``

For deployment, the typical pipeline is: build remote → upload chunks + remoteEntry.js to CDN → invalidate CDN cache for remoteEntry.js only (chunks are content-hashed, so they're cached forever). The host fetches remoteEntry.js fresh on each page load, so it picks up new remote versions automatically. If you want controlled rollouts, version the remoteEntry.js URL (remoteEntry.v2.js) and update the host's remotes config deliberately.

Worth noting: if your team uses feature flags to control which remote version is active, you can dynamically construct the remoteEntry URL at runtime instead of hardcoding it in Webpack config. Webpack 5 supports dynamic remote loading via __webpack_init_sharing__ and __webpack_share_scopes__ — a bit verbose but it unlocks A/B testing at the module level.

For visual consistency across independently deployed remotes, your ui-kit remote should be the single source of truth for design tokens. If you're building that shared component layer, Empire UI's gradient generator and box shadow generator are handy for extracting the exact CSS values you'd codify as tokens — especially if you want your federated design system to support multiple visual themes like glassmorphism or neobrutalism.

When Module Federation Is (and Isn't) Worth the Complexity

Module Federation adds real operational complexity: you're managing distributed JavaScript artifacts, runtime manifest fetching, shared dependency graphs, and cross-app routing. That complexity has a break-even point, and it's higher than most blog posts admit.

Look, if you have one team and one codebase, stay in a monorepo. A well-structured Next.js app with src/features/* folders and barrel exports gets you 90% of the architectural benefits with none of the runtime coordination overhead. Module Federation earns its complexity when you have two or more independent teams that genuinely need to deploy on separate schedules, or when a shared component library needs to update across 10+ consuming apps without a synchronized release.

The sweet spot is a design system remote that multiple product apps consume. One team owns ui-kit, they ship updates to the CDN, consuming apps pick up the new version on next page load. No npm publish, no coordinated bumps across 10 package.json files. That specific pattern — one remote, many hosts — is where Module Federation shines and the complexity cost is clearly justified.

For teams building that shared design system layer, browse Empire UI's component library to see what a mature, multi-style component system looks like at scale — it's free, MIT-licensed, and structured in a way that maps naturally to a Module Federation exposes config. You can also check out the templates section to see how the same components compose into full page layouts across different industries.

FAQ

Do I need a separate Webpack config for every micro-frontend app?

Yes, each app — host or remote — has its own Webpack config with its own ModuleFederationPlugin setup. There's no shared config file. The configs coordinate through the remotes and exposes keys, not through a shared file.

Can I use Module Federation with Vite instead of Webpack?

You can, via @originjs/vite-plugin-federation. It's not official and has quirks around SSR and HMR that the Webpack plugin doesn't, but it works for most client-rendered use cases. Mixing a Vite remote with a Webpack host is also possible — the runtime protocol is standard JavaScript, not tied to the bundler.

What happens if the remote's CDN goes down?

The host's Suspense boundary catches the failed dynamic import and renders your fallback. You should also add an error boundary around federated components to show a graceful degraded state rather than a blank panel. The host app itself keeps running.

How do I handle TypeScript types for remote modules?

Write manual declaration files (.d.ts) for each exposed module, or use the @module-federation/typescript plugin which generates them from the remote's build output automatically. The auto-generation approach is worth setting up once you have more than three or four exposed modules.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Micro-Frontends in React: Module Federation, Single-SPALottie Animations in React: Setup, Optimisation and PitfallsMicro-Frontends with React: Module Federation in 2026React Architecture & Patterns: The Complete 2026 Guide