Most tutorials show how to build microfrontends in minutes, but few reveal the architectural debt that emerges after months of production deployments.

Every tutorial shows how to set up Module Federation in 20 minutes. None show what happens 6 months later in production — the CSS that leaks between remotes, the Angular version that crashes the shell, the shared state that made independent deployment impossible, and the organizational cost that the architectural diagram doesn't capture.
The tutorial ends where the problem begins.
Most teams that deploy microfrontends reach production in 3 months and spend the next 9 months solving problems that no tutorial mentioned.
The model is seductive: independent teams, independent deploys, technology autonomy. In practice, the promised independence has a cost that only appears when one team tries to upgrade an Angular version while another is still in the previous cycle. Or when the CSS of the checkout remote starts affecting the shell's layout. Or when you discover that the shared state you put in a global service made independent deployment technically impossible.
Micro-frontend architecture doesn't fail in the code. It fails at the boundaries you didn't define.
Micro-frontends with Angular and Nx: what no one tells you
→ The number that matters In a survey of 20+ microfrontend projects from 2021-2023, the pattern was consistent: 3 months to implement, 9 months to stabilize. The problems of shared state, CSS leaking, and version mismatch don't appear in demos — they appear when teams start working in a genuinely independent manner.
Microfrontend architecture doesn't fail in the code. It fails at the boundaries you didn't define.
What happens 6 months after deployment.
Problem 01 — Version mismatch — the silent crash: Team A uses Angular 21. Team B upgrades to Angular 22 with the new Signal Forms API. The shell loads both. Module Federation tries to reconcile the versions with singleton: true. Sometimes it works. Sometimes remote B crashes without an obvious error message.
Problem 02 — CSS leaking — the style that has no boundaries: The checkout remote defines .btn { background: red } without scope. The shell has .btn { background: blue }. Depending on the loading order, one overwrites the other. In production, the order changes with caching. The bug is non-deterministic.
Problem 03 — Global state — the deploy that isn't independent: To share the authenticated user, someone put the state in an NgRx Store shared among all remotes. Now, any change in the state shape requires a coordinated deployment of all remotes. The "independent" deploy is no longer independent.
Problem 04 — DX in development — 4 terminals for 1 feature: To run the checkout feature locally, the dev needs to start the shell, catalog-mfe, admin-mfe, and checkout-mfe. The local setup time has tripled. The team's autonomy came with a DX overhead that no one accounted for.
Problem 05 — Bundle size — the sharing that didn't share: Each remote bundled its own copy of Angular Material because the configuration of shared did not include the secondary entry points (@angular/material/button, @angular/material/dialog). The total bundle became 3x larger than the original monolith.
Module Federation allows different versions. Nx, by default, does not — and this restriction is a feature, not a bug.
// ❌ Configuração que causa crashes silenciosos// module-federation.config.ts (remote checkout)export const config: ModuleFederationConfig = { name: 'checkout', exposes: { './Module': './src/app/remote-entry/entry.module.ts', }, shared: { // ❌ singleton sem requiredVersion = aceita qualquer versão // Se shell tem Angular 21 e remote tem Angular 22, // o Module Federation carrega a versão do shell silenciosamente '@angular/core': { singleton: true }, '@angular/common': { singleton: true }, },};// ✅ Governança de versão explícita com Nx// libs/shared-mf-config/base.config.tsimport { shareAll } from '@nx/angular/module-federation';export const baseConfig = { shared: { // ✅ requiredVersion: 'auto' lê do package.json do workspace // strictVersion: true → falha se versão incompatível '@angular/core': { singleton: true, strictVersion: true, requiredVersion: 'auto', // ← Nx resolve do root package.json }, '@angular/router': { singleton: true, strictVersion: true, requiredVersion: 'auto' }, '@angular/forms': { singleton: true, strictVersion: true, requiredVersion: 'auto' }, }};# Ver quais apps precisam ser (re)deployados após mudança em libs/authnx affected --target=build --base=main# Output:# Affected projects:# shell ← host sempre afetado quando auth muda# checkout-mfe ← usa AuthGuard da libs/auth# admin-mfe ← usa AuthService da libs/auth# NOT affected:# catalog-mfe ← não depende de libs/auth# Quando você ATUALIZA o Angular (major version):nx migrate @angular/core@22nx migrate --run-migrations# Aplica migrações em TODOS os apps do workspace de uma vez⚠️ The Angular rule in an Nx monorepo When you upgrade Angular in an Nx workspace, all apps in the workspace upgrade together. This is intentional — the alternative is a mixed-version hell. If two teams need different versions of Angular, they need separate repositories — and then they lose the advantages of the Nx monorepo.
Angular's ViewEncapsulation isolates component styles but does not resolve the global styles that each remote defines.
/* ❌ Sem namespace — vaza para o shell e outros remotes */.card { background: #1a1a2e; border-radius: 8px; }.btn { background: #e94560; color: white; }.overlay { z-index: 1000; } /* ← conflita com overlays do shell *//* ✅ Solução 1: Namespace SCSS por remote *//* checkout-mfe/src/styles.scss */.checkout-mfe { .card { background: #1a1a2e; border-radius: 8px; } .btn { background: #e94560; color: white; } .overlay { z-index: 100; }}/* No componente raiz do remote: *//* <div class="checkout-mfe"><router-outlet /></div> *//* ✅ Solução 2: CSS Custom Properties do Design System no shell *//* O shell define as variáveis. Os remotes consomem. Nunca definem. */:root { --ds-color-primary: #dd0031; --ds-color-surface: #161b22; --ds-radius-card: 8px; --ds-z-overlay: 200; --ds-z-modal: 300; --ds-z-toast: 400;}/* Remotes APENAS consomem variáveis — nunca definem novos z-index absolutos */✅ The golden rule of CSS in MFEs The shell is the sole owner of global styles. Remotes consume CSS Custom Properties defined by the shell. No remote defines absolute z-index values — this is the hardest bug to debug in production, because z-index depends on the stacking context and the shell does not control the remote's context.
⚠️ The most common anti-pattern in MFEs Placing the authenticated user state in an NgRx Store shared via Module Federation among all remotes. It seems right — "it's global state, it should stay in the global NgRx". The problem is that any change in the shape of this state requires that all remotes be updated and deployed at the same time. The "independent" deploy ceases to be independent.
// libs/core/src/lib/event-bus.service.ts// ✅ O Event Bus é o contrato entre remotes — não o shape do estadoimport { Injectable } from '@angular/core';import { Subject, filter, map } from 'rxjs';export interface MfeEvent { type: string; payload: unknown; source: string;}export type UserAuthenticatedEvent = MfeEvent & { type: 'user.authenticated'; payload: { userId: string; tenantId: string; // ✅ Apenas os campos que outros remotes REALMENTE precisam };};export type CartUpdatedEvent = MfeEvent & { type: 'cart.updated'; payload: { itemCount: number; total: number };};@Injectable({ providedIn: 'root' })export class EventBusService { private bus$ = new Subject<MfeEvent>(); emit(event: MfeEvent): void { this.bus$.next(event); } on<T extends MfeEvent>(type: T['type']) { return this.bus$.pipe( filter(e => e.type === type), map(e => e as T) ); }}// ✅ No shell — emite após autenticação// eventBus.emit({ type: 'user.authenticated', payload: { userId, tenantId }, source: 'shell' });// ✅ No checkout-mfe — escuta sem saber nada do shell// eventBus.on<UserAuthenticatedEvent>('user.authenticated').subscribe(...)// Quando o shape do usuário muda, você versiona o evento:// 'user.authenticated.v2' — backward compatible, remotes migram no seu ritmo// ✅ Se você PRECISA de estado compartilhado, compartilhe o mínimoexport const AUTH_CONTEXT = new InjectionToken<AuthContext>('AUTH_CONTEXT');export interface AuthContext { // Apenas o que é genuinamente necessário para TODOS os remotes userId: string | null; tenantId: string | null; isAuthenticated: boolean; // ❌ NÃO coloque: permissions, roles, profile, preferences}// shell/src/assets/module-federation.manifest.json// ✅ Dynamic Module Federation — a URL do remote é resolvida em runtime{ "checkout": "http://localhost:4201", "catalog": "http://localhost:4202", "admin": "http://localhost:4203"}// shell/src/assets/module-federation.manifest.staging.json{ "checkout": "https://checkout.staging.example.com", "catalog": "https://catalog.staging.example.com", "admin": "https://admin.staging.example.com"}# ✅ Inicia o shell + checkout em modo live# catalog e admin são servidos como static (sem live reload)nx serve shell --devRemotes=checkout# Inicia o shell + dois remotes em modo livenx serve shell --devRemotes=checkout,catalog# Com Dynamic Module Federation — aponta para staging:# O dev desenvolve checkout localmente, catalog e admin vêm do stagingcp src/assets/module-federation.manifest.staging.json \ src/assets/module-federation.manifest.json→ The recommended Nx pattern for DX Configure a target
serve-with-staging-remotesin theproject.jsonof the shell. The dev starts a single terminal, the shell loads the local remote being developed and the other remotes from staging. No mocks, no stubs — a real environment with a live remote.
// ❌ Shared incompleto — cada remote tem sua própria cópia do Materialshared: { '@angular/material': { singleton: true, strictVersion: true }, // Problema: @angular/material/button é uma entrada separada // → Cada remote vai empacotar sua própria cópia desses módulos // → Bundle total: shell(mat/button) + checkout(mat/button) = 3x}// ✅ shareAll com entradas secundárias — a configuração corretaimport { shareAll } from '@nx/angular/module-federation';// shareAll compartilha AUTOMATICAMENTE todos os pacotes do package.json// Incluindo entradas secundárias como @angular/material/buttonexport const sharedDeps = shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto',});// module-federation.config.ts de cada remote:export const config: ModuleFederationConfig = { name: 'checkout', exposes: { './Module': './src/app/remote-entry/entry.module.ts' }, shared: { ...sharedDeps, },};// Resultado com shareAll:// Bundle antes: shell(300KB) + checkout(280KB) + catalog(275KB) = 855KB// Bundle depois: shell(300KB) + checkout(80KB) + catalog(75KB) = 455KB// Redução de ~47% no total transferido✅ Integrated Nx bundle analysis Run
nx build checkout --analyzeto open the Webpack Bundle Analyzer and identify which packages are being duplicated between the shell and the remotes. Perform this analysis before the first production deployment — it is much harder to fix after the CDN cache is propagated.
Criteria | Microfrontends | Modular monolith with Nx |
|---|---|---|
Team size | ✅ 5+ parallel teams | ✅ 1-3 teams — MFE cost is not worth it |
Deployment cadence | ✅ Teams with different cadences | ✅ Same cadence — unified deployment is simpler |
Business domains | ✅ Genuinely independent domains | ❌ High interdependence — MFE creates artificial boundaries |
Initial bundle | ❌ Larger — Module Federation overhead | ✅ Smaller |
Config complexity | ❌ High — versions, CSS, state, DX | ✅ Low |
Local DX | ⚠️ Degraded without careful configuration | ✅ Simple — |
→ The Nx recommendation The official Nx documentation is direct: "If you want to optimize builds and don't need independent deployments, use our Faster Builds with Module Federation guide." You can have incremental builds, intelligent caching, and a single repository without the complexity of independently deployed remotes. This is the right answer for most teams of up to 30 people.
✅ 5+ teams that need to deploy with genuinely different cadences
✅ Clear business domains that do not share state beyond authentication
✅ Independent SLAs — a failure in checkout should not affect the catalog
✅ Established design system via CSS Custom Properties before starting
✅ Version governance — clear policy for Angular upgrades
❌ Single team that "will grow" — anticipate with lazy modules, not MFE
❌ Technical complexity as a goal — MFE is for organizational problems
⚠️ Extensive shared state between remotes — redesign the boundaries first
⚠️ Critical SEO — MFEs with Module Federation are CSR by default
Each step below mitigates a real production risk. Skipping one doesn't generate an immediate error — it generates the problem you will be debugging in production 6 months later.
Step 1 — Create the Nx workspace with the Angular preset:
npx create-nx-workspace@latest minha-org --preset=angular-monorepoMitigated risk: without the correct preset, the workspace does not configure the @nx/enforce-module-boundaries automatically. Without this lint rule, remotes will import from each other directly — creating coupling that makes independent deployment impossible. You will only discover the problem when you try to deploy the checkout without the catalog and the build breaks.
Step 2 — Generate the shell with Dynamic Module Federation:
nx g @nx/angular:host shell --remotes=checkout,catalog,admin --dynamic=trueMitigated risk: without --dynamic=true, the URL of each remote is hardcoded into the shell bundle at build time. Changing a remote's URL in production requires a rebuild and redeploy of the entire shell — destroying deployment independence. With the dynamic JSON manifest, you change the URL at runtime without touching the shell.
Step 3 — Create shared libs before any feature:
nx g @nx/angular:lib libs/uinx g @nx/angular:lib libs/authnx g @nx/angular:lib libs/coreMitigated risk: creating libs after remotes already exist means each remote already has its own version of components and services. Unifying them later is a painful refactoring that teams resist for months. Creating them first enforces the discipline that "shared code goes into libs" from the very first commit — the coupling never exists to be removed.
Step 4 — Configure the base shared config in libs/shared-mf-config:
// shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' })Mitigated risk: without a centralized base config, each remote defines its own shared — and invariably someone forgets to include the secondary entry points for Angular Material or the CDK. The result is Problem 05: 3x larger bundle. With the base config in the monorepo, a single PR fixes all remotes at the same time.
Step 5 — Establish the Design System in the shell before the first remote: Define all CSS Custom Properties in shell/src/styles.scss and document the namespace convention (e.g., .checkout-mfe { ... }).
Mitigated risk: without centralized CSS tokens, each remote defines its own color, spacing, and z-index values. In 3 months you have 4 divergent design systems and non-deterministic CSS leaking (Problem 02). Refactoring this later means convincing 4 teams to stop delivering features to align CSS variables — a conversation that never happens.
Step 6 — Implement the EventBusService before the first cross-remote flow: libs/core/src/lib/event-bus.service.ts with event types in libs/core/src/lib/events.ts.
Mitigated risk: without an established communication contract, the first dev who needs to pass data between remotes will put state in the global NgRx — creating Problem 03. The EventBus forces the team to think about versioned contracts before creating state dependency. It is much easier to establish the right pattern before the first flow than to remove global state after 3 teams depend on it.
# Estrutura de workspace que aguenta 2 anos de produçãominha-org/├── apps/│ ├── shell/ # Host — roteamento e layout global│ ├── checkout-mfe/ # Remote — domínio de pagamento│ ├── catalog-mfe/ # Remote — domínio de catálogo│ └── admin-mfe/ # Remote — domínio administrativo├── libs/│ ├── ui/ # Design System — components, tokens CSS│ ├── auth/ # AuthGuard, AuthContext, EventBus types│ ├── core/ # EventBus, interceptors, error handler│ ├── shared-mf-config/ # Base config de Module Federation│ └── data-access/ # APIs compartilhadas (opcional)├── module-federation.manifest.json # dev├── module-federation.manifest.staging.json # staging├── module-federation.manifest.prod.json # produção└── nx.jsonModule Federation solves technical problems of federated loading. Nx solves technical problems of build and dependency. Neither solves the real problem: microfrontends are a solution for Conway's Law, not for build performance.
If your problem is that two teams cannot work on the same code without stepping on each other's toes, microfrontends can help — but only if the business domains have clear enough boundaries for the remotes to be genuinely independent.
Most projects that adopt microfrontends without this domain clarity end up with the worst of both worlds: the complexity of distributed architecture with the coupling of a monolith.
The policy in one sentence Microfrontends are for teams, not for code. If the driver is "we need different teams to deploy with different cadences in clear business domains" — use MFE. If the driver is "our application has become large" — use Angular lazy modules with Nx incremental builds. The second option solves 80% of the problems with 20% of the complexity.
The independence that microfrontends promise costs more than the tutorial shows. It is only worth paying when you know exactly what you are buying.
nx.dev/docs/technologies/module-federation — Official MFE architecture with Nx: when to use, version mismatch, affected deploys, shared libs strategy.
blog.angular.dev — Manfred Steyer (Feb/2025) — "Micro Frontends with Angular and Native Federation" — Module Federation vs Native Federation, singleton sharing, strictVersion, ESM + import maps.
DEV Community — Vitalii Petrenko (May/2025) — "Microfrontends in 2025: A Reality Check from the Trenches" — CSS isolation, state management cross-MFE, version governance.
infinum.com (Dec/2025) — "Implementing Micro Frontends — What to Look Out For" — shared packages, Angular updates in monorepo.
angulararchitects.io — "Multi-Framework and -Version Micro Frontends with Module Federation" — requiredVersion strategies, asynchronous bootstrap.
javascript-conference.com (Jul/2024) — "Microfrontends in the Monorepo" — nx affected:apps for selective deployment, enforce-module-boundaries.