All articles
·
micro-frontends angular native-federation design-systems design-tokens architecture ci-cd

Micro-Frontends in Anger: A Field Guide to Angular Native Federation

Hard-won lessons from building a production micro-frontend platform — federation, dependency sharing, a token-driven design system, pipeline orchestration, and the traps to avoid.

This is a practitioner’s retrospective, not a primer. It won’t explain what a micro-frontend is — it’s about what actually bit us on a production platform, and what I’d tell my past self. One project, unnamed, generalized into principles.

Note

TL;DR — Micro-frontends are an organizational decision (§1). A thin host shell owns the frame; remotes load at runtime (§2) over Native Federation (§3). Everything hinges on injection-token identity — share framework packages on version ranges, never exact pins, or DI breaks silently (§4–§6). One shared UI library plus design tokens shipped as runtime CSS variables keep the look consistent and let designers reskin through an auto-PR with no rebuild (§7–§10). The rest is polyrepo hygiene: uniform pipelines, host-owned security headers, one-time cross-cutting setup (§11–§13).

1. Why micro-frontends at all

In one line: an organizational choice, not a technical one — adopt it for independent deploys, not smaller bundles.

Micro-frontends are an organizational solution before they are a technical one. Reach for them when independent teams need independent release cadences — not because the bundle is big.

Reach for them when…Don’t, if the real reason is…
Independent teams need independent deploy cadence”It’s modern”
You’re incrementally migrating a legacy monolithYou just want code-splitting (that’s lazy loading)
Teams need genuine release autonomyYou’re dodging a module-boundaries conversation

Important

The cost you’re signing up for: runtime integration, shared-dependency management, and a contract surface between apps that a monolith gives you for free at compile time.

A micro-frontend split is a distributed-systems problem wearing a frontend costume.

2. The shape — a host shell and its remotes

In one line: a thin host shell owns the frame and the shared singletons; remotes are independent apps loaded at runtime.

  • A thin host shell owns the frame: authentication, navigation/chrome, routing to remotes, and the shared runtime singletons. It has (almost) no feature code of its own.
  • Remotes are independent apps, each owning one bounded feature area, loaded at runtime.
  • Polyrepo, not a monorepo: each app is its own repository, pipeline, and deploy — autonomy at the cost of coordinated changes (see §5 and §12).

Everything runs in one browser runtime — a single JavaScript context and, critically, a single dependency-injection graph. The host provides the shared instances (auth, i18n, observability) through a small contract of injection tokens; each remote consumes them rather than creating its own. At page load the host reads a manifest and pulls in remotes over the network, so a remote can ship on its own schedule without the host knowing anything but its URL. Design tokens are the one thing that doesn’t flow through this graph: they arrive as CSS variables from a separate pipeline that never touches an application build (§9–§10).

3. Choosing the federation runtime

In one line: pick Native Federation — build-tool-agnostic, and it survives framework build churn.

CriterionNative FederationWebpack Module FederationMonorepo orchestrator
Build-tool couplingAgnostic (esbuild/Vite)Tied to WebpackTied to the monorepo tool
Independent repos & deploys✓ First-class✓ Possible✗ Fights the model
Survives framework build churn✓At risk✓
Our pick✓ Would pick again——
  • Native Federation over Webpack Module Federation: build-tool-agnostic, esbuild/Vite-friendly, and it survives framework build-system churn.
  • Don’t adopt a monorepo build orchestrator if the whole point is independent repos and deploy cadences — you’ll spend your time fighting it to keep the repos independent.

Tip

The federation layer is infrastructure — treat its version like a database driver. Keep the federation runtime package current (so the dev build actually terminates), and pin the framework and toolchain versions together.

4. The shared-singleton contract — the crux

In one line: everything rests on injection-token identity — host and remotes must share one token that resolves to one instance.

Everything else in this article is detail. This is the load-bearing wall.

At runtime, the platform is several apps sharing one JavaScript context — and, crucially, one dependency-injection graph. The whole promise is that the host hands a remote something ready-made (an authenticated HTTP context, a translation service, animation providers) and the remote just injects it. That rests entirely on injection-token identity: host and remote must hold the same token, resolving to the same instance. Get it right and the platform behaves like one app deployed in pieces; get it wrong and it fails in ways that cost a day to trace.

A few things must be singletons across the host and every remote:

  • the authentication context
  • the animation and rendering providers
  • the translation root
  • the observability initialisation
  • any locale data registered into the framework

The clean guarantee: put those tokens in a dedicated contract package — one that defines the tokens and nothing else. The host provides the real implementations; remotes only ever consume them.

// @org/mfe-contracts — defines the token and its shape, nothing else.
// Note: NO `providedIn: 'root'`. The host provides the instance; remotes only inject.
export interface AuthContext {
  getToken(): Promise<string>
  // ...
}
export const AUTH_CONTEXT = new InjectionToken<AuthContext>('AUTH_CONTEXT')

// Host — the ONLY place the instance is created.
bootstrapApplication(App, { providers: [provideAuth(/* ... */)] }) // registers AUTH_CONTEXT

// Remote — consumes, never provides.
export class OrdersService {
  private readonly auth = inject(AUTH_CONTEXT) // the host's instance, via federation
}

Caution

The ghost bug. If the host and a remote each end up with their own copy of the module that defines a token, you get two token objects that merely share a name. The host registers its interceptor against its token; the remote injects the other one and gets nothing. HTTP calls go out without their auth header, the auth context reads as undefined even though the user is plainly logged in, and nothing throws. Two objects that were supposed to be one simply never met. It’s almost always a dependency-sharing problem in disguise.

flowchart LR
  subgraph ok["✓ Shared correctly — one module, one token"]
    direction TB
    h1["Host"] --> t1(["AUTH_CONTEXT"])
    a1["Remote"] --> t1
    t1 --> good["one auth context<br/>interceptor fires"]
  end
  subgraph bad["✗ Duplicated module — two tokens, same name"]
    direction TB
    h2["Host"] --> t2(["AUTH_CONTEXT (copy 1)"])
    a2["Remote"] --> t3(["AUTH_CONTEXT (copy 2)"])
    t2 --> b1["host registers its interceptor here"]
    t3 --> b2["remote injects this → undefined<br/>requests go out unauthenticated"]
  end

  classDef good fill:#10281c,stroke:#22c55e,color:#eef2f7,stroke-width:1.5px;
  classDef bad fill:#2a1117,stroke:#f87171,color:#eef2f7,stroke-width:1.5px;
  class h1,a1,t1,good good;
  class h2,a2,t2,t3,b1,b2 bad;

Same name, two objects. Nothing throws — the remote just quietly gets a token the host never configured.

The rule that prevents it is a two-tier sharing strategy:

Tier 1 — framework packagesTier 2 — everything else
Examplescore, common, http, router, forms, rxjs-interopUI-lib vendor deps, utilities, the observability SDK
Version rangePermissive range (e.g. ^22)Loose / auto via share-all
Major mismatchStrict — fails loudly at loadHost’s copy wins, with a warning
WhyPatch/minor drift never duplicates; a real major mismatch fails loudly instead of splitting token identityConvenience without duplication risk
// federation.config.js (identical in the host and every remote)
const FRAMEWORK_SINGLETON = {
  singleton: true,
  strictVersion: true, // a genuine MAJOR mismatch fails loudly at load...
  requiredVersion: '^22.0.0' // ...but any 22.x satisfies — patch/minor drift never duplicates
}

module.exports = withNativeFederation({
  shared: {
    // Tier 2 — everything else: loose, host's copy wins with a warning
    ...shareAll({ singleton: true, strictVersion: false, requiredVersion: 'auto' }),

    // Tier 1 — framework packages, spread AFTER shareAll so these override it
    '@angular/core': FRAMEWORK_SINGLETON,
    '@angular/common': FRAMEWORK_SINGLETON,
    '@angular/common/http': FRAMEWORK_SINGLETON, // secondary entry points must be listed explicitly
    '@angular/router': FRAMEWORK_SINGLETON,
    // ...forms, animations, platform-browser, rxjs-interop

    // The shared contract library — one instance across all apps
    '@org/mfe-contracts': { singleton: true, strictVersion: false, requiredVersion: '^1.0.0' }
  }
})

Don’t pin framework packages to the exact installed version (or requiredVersion: 'auto', which resolves to it) — it reads as the “safe” choice but guarantees duplication. The day the host is on 22.1.3 and a remote on 22.1.4, both load and token identity splits. A range plus a strict-major check is strict where it counts, forgiving where it must be.

Two smaller disciplines round it out:

  • Never mark contract tokens as globally self-provided (providedIn: 'root' and its equivalents) — that quietly reintroduces per-app instances and defeats the “host owns it” model.
  • Write these rules down before the second remote exists. The first remote works by accident (there’s only one of everything); the rules only start paying rent when the second one shows up.

5. Dependency distribution

In one line: shared libs are devDependencies (externalized at runtime); framework packages share on version ranges, never exact pins.

DependencyDeclared asAt runtimeVersion policy
Contract package, UI librarydevDependency (in remotes)Externalized — host providesPinned, bumped deliberately
Third-party vendor deps the UI lib needspeerDependencyOne shared instanceEach app provides; shared
Framework packagesdependencyShared singletonShared major; range + strict-major
App-owned depsdependencyBundled per appFloat freely

Declaring shared libraries as devDependencies in remotes keeps them out of the remote’s bundle (the host provides the real instance at runtime) while still giving developers types and a runnable standalone app.

Version drift is the recurring tax, and the module graph is shared state. On our platform, a single new dependency entering the graph forced a dev-server cache clear across apps because the optimizer keyed on a stale hash. Treat cache invalidation as a first-class concern, and document the version policy explicitly: framework packages move on a shared major, app-owned deps float, the contract/UI library is pinned and bumped deliberately.

6. Traps to avoid — the greatest hits

In one line: one root cause — a shared runtime, not an isolated one — wearing six different disguises.

TrapWhat happensFix
Exact-version sharing of framework packagesDuplicate modules → broken DI identityRanges + strict-major
Assuming CSS is isolated between remotesIt isn’t — federation shares a runtime, not a shadow boundary; global CSS leaksHost owns global styles; remotes never inject a global theme via encapsulation escape hatches
Reactive-interop primitives that assume one injectorThe shared chunk splits an internal context and the helper breaksKnow which ones; prefer primitives that don’t reach for ambient injection
Contract tokens marked globally-providedReintroduces duplicate per-app instancesNever providedIn: 'root' on contract tokens
A manifest pointing at an insecure originAn untrusted remote gets loadedAdd a pipeline gate that rejects it
Silent fallbacks in the integration layerA failed remote vanishes invisiblyDegrade visibly, never to a blank screen

7. The design-system layer — one UI library

In one line: one shared UI library is the only source of components; feature code never imports vendor internals.

Consistency across independently-deployed apps is a design-system problem, not a per-app one.

  • A single shared UI library is the only sanctioned source of components. Feature code never imports the underlying vendor components directly — the library re-exports primitives, icons, providers, and types so there’s exactly one seam.
  • The library ships a pre-compiled component theme (the vendor components themed once, centrally) rather than each app theming vendor internals. This is what keeps a button in one remote identical to a button in another.
  • A hard “import only from the library” rule is the difference between one upgrade and N uncoordinated ones.

8. Token-based styling

In one line: tokens are the contract for look — authored in DTCG, shipped as runtime CSS variables that inherit across the federation boundary.

Tokens are the contract for look, the way an injection token is the contract for behavior.

  • Author design tokens in a standard format (DTCG), transform them with a token pipeline (e.g. Style Dictionary) into CSS custom properties delivered at runtime — plus SCSS variables for the library’s own styles and a set of token-backed utility classes for app markup.
  • A bridge layer maps your semantic tokens onto the vendor theme’s own variables at :root, so vendor components pick up your palette automatically and their derived states recompute correctly.
  • Prefer semantic tokens over primitives; never hardcode hex (allow it only as a fallback to a token); keep layout in utility classes and values in tokens.

Note

Because they’re CSS variables, tokens inherit across the federation boundary even when component styles can’t. That single property is what makes retheming a subtree — or the whole platform — a matter of re-pointing a variable.

// design tokens authored as DTCG JSON — primitives, then semantics that reference them
{
  "color": {
    "purple": { "500": { "$type": "color", "$value": "#7000bd" } },
    "foreground": {
      "primary": { "default": { "$type": "color", "$value": "{color.purple.500}" } }
    }
  }
}
/* generated tokens.css — one <link>, loaded once at runtime; CSS variables inherit everywhere,
   including across the federation boundary that blocks component styles */
:root {
  --ds-color-purple-500: #7000bd; /* primitive  */
  --ds-foreground-primary-default: var(--ds-color-purple-500); /* semantic → primitive */
}

/* bridge.css — map semantic tokens onto the vendor theme's own variables so vendor components
   pick up the palette automatically. MUST be :root: the vendor derives hover/active states with
   relative-color formulas that only resolve against :root, not a component host. */
:root {
  --vendor-color-primary: var(--ds-foreground-primary-default);
}

9. Figma → repo: designers change the theme without touching the code

In one line: with the pipeline in place, a designer reskins the whole platform through an auto-PR — no developer, no build.

This is the payoff for doing tokens properly: a designer can restyle every app, every remote, and the vendor components with no developer, build, or merge conflict. The theme stops being something engineering implements and becomes something design ships.

It works because the token layer splits cleanly in two, with different owners:

  • Figma owns the values — what “primary” actually is this quarter, the spacing scale, the corner radii, the elevation. Those are data, and design is the authority on them.
  • The repository owns the structure — the token names and paths (the generated CSS variable names components reference). Those are an API, and engineering is the authority on them.

Keeping that line sharp is the whole trick: designers change data all day without touching the API. The sync itself is deliberately unglamorous — a designer edits tokens in Figma (Tokens Studio Pro, for us) and presses push; the plugin writes the changes to the repo’s DTCG JSON and opens a pull request on its own. On merge, the token pipeline (§10) transforms them into CSS custom properties plus the vendor bridge and deploys straight to the shared static root — and not a single app is rebuilt. A rebrand is a CSS-variable swap, not a release. The only path that ever touches code is a rename.

It’s safe rather than scary for three reasons:

  • A PR, not a push — reviewable, schedulable, revertable; it never lands unannounced in a working branch.
  • Non-breaking by construction — value changes and new tokens alter data, not names, so nothing rebuilds.
  • Decoupled in time — design moves at design speed, code at engineering speed; neither blocks the other.

Renames and removals are the one thing that breaks code. Renaming a token changes the generated CSS variable name, and any component still pointing at the old name fails silently — no compile error, no console warning, just an element that quietly renders unstyled. Review rule: value-only changes and new tokens merge freely, but a rename or removal must grep the codebase for the old variable and update every reference in the same PR. A CI check that flags any token path that disappeared between base and branch turns that habit into an enforced gate.

// ✓ value change — safe: the value changes, the name (→ CSS variable) stays the same
   "purple": { "500": { "$type": "color",
-    "$value": "#7000bd"
+    "$value": "#6a00b0"
   } }

// ✗ rename — "color.purple.500" → "color.brand.500"
//   renames the generated variable: --ds-color-purple-500 → --ds-color-brand-500
//   every component still on the old name renders unstyled, silently.
//   → grep the old variable across the repo and update it IN THE SAME PR.

When your design tokens are CSS variables shipped by their own pipeline, a rebrand stops being a project and becomes a pull request from a designer.

10. Decoupling the token pipeline from the app build

In one line: tokens deploy on their own pipeline, never through an app build — a color change ships without rebuilding anything.

Design tokens change more often than code and are owned by design, not by any single app. So the token artifacts (tokens.css, the bridge, fonts) are deployed by their own pipeline, straight to the shared static-hosting root — independent of any application build. A color tweak ships without rebuilding a single app.

flowchart TB
  subgraph ap["App pipeline — one per repo"]
    c1["App code change"] --> b1["build · test · lint"] --> d1["deploy app bundle"]
  end
  subgraph tp["Token pipeline — design-owned"]
    c2["Token change (merged PR)"] --> b2["Style Dictionary transform"] --> d2["deploy tokens.css + bridge"]
  end
  d1 --> root[("Shared static root")]
  d2 --> root
  root --> rtime["Host + remotes at runtime"]

  classDef accent fill:#20204a,stroke:#8d99f9,color:#eef2f7,stroke-width:1.5px;
  class root,rtime accent;

Two pipelines, one destination, never crossing. A token change never triggers an app build — and an app deploy must never overwrite the token files. One owner per artifact.

Caution

If the app build also copies the token files, every app deploy silently overwrites the latest tokens with a pinned older copy. Pick one owner per artifact. (We ran the app-copy path as interim scaffolding and documented the exact cleanup for when the token pipeline went live.)

11. Pipeline orchestration & deployments

In one line: N repos means N pipelines — keep them boringly uniform, and let the host own the security headers.

Polyrepo means N pipelines; make them boringly consistent.

  • Per-repo CI/CD: each app builds, tests, lints, and deploys itself. Shared templates keep the pipelines uniform without coupling them.
  • Environment-specific manifests: the host resolves which remote URLs to load per environment (prod regions, staging, local dev).
  • Static Web App hosting, with the host owning security headers — Content-Security-Policy (script-src/connect-src list each remote origin) and framing protections you never weaken.
  • Deployment independence: a remote can deploy without the host; the host discovers remotes at runtime via the manifest. A temporarily-unavailable remote must surface a graceful error, not a blank screen.

Important

The host’s CSP must list every remote origin under script-src/connect-src, and a CI gate must reject insecure origins in production manifests. These are the two security controls the whole runtime-integration model leans on.

12. Cross-cutting concerns across remotes

In one line: auth, i18n, and observability initialize once in the host; remotes consume them, never re-init.

Some things must feel seamless even though the code is split:

ConcernHost ownsRemotes
AuthOne login, one token sourceAssume they’re already authenticated (host’s guard handled it)
i18nThe single translation rootContribute namespaced keys; load once per locale (dedupe the fetch, or you double-load on every language switch)
ObservabilityInitialized onceTag themselves, never re-init

For i18n specifically, a route resolver that awaits the load prevents the first-render flicker.

13. Team workflow — standalone vs integrated

In one line: run remotes standalone day-to-day; reserve the full federated setup for debugging the integration.

Dual-mode development is half of why teams adopt micro-frontends.

ModeWhen it’s usedWhat runs
StandaloneThe daily driverThe remote app alone — full speed, no host clone
Integrated (federated)Debugging the integration itselfHost + remote together — DI identity, cross-remote nav, the full shell

Getting this dual-mode right is what makes the architecture feel light day-to-day despite the runtime complexity.

14. Retrospective — what worked, what I’d change

  • Worked: the contract package for DI identity; one UI library + one compiled theme; tokens as runtime CSS variables; the Figma→PR token flow that let design reskin without engineering; the two-tier sharing rule; standalone dev mode.
  • Cost more than expected: version-drift management, dev-server cache invalidation, and educating everyone on the “CSS isn’t isolated” reality.
  • Would do earlier: decouple the token pipeline from app builds from day one; write down the shared-singleton rules as a checked-in doc before the second remote exists.
  • Still open: dark-mode token delivery without a flash on first paint, and how the shared-singleton contract scales as the number of remotes grows past a handful.

15. A checklist to steal

  • One contract package owns the shared injection tokens; remotes only consume them.
  • Framework packages shared with version ranges + strict-major, never exact-pinned.
  • Shared libs are devDependencies in remotes (externalized at runtime); vendor libs are peers.
  • Host owns global CSS, auth, i18n root, observability init, animation providers.
  • UI comes from one library; feature code never imports vendor components directly.
  • Tokens authored in DTCG → runtime CSS variables + a vendor bridge at :root.
  • Designers author token values in Figma; the plugin opens a PR — value changes/additions merge freely, renames grep-and-update the codebase in the same PR.
  • Token artifacts deploy on their own pipeline, not through app builds.
  • Per-repo pipelines from shared templates; CI gate rejects insecure manifest origins.
  • Host CSP lists every remote origin; framing protections stay on.
  • Remotes fail visibly; no silent blank screens.
  • Standalone dev mode is the daily driver; integrated mode is for integration bugs.

Written from experience on a production platform; all examples generalized and anonymized.