Fifty teams shipping frontend code independently sounds like a dream. Then React 17 and React 18 collide at runtime, your design system has six competing “latest” versions in production simultaneously, and a shared header owned by nobody quietly breaks for 30% of your users.
This is the real problem with micro frontends at scale. The pattern isn’t complicated. The governance layer — the shared kernel, versioning contracts, the theming system that can’t shatter when Team 47 deploys on a Friday — that’s where things fall apart. Here’s how to actually structure it.
The Monorepo Foundation
First, a clarification that trips up almost every team starting this journey: monorepo is a code storage strategy; micro frontends are a runtime composition strategy. They’re not alternatives — they’re orthogonal. You can have 50 micro frontends all living in a single monorepo, which is exactly what most large-scale teams do [2]. You get team autonomy at deploy time without fragmenting shared packages across 50 different repos.
The real question at 50+ apps is which monorepo toolchain to use.
| Tool | Best For | Build Speed | Overhead |
|---|---|---|---|
| Nx | Multi-framework, enterprise guardrails, Module Federation built-in | Fast (Rust daemon) | Higher — generators, plugins, conventions |
| Turborepo | Pure JS/TS teams, simpler config, Vercel integration | Very fast (Rust, remote cache) | Low — minimal config to get started |
| pnpm workspaces | Lightweight, no orchestration needed | Package manager speed | Minimal — no build graph awareness |
Turborepo, built in Rust after Vercel acquired it in 2021, wins on simplicity [4]. But if your 50 teams span React, Angular, and Vue, use Nx — its native Module Federation support and code generators enforce conventions that prevent teams from quietly diverging from the architecture [3].
The workspace structure matters as much as the tool itself. Here’s a layout that scales:
/
├── apps/
│ ├── shell/ # Host application — routing, auth, layout
│ ├── mfe-catalog/ # Owned by Catalog team
│ ├── mfe-checkout/ # Owned by Checkout team
│ └── ... (47 more)
├── packages/
│ ├── ui/ # Shared component library
│ ├── design-tokens/ # Token definitions only — no compiled styles
│ ├── utils/ # Shared utilities
│ ├── i18n/ # Translation infrastructure
│ └── tsconfig/ # Base TypeScript configs
└── tools/
└── configs/ # Shared ESLint, Prettier, CI pipeline templates
Apps depend on packages. Packages never depend on apps. Apps never directly import other apps. Cross-app imports at build time are the single most common way teams accidentally recreate a monolith inside a monorepo [15].
The Feature-Sliced Design architecture formalizes this boundary precisely: strict layering where lower-level shared utilities have no knowledge of the business features above them, which prevents the circular dependency chains that make large frontends unmaintainable [2].
Theming and Design Tokens at Scale
This is where multi-team systems accumulate invisible technical debt. Each team ships with a slightly different shade of $blue-500. Buttons that look identical have four implementations. Dark mode works in three apps and silently breaks in twelve. Nobody notices until a designer runs an audit.
CSS custom properties are the only theming primitive that scales across micro frontends. Not compiled SCSS variables, not JS theme objects, not Tailwind config files. CSS custom properties propagate at runtime, cross Shadow DOM boundaries, work regardless of which framework a consuming MFE uses, and can be overridden dynamically [11].
The token architecture needs three layers:
/* Layer 1: Global primitives — reference only in semantic layer */
:root {
--primitive-blue-500: #3b82f6;
--primitive-space-4: 1rem;
}
/* Layer 2: Semantic tokens — these are the API consuming teams use */
:root {
--color-interactive: var(--primitive-blue-500);
--color-interactive-hover: var(--primitive-blue-700);
--space-component-padding: var(--primitive-space-4);
}
/* Layer 3: Component-scoped tokens */
.button {
--button-background: var(--color-interactive);
background: var(--button-background);
}
Consuming teams should only ever reference semantic tokens. If Team 23 hardcodes --primitive-blue-500 directly, your next rebrand becomes a find-and-replace exercise across 50 micro frontends [10]. Semantic tokens decouple intent from value.
Accessibility overlaps here directly and almost for free. Map prefers-color-scheme and prefers-reduced-motion to semantic token overrides in the token package itself:
@media (prefers-reduced-motion: reduce) {
:root { --motion-duration-standard: 0ms; --motion-duration-fast: 0ms; }
}
@media (prefers-contrast: more) {
:root { --color-interactive: #1d4ed8; } /* Higher contrast blue */
}
Any component that consumes semantic tokens gets accessibility adaptations for free — the component doesn’t need to know about the media query. This is the compounding benefit of doing tokens properly [11].
Accessibility Cannot Be Each Team’s Problem
Honest take: if you tell 50 teams “each of you is responsible for WCAG compliance,” you’ll get wildly inconsistent results. Some teams care deeply. Others have a product manager asking why they’re spending sprints on “invisible stuff.”
Accessibility must be enforced at the shared component level, not delegated to consuming teams. Every interactive element in packages/ui ships with keyboard navigation, correct ARIA attributes, visible focus rings, and sufficient contrast — by default, not as an opt-in prop. If the accessible version is the default version, teams can’t accidentally ship an inaccessible variant without explicitly overriding it.
The shell application owns two accessibility concerns that live in the gaps between micro frontends:
- Focus management on route transitions — not just scroll-to-top. When the user navigates from the Catalog MFE to the Checkout MFE, a screen reader needs to hear that the page changed. The shell should announce the new page title to an ARIA live region on route change.
- Skip navigation — the shell injects
<a href="#main-content" class="skip-link">Skip to main content</a>once. Every MFE must expose a#main-contentlandmark. This is a published interface contract, not a nicety.
The skip link contract belongs in your ADR (Architecture Decision Record) and should be checked by the contract tests in CI. If a team removes the #main-content id from their MFE, the CI pipeline catches it before it ships.
Runtime Integration: Three Real Options
Module Federation 2.0
Webpack Module Federation allows independently built applications to expose and consume modules at runtime without pre-bundling them together [1]. Version 2.0 reached a stable release in April 2026 and added three things that genuinely matter at 50+ MFE scale [7]:
- TypeScript type sharing — remotes export their types to the host, so you get autocomplete across MFE boundaries without manual type package publishing
- Manifest-based remote discovery — the host fetches a manifest at startup to resolve remote URLs dynamically, enabling canary deploys and instant rollbacks without redeploying the shell
- Bundler-agnostic runtime —
@module-federation/enhancedworks across webpack, Rspack, Rollup, and Vite under a single API
For teams where webpack build times have become a genuine bottleneck, Rspack (webpack-compatible, built in Rust) + MF2 cuts build time by 5–10× while maintaining full API compatibility [7].
Import Maps
Import maps are a browser-native feature that lets you remap bare module specifiers to real URLs — ~94.5% global browser support as of early 2026 [8]:
<script type="importmap">
{
"imports": {
"react": "https://cdn.example.com/react@18.3.0/esm.js",
"@company/design-tokens": "https://cdn.example.com/tokens@2.4.0/index.js"
}
}
</script>
The shell controls the import map. The shell controls which version every MFE gets. No negotiation, no runtime version conflicts. The tradeoff: import maps are harder to combine with SSR and don’t offer the DX niceties of Module Federation type sharing. Single-spa’s recommended setup has used SystemJS + import maps as its standard since 2022, making it the well-tested path for gradual modernization workloads [16].
Native Federation
Native Federation applies the Module Federation mental model to browser-native ESM and import maps — no webpack dependency required [9]. It’s the natural choice if you’re building new MFEs with Vite, Rspack, or Angular 17+ and want to avoid bundler lock-in entirely.
For new systems in 2026: Native Federation or MF2-on-Rspack. For migrating existing webpack setups: @module-federation/enhanced is the path of least resistance.
Zero Version Conflicts: The Singleton Contract
Here’s the thing about version conflicts that the docs undersell: the problem isn’t just duplicated code (annoying but survivable) — it’s two separate React instances running simultaneously. React hooks throw when a component from one instance tries to use context from another [6]. Zalando’s engineering team called this out explicitly as a “real concern” when building their modular portal with Module Federation, noting it could cause silent runtime errors or unexpected behavior across teams [12].
The fix is the singleton flag in your shared config:
// Every MFE's webpack/rspack config — no exceptions
new ModuleFederationPlugin({
name: 'mfe_catalog',
shared: {
react: {
singleton: true,
strictVersion: false,
requiredVersion: '^18.0.0'
},
'react-dom': {
singleton: true,
strictVersion: false,
requiredVersion: '^18.0.0'
},
'@company/design-tokens': {
singleton: true,
strictVersion: true, // ← fail loudly on breaking version
requiredVersion: '~2.0.0'
},
}
})
singleton: true tells Module Federation to use the first-loaded instance and skip loading any alternative [5]. For React and ReactDOM, pair it with strictVersion: false — one running instance matters more than version exactness. For design tokens, strictVersion: true — you want it to throw at startup if a team ships on a breaking version, because silent inconsistency is worse than a loud failure [5].
The multiple instance problem is exactly this — two separate runtime instances of the same library coexisting and causing unpredictable behavior [6]. Enforcing this shared config across all 50 apps via a linting rule or a generated Nx plugin config (rather than trusting teams to copy-paste it correctly) is the only sustainable approach at scale.
Publishing Strategy
At 50+ MFEs, you’re managing two distinct publishing concerns that need separate strategies.
Shared packages (packages/ui, packages/design-tokens, packages/utils) are consumed by other teams and published to a private npm registry. These get semantic-release configured per-package, which automates version bumps, changelogs, and registry publishing entirely from conventional commit messages [13]. No manual version bumps. No “I forgot to publish after merging.” The multi-semantic-release package extends this to the monorepo — if packages/ui has a breaking change, any package depending on it automatically gets a patch bump in the same release run [13].
MFE apps are not npm packages. They’re deployed artifacts. The CDN URL structure matters:
https://cdn.company.com/mfe/
├── catalog/
│ ├── v2.4.1/remoteEntry.js # pinned version, immutable
│ ├── v2.4.0/remoteEntry.js
│ └── manifest.json # { "stable": "v2.4.1", "canary": "v2.5.0-rc1" }
├── checkout/
│ └── ...
└── global-manifest.json # single source of truth for shell at startup
The shell must never hardcode latest/ URLs in production. At startup, the shell fetches global-manifest.json and resolves the correct remote URL for each MFE [18]. This is what enables canary deployments (route 5% of traffic to a new version), instant rollbacks (update the manifest, not a shell deploy), and stable pinned versions for debugging production incidents.
CI/CD: Independence Without Chaos
Each micro frontend gets its own pipeline, but the pipelines share a contract [14]. Every MFE CI pipeline runs these steps in order:
- Build — generate
remoteEntry.js+ hashed static assets - Unit + integration tests — scoped to the MFE only
- Contract tests — assert that all exposed module interfaces still match the TypeScript contract the shell expects
- Upload versioned artifacts to CDN (immutable, never overwrite)
- Update manifest — register as a canary candidate
- Monitor canary — 5% traffic, watch error rate for 15 minutes
- Promote to stable or rollback in manifest
The contract testing step is what most teams skip and then regret. When the Catalog MFE renames a prop on its exposed CatalogPage component, the shell doesn’t find out until a user sees a broken page [14]. A contract test that checks the exposed module’s shape against the host’s expectations — committed in the same repo, run in both the MFE’s CI pipeline and the shell’s — closes this gap entirely.
The monorepo pays off in CI: Nx’s affected command or Turborepo’s dependency graph-aware --filter automatically identifies which apps to rebuild and test when a shared package changes [3][4]. A change to packages/design-tokens triggers the pipelines for every MFE that consumes it — you get broad test coverage without running everything on every commit.
Governance: Who Owns What
At 50 teams, “everyone owns the shared components” means nobody does. The ownership model needs to be explicit.
A dedicated platform/foundation team owns:
packages/ui— the shared component librarypackages/design-tokens— the token definitions- The shell application’s routing and layout infrastructure
- The global CDN manifest
- The Module Federation / import map configuration templates
Each product team owns:
- Its own MFE app, end-to-end
- Its own CDN deployment pipeline
- Keeping its shared dependency versions within the permitted semver range
An Architecture Working Group (doesn’t need to be formal — a weekly async thread with opt-in attendance works fine) handles cross-cutting decisions: proposals for new shared packages, deprecations, token naming conventions, breaking change schedules.
Adding a new shared package requires a proposal, not just a PR. The worst governance failure pattern I’ve seen in large front-end platforms: a team adds a new packages/feature-x to the monorepo unilaterally, other teams start depending on it, and then the original team reorganizes — leaving the platform team with an undocumented, orphaned package that everyone secretly imports. One short RFC document, one approval from the platform team, is all it takes to prevent this.
The platform team should be the only group with write access to the global manifest and the CDN stable promotion pipeline. Every other team can deploy to canary freely — but promoting to stable goes through one gatekeeper. This is the difference between “50 teams can ship independently” and “50 teams can accidentally take down the shell on a Friday afternoon.”
End
Sources
- Module Federation — webpack official docs
- Monorepo Architecture: The Ultimate Guide for 2025 — Feature-Sliced Design
- Micro Frontend Architecture — Nx Documentation
- Monorepo Tools Comparison: Turborepo vs Nx vs Lerna in 2025
- Solving Micro-Frontend Challenges with Module Federation — LogRocket Blog
- Understanding the Multiple Instance Problem in Micro Frontends
- Module Federation 2.0 Reaches Stable Release with Wider Support — InfoQ
- Micro Frontends with Import Maps: The Lightweight Alternative to Module Federation
- Micro Frontends with Angular and Native Federation — Angular Blog
- Design Tokens: The Foundation of Your UI Architecture — Feature-Sliced Design
- CSS Variables Guide: Design Tokens & Theming — FrontendTools
- Building a Modular Portal with Webpack Module Federation — Zalando Engineering Blog
- Semantic Release — FAQ and Documentation
- CI/CD Pipelines and Automation for Micro-Frontends — microfrontend.dev
- Micro-Frontends: Are They Still Worth It in 2025? — Feature-Sliced Design
- The Recommended Setup — single-spa
- Micro-Frontends: Complete Implementation Guide for Large Teams
- Scalable Micro-Frontend Deployment Strategy