A micro frontend is a piece of UI that is built, tested and shipped independently by one team, then composed with the others at runtime, in the browser. The word that matters is “independently”: if two parts must be shipped together, they are not micro frontends, but modules of the same application — however separate their folders are.
The architecture solves an organisational problem, not a technical one: several autonomous teams that want to ship at different rhythms without blocking each other. The price is paid in runtime complexity: dependency versions, integration failures, visual consistency, testing, observability.
- Micro frontends solve an organisational problem (teams that ship independently), not a technical one.
- The price is real: more JavaScript, dependency versions to keep in step, harder end-to-end tests.
- For Angular with esbuild, the recommended option is Native Federation. Web Components and iframes fit when you mix frameworks or want full isolation.
- The contracts between apps (routes, events, versions) matter more than the technology. Every remotely loaded app needs a fallback.
- With a single team, a monorepo with clear boundaries is almost always simpler.
Most Angular applications, including large ones, are better served by a single application in a monorepo, with clear boundaries and lazy loading. Micro frontends are the right answer for a few precise situations, and an expensive source of problems otherwise. This page helps you tell them apart and, if needed, build them correctly with Native Federation.
What it is and what it is not
A micro frontend system has three properties, all of them mandatory:
- Separate build — each part has its own pipeline and its own
package.json(or at least its own build target). - Separate deploy — the Catalog team publishes a new version without the shell or the Cart team doing anything.
- Runtime composition — a shell (host) finds out only in the browser which version of each remote to load, usually from a manifest.
BUILD-TIME — NOT a micro frontend
libs/catalog ─┐
libs/cart ─┼─► ng build shell ─► one single deploy
libs/reviews ─┘
one version, one release, contracts checked by the compiler
RUNTIME — micro frontends
catalog → build → deploy → catalog…/remoteEntry.json ─┐
cart → build → deploy → cart…/remoteEntry.json ─┼─► the shell composes them
reviews → build → deploy → reviews…/remoteEntry.json ─┘ in the browser
N versions, N releases, contracts verified only at runtime| Composition | How it works | Is it a micro frontend? |
|---|---|---|
| Libraries in a monorepo | the shell imports libs/catalog; one build, one deploy | no — it is build-time (see Monorepo) |
Lazy routes, @defer | separate chunks, but produced by the same build | no — the same application |
| Native / Module Federation | remotes publish ES modules + metadata; the shell loads them from the manifest and shares dependencies | yes |
| Web Components | each team ships a bundle that defines tags; the shell only uses them | yes (see Angular Elements) |
| iframes | separate documents, inside a frame | yes — the strongest isolation |
Real reasons, false reasons
The reasons that justify the price are almost all about people and delivery:
- Autonomous teams with different cadences. The checkout team ships several times a day, the catalog once a week, and a shared release blocks both: a regression in the catalog stops an urgent payment fix.
- Very large organisations. Dozens of frontend developers on the same product, several business domains, each with clear ownership (Conway's law: the architecture ends up reflecting the organisation anyway).
- Gradual migration (strangler fig). A new shell “wraps” the old application (AngularJS, an older Angular, another framework); screens migrate one by one, and the old code shrinks until it disappears — without a “big bang” rewrite.
- Separate products under one experience: acquisitions, business units, portals that bring together existing applications.
False reasons sound good in presentations, but have cheaper solutions:
| “We want MFE for…” | Why it does not hold | What actually solves it |
|---|---|---|
| scalability / performance | MFE adds bytes, requests and version negotiation at startup | lazy routes, @defer, SSR, optimisations |
| clean, modular code | modularity is a property of the code, not of the deploy | libraries + import rules (boundaries, structure) |
| faster builds | N pipelines mean N places that can break | incremental build and cache (Nx affected) in a monorepo |
| each team choosing its own framework | technological diversity is a permanent cost: a duplicated design system, people hard to move between teams | accept it only when it is unavoidable (migration, acquisition) |
If you cannot name two teams that today block each other at release time, you do not have the problem that micro frontends solve.
The price
Each cost below shows up in practice; the difference between a healthy system and a fragile one is whether you have a plan for it from day one.
| Cost | What concretely happens | How you keep it under control |
|---|---|---|
| Shared versions | a remote built with another Angular cannot use the shell's runtime; major upgrades become coordinated | the same version everywhere, a shared upgrade calendar, a check in CI |
| Runtime integration | a renamed export in a remote breaks the shell in production, not at compile time | exposes treated as a public API (semver), contract tests, a fallback per remote |
| Duplicate bundles | whatever is not shared is downloaded N times; whatever is shared is built separately, so it is no longer tree-shaken per application | a bundle analyser, share only what makes sense |
| Design system | five teams, five versions of buttons | the design system as a shared package + CSS tokens, owned by a platform team |
| Routing | who owns the URL, what happens on a direct deep link into a remote | the shell owns the top-level routes, each remote exports Routes for its prefix |
| Auth and session | the token must reach all remotes, the refresh must happen only once | an HttpOnly cookie or an auth service from a shared library as a singleton (auth refresh) |
| E2E testing | each team is green in isolation, the bug appears in the composition | e2e on “shell + the production versions of the others” |
| Observability | “error in production” — from which remote, which version? | the version of each remote in every log, source maps per remote |
| Local development | you start five servers to work on one | a manifest per environment: the local shell loads the deployed remotes, you run only what you change |
Native Federation step by step
Native Federation implements the Module Federation model on web standards: remotes are ordinary ES modules, linked through an import map. For Angular 22 the package is @angular-architects/native-federation 22.x — the version follows the Angular major.minor — and it stays on the CLI's esbuild builder (@angular/build:application), which it wraps. The project has moved to the github.com/native-federation organisation. The mechanism, step by step:
- Build. Each remote produces ES modules and a
remoteEntry.json: what it exposes (./routes→ the generated file) and what dependencies it shares, withversion,requiredVersion,singleton,strictVersion. - Shell startup.
main.tscallsinitFederation()before Angular: it reads the manifest (name → URL), downloads the metadata of each remote, negotiates the versions and writes the import map (withes-module-shims). - Bootstrap. Only then
import('./bootstrap')starts Angular. That is why the schematic moves the bootstrap into a separate file:@angular/coremust be resolved through the import map, so it has to exist first. - Navigation.
loadRemoteModule('catalog', './routes')is, in essence, a dynamicimport()of a URL on the Catalog team's server. When the remote's code doesimport '@angular/core', the import map sends it to the shell's copy: one Angular, one root injector.
# the adapter's version follows the Angular major.minor (22.2.x for Angular 22.2)
npm i -D @angular-architects/native-federation
ng g @angular-architects/native-federation:init \
--project catalog --port 4201 --type remote
ng g @angular-architects/native-federation:init \
--project shell --port 4200 --type dynamic-host
# What the schematic changes:
# • build/serve → @angular-architects/native-federation:build,
# which delegates to @angular/build:application (esbuild)
# • main.ts → initFederation(...) then import('./bootstrap');
# the old bootstrap ends up in bootstrap.ts
# • generates federation.config.mjs (+ public/federation.manifest.json on dynamic-host)
# • adds es-module-shims to polyfills (import maps with scopes)
# • with --webcomponent: bootstrap as a custom element (createApplication + define)// catalog/federation.config.mjs — one remote, as generated by 22.x
import {
withNativeFederation,
fromPackageJson,
} from '@angular-architects/native-federation/config';
export default withNativeFederation({
name: 'catalog',
exposes: {
// the remote's public API
'./routes': './projects/catalog/src/app/catalog.routes.ts',
},
shared: fromPackageJson({
singleton: true,
strictVersion: true,
requiredVersion: 'auto',
build: 'package',
})
// all of @angular/core, including the secondary entry points → a single copy
.patch(['@angular/core'], { includeSecondaries: { keepAll: true } }),
skip: ['rxjs/ajax', 'rxjs/fetch', 'rxjs/testing', 'rxjs/webSocket'],
features: { denseChunking: true },
});// shell/src/main.ts — runs BEFORE Angular:
// reads the manifest, downloads the remoteEntry.json files, negotiates the versions,
// writes the import map — only then loads bootstrap.ts (and @angular/core)
import { initFederation } from '@angular-architects/native-federation';
initFederation('federation.manifest.json')
.then((nf) => import('./bootstrap').then((m) => m.bootstrap(nf)))
.catch((err) => console.error(err));- For Angular 22, Native Federation v4 comes back into the main package
@angular-architects/native-federation(for Angular 20–21 there was the-v4bridge). - The schematic generates
federation.config.mjswithfromPackageJson(...);shareAllandshareremain available. loadRemoteModuleimported directly from the package is marked@deprecated: use the one from the result ofinitFederation(), as inapp.routes.ts.
// The short form from older tutorials still exists in 22.x,
// but it is marked @deprecated:
import { loadRemoteModule } from '@angular-architects/native-federation';
const route = {
path: 'catalog',
loadChildren: () =>
loadRemoteModule('catalog', './routes').then((m) => m.CATALOG_ROUTES),
};
// The reason, from the d.ts: it depends on a global instance and on the LAST initFederation,
// so it is fragile in tests and with several hosts.
// Recommended: loadRemoteModule from the result of initFederation (see app.routes.ts).The demo below reproduces exactly this flow. The servers are simulated, but each “remote” is a real Angular component, sitting in its own chunk and loaded with a dynamic import() — the same mechanism that loadRemoteModule uses underneath. The catalog and the cart talk only through events on window; the reviews do not talk to anyone.
Shared dependencies and versions
For each package in shared, a remote declares four things: version (what it was built with), requiredVersion (what it accepts; 'auto' = the range from package.json, for example ^22.2.0), singleton (a single instance for everyone) and strictVersion (refuses a version outside the range).
Why Angular must be a singleton. @angular/core is not a library of pure functions: it is the runtime that renders, the injector, change detection. Compiled templates call the runtime instructions of the build they were compiled with, so a component from one copy cannot be rendered by another. Even for an ordinary library, two copies mean two different classes: inject(AuthService) in the remote asks for a different token than the one registered by the shell, and your “singleton” suddenly has two instances.
What happens when the versions do not match depends on the tool:
| Situation (singleton: true) | Native Federation (orchestrator v4) | Module Federation (webpack) |
|---|---|---|
the shared version satisfies requiredVersion | the remote uses the shared copy; the host's version takes priority | uses the singleton |
does not satisfy, strictVersion: true | the remote gets its own copy, in the scopes of the import map | error on load: Unsatisfied version … of shared singleton module … |
does not satisfy, strictVersion: false | uses the shared version anyway + a warning | uses the singleton + console.warn |
For Angular, “its own copy” does not save the situation: the remote would have a different runtime, so its components cannot enter the shell's tree. A remote on another major is integrated only in isolation — as a Web Component with its own bootstrap, or in an iframe. If you prefer to find out at startup, not at navigation, the orchestrator has the option strict.strictExternalCompatibility, which throws an error at initialisation instead of creating scopes.
// Three ways to write `shared` in federation.config.mjs
// 1. the default in 22.x: everything in dependencies from package.json, the same rules
shared: fromPackageJson({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
// 2. the classic style (still exported)
shared: { ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }) },
// 3. explicit, package by package
shared: share({
'@angular/core': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
// has state → singleton
'@ngrx/signals': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
// stateless utility: each remote can have its own copy
'date-fns': { singleton: false },
}),
// requiredVersion: 'auto' → the range from package.json (e.g. "^22.2.0")
// autoShareScope({ level: 'minor' }) → shares only between remotes
// on the same version line (e.g. "ng22.2") The shell (host) provides @angular/core at version 22.2.2, which takes priority in the global scope.
| Remote | built with | requiredVersion | singleton | strictVersion |
|---|---|---|---|---|
| catalog | ||||
| cart | ||||
| reviews |
@angular/core downloaded: 2one extra Angular runtime{
"imports": {
"@angular/core": "https://shell.example/core-22.2.2.js"
},
"scopes": {
"https://cart.example/": {
"@angular/core": "https://cart.example/core-22.1.4.js"
}
}
}- Ideal: the same exact Angular version in the shell and in all remotes.
^gives you tolerance between minors, but AOT-compiled code uses internal runtime instructions — test the combination, do not rely on semver. - The host wins, so on a new minor the shell can go first, as long as the remotes' ranges accept it. A new major requires a synchronised upgrade, or isolating the remote that has fallen behind.
- Singleton + strictVersion for the framework and for everything with state: Angular, router, stores, design system, the auth library. Stateless utilities can stay non-singleton.
autoShareScope()ties sharing to a version line (for exampleng22.2), so you do not accidentally mix incompatible builds.
Module Federation, Web Components, iframes
Module Federation (webpack)
@angular-architects/module-federation has a version for Angular 22, but it is the “classic” variant, on webpack: ng add moves the project from the esbuild builder to @angular-builders/custom-webpack (from Angular 22; up to 21 it used ngx-build-plus), and it explicitly warns you that for new projects the alternative is Native Federation. There is also an rspack builder, marked experimental. Choose it only if you are extending an existing Module Federation system or must share the runtime with remotes already built on webpack.
// Classic Module Federation (webpack) — @angular-architects/module-federation 22.x
const {
shareAll,
withModuleFederationPlugin,
} = require('@angular-architects/module-federation/webpack');
module.exports = withModuleFederationPlugin({
name: 'catalog',
exposes: {
'./routes': './projects/catalog/src/app/catalog.routes.ts',
},
shared: {
...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
},
});Web Components
Each remote starts its own application with createApplication() and defines a tag; the shell just puts it on the page. It works across different Angular majors and across frameworks, because nothing is shared. That is exactly its cost: each bundle brings its own runtime (≈ 33 kB transferred for a simple element, measured on the Angular Elements page), there is no common DI, and communication goes through attributes and events. It combines well with federation: a remote on another major is exposed as a Web Component and still loaded through loadRemoteModule.
// A remote on ANOTHER Angular major (or another framework): integrated in isolation,
// as a Web Component with its own runtime.
//
// remote — federation.config.mjs: exposes: { './Bootstrap': './src/bootstrap.ts' }
// remote — bootstrap.ts:
// createApplication(appConfig).then(({ injector }) =>
// customElements.define('mfe-legacy', createCustomElement(App, { injector })));
//
// shell — a page that loads the bundle (its effect: define), then uses the tag
import { CUSTOM_ELEMENTS_SCHEMA, Component, inject, input, resource } from '@angular/core';
import { NATIVE_FEDERATION } from '../app.config';
@Component({
selector: 'app-legacy-page',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
@if (bundle.hasValue()) {
<mfe-legacy [attr.customer-id]="customerId()"></mfe-legacy>
} @else if (bundle.error()) {
<p role="alert">
The legacy module is not available.
<button (click)="bundle.reload()">Retry</button>
</p>
}
`,
})
export class LegacyPage {
private readonly nf = inject(NATIVE_FEDERATION);
readonly customerId = input.required<string>();
protected readonly bundle = resource({
loader: () => this.nf.loadRemoteModule('legacy', './Bootstrap'),
});
}iframes
The strongest isolation: another document, another JavaScript, another CSS; errors, memory leaks and styles stay inside the frame, and with sandbox you can run code you do not trust. You pay with integration: the height is synchronised through postMessage, a dialog cannot leave the frame, deep links and browser history must be linked by hand, focus and accessibility become complicated, and each frame loads the whole application. Suitable for third-party code, legacy applications you cannot modify, and internal tools.
<!-- The strongest isolation: another document, another JS, another CSS;
errors stay inside the frame -->
<iframe
src="https://billing.exemplu.ro/embed?customer=42"
title="Billing"
sandbox="allow-scripts allow-same-origin allow-forms"
loading="lazy"
style="width: 100%; border: 0"
></iframe>
<!-- The price: the height is communicated through postMessage, a dialog cannot leave
the frame, deep links and browser history are synced by hand,
and each frame loads the whole application. -->Contracts, communication, fallback
The contract of a remote is everything that depends on it from outside: the keys in exposes, what they export, the route parameters they read, the events they emit and the shared dependencies. Treat it as a public API with semver: an incompatible change means a new key (./routes-v2), and the old one stays until the shell migrates.
Communication, in the order it is worth trying:
- The URL — route parameters and query params. Free, deep-linkable, survives a refresh, and the remote does not import anything from another remote.
- Typed events on
window, from a tinycontractspackage (types +emit/listen). Do not forget the one that starts late: it has to ask for the state. - A few small services, shared as singletons — auth, the current user, feature flags — from a library included in
shared.
What to avoid: a common global store (the same NgRx store, or the same service holding all the state, used by every remote). Any change in the shape of the state becomes a coordinated change between teams — exactly the coupling you wanted to remove. Each remote keeps its own state; only events and URLs travel between them (see also state management).
// 1. The URL is the first channel: free, deep-linkable, survives a refresh.
// /catalog/audio?sort=price → the remote reads from the URL, not from a shared store.
// (withComponentInputBinding() goes in the SHELL: the remote's routes
// run in the shell's router)
export class ProductList {
readonly category = input<string>(); // from :category
readonly sort = input<'price' | 'rating'>('rating'); // from ?sort=
}
// 2. Navigation between remotes also goes through the URL:
// the remote does not import anything from another remote.
this.router.navigate(['/cart'], { queryParams: { added: product.id } });Fallback. Any remote can be missing: a failed deploy, a CDN that is down, a mistake in the manifest of an environment. Two situations, two solutions:
- A whole route: let
loadChildrenfail and turn the error into a redirect withwithNavigationErrorHandler. A rejected loader is not cached by the router, so the next navigation retries the remote. - A remote inside a page (a widget, a slot): a shell component loads the remote with
resource, with a timeout, a fallback andreload()for “Retry” — as in the first demo.
// A FAILED remote ROUTE: let loadChildren fail and redirect.
// A rejected loader is NOT cached by the router → the next navigation retries.
import { ApplicationConfig, inject } from '@angular/core';
import {
NavigationError,
RedirectCommand,
Router,
provideRouter,
withNavigationErrorHandler,
} from '@angular/router';
import type { NativeFederationResult } from '@angular-architects/native-federation';
import { routes } from './app.routes';
export const appConfig = (nf: NativeFederationResult): ApplicationConfig => ({
providers: [
provideRouter(
routes(nf),
withNavigationErrorHandler((e: NavigationError) => {
const router = inject(Router);
const target = `/indisponibil?url=${encodeURIComponent(e.url)}`;
return new RedirectCommand(router.parseUrl(target));
}),
),
],
});
// ✗ The trap: a fallback returned from the loader is a SUCCESS for the router.
// It is cached on the route until the page reloads, so "Retry"
// never reaches the remote again:
// loadComponent: () => nf.loadRemoteModule(...).then(...).catch(() => RemoteDown)When YES, when NO
The decision depends on the organisation more than on the code. The score below applies the rules from this page: it is not a magic formula, but it forces you to answer the questions that matter and shows you why a recommendation comes out.
- + 3 teams: boundaries in the code (libraries, import rules) usually suffice
- + a shared release is acceptable
- + from ~4 autonomous teams, autonomy starts to matter
- − without a real need for independent deploys, MFE does not justify its price
- + the same Angular version → @angular/core can be a shared singleton
- − no platform, per-team pipelines or monitoring: the complexity of MFE reaches production
- Several autonomous teams (roughly 4–5 or more) on the same product, with different release cadences, who today block each other.
- Independent deploy is mandatory (contractual, regulatory, different rhythms) → Native Federation with the same Angular version.
- Gradual migration from an old application → a new shell + strangler fig; old screens as Web Components or iframes until they disappear.
- Separate products under the same brand, with clear ownership by business domain.
- You already have the foundation: a platform team for the shell and the design system, CI/CD per team, monitoring by remote version.
- One team or a few teams that can ship together → one application in a monorepo, with lazy routes and boundaries.
- The problem is performance →
@defer, lazy loading, SSR; MFE makes it worse. - The problem is tangled code → libraries, import rules, architecture; MFE only moves the tangle between repositories.
- You just want to reuse components between applications → a published library, or one shared inside the monorepo.
- A widget for foreign pages → a single Web Component (Angular Elements), not an architecture.
- There is no one to own the shell, the contracts and the common upgrades → postpone until there is.
Common pitfalls
- The “shared” library that is not in
shared→ each remote bundles it separately, soprovidedIn: 'root'produces two instances (two classes, two tokens). Include the stateful libraries insharedand checkremoteEntry.json. singleton: falseon@angular/coreor a remote on another major → two Angular runtimes. The same version everywhere; for exceptions, isolation (Web Component, iframe).strictVersion: falseso that it “passes” → the message disappears from the console, the problem remains and shows up in production. Fix the versions, not the flag.- A fallback returned from
loadComponent(.catch(() => RemoteDown)) → the router caches it on the route until a refresh, and “Retry” never reaches the remote. Let the loader fail, or use a host component withreload(). - No timeout → a
remoteEntry.jsonthat does not respond keeps the page in “loading” forever. Put a timeout on every remote load. - One manifest for all environments → staging loads the production remotes (or the other way round). A manifest per environment, generated at deploy.
- A common global store between remotes → lockstep releases, a distributed monolith. Local state + events + URL.
- A remote that imports code directly from another remote → a hidden dependency between deploys. Everything goes through
libs/contractsor through the URL. - Absolute routes written in a remote (
/products/42) → they break when the shell mounts it under another prefix. Navigate relative to the active route. - Global CSS from remotes → one team's styles break another's. No global styles in remotes; CSS tokens from the design system;
ShadowDomfor Web Components. - Errors without context → you do not know which remote, in which version, crashed. Attach the remote's name and version to every reported error (see error handling).
Cheat sheet
// REMOTE — federation.config.mjs
withNativeFederation({
name: 'catalog',
exposes: { './routes': './src/app/catalog.routes.ts' }, // public API, versioned
shared: fromPackageJson({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
});
// SHELL — public/federation.manifest.json (one per environment: dev / staging / prod)
// { "catalog": "https://catalog.exemplu.ro/remoteEntry.json" }
// SHELL — main.ts
initFederation('federation.manifest.json')
.then((nf) => import('./bootstrap').then((m) => m.bootstrap(nf)));
// SHELL — routes
const catalog = {
path: 'catalog',
loadChildren: () =>
nf.loadRemoteModule<{ CATALOG_ROUTES: Routes }>('catalog', './routes')
.then((m) => m.CATALOG_ROUTES),
};
// VERSIONS: the same version of Angular everywhere; the host takes priority.
// incompatible + strictVersion → own copy in scopes (NF)
// "Unsatisfied version …" (webpack MF)
// incompatible, without strictVersion → uses the shared version + warning
// COMMUNICATION: URL → typed events (libs/contracts) → small shared services.
// No common stores.
// FAILURE: route → withNavigationErrorHandler + redirect
// slot → host component with timeout + resource.reload()
// ANOTHER MAJOR / ANOTHER FRAMEWORK → Web Component with its own runtime, or iframe