Micro Frontends with Angular 22.x: Independent, Versioned, and Clean

Micro Frontends with Angular 22.x: Independent, Versioned, and Clean
Photo by Caspar Camille Rubin / Unsplash

Your 14-team Angular monolith has a 45-minute build, a 3-hour release train, and one broken import blocks everyone. I've lived this. After running micro frontends in production with Angular 22, I can tell you the situation has genuinely changed.

Micro frontends have been oversold for years. Half the conference talks showed Webpack hacks that fought the framework at every turn. But Angular 22's defaults (esbuild, zoneless, standalone-everything) make this architecture viable without fighting your toolchain. The framework now wants you to decompose.

This post covers the four pillars that make it work: federation, deployment, versioning, and communication. These aren't theoretical patterns. They're what survived contact with real CI/CD pipelines, real teams, and real production traffic.


Module Federation: Webpack Legacy vs. Native Federation

Webpack Module Federation is the deprecated path in Angular 22. It still works if you opt into the custom Webpack builder, but you're swimming upstream. The esbuild ApplicationBuilder is the default, and every new Angular schematic assumes it. If you're still on Webpack MF, plan your migration now.

Native Federation (@angular-architects/native-federation v22.x) is the answer. Built on browser-native ESM and Import Maps. Works with the esbuild ApplicationBuilder, not against it. No Webpack plugin required.

The architectural difference matters. Native Federation resolves shared dependencies via import maps at runtime, not through bundler magic at build time. This means smaller remotes, faster cold starts, and true browser-native module loading. Your remotes are just ES modules served from a CDN.

Here's what a host configuration looks like side-by-side with a remote:

// host — federation.config.js
const { withNativeFederation, shareAll } = require('@angular-architects/native-federation/config');

module.exports = withNativeFederation({
  name: 'host-shell',
  shared: {
    ...shareAll({
      singleton: true,
      strictVersion: true,
      requiredVersion: 'auto',
    }),
  },
  sharedMappings: ['@mfe/contracts'],
});
// remote — federation.config.js (e.g., mfe-dashboard)
const { withNativeFederation, shareAll } = require('@angular-architects/native-federation/config');

module.exports = withNativeFederation({
  name: 'mfe-dashboard',
  exposes: {
    './routes': './src/app/dashboard/routes.ts',
  },
  shared: {
    ...shareAll({
      singleton: true,
      strictVersion: true,
      requiredVersion: 'auto',
    }),
  },
  sharedMappings: ['@mfe/contracts'],
});

The sharedMappings array is critical. That @mfe/contracts package contains your shared TypeScript interfaces and signal-based service contracts. It's how the host type-checks remote interfaces without bundling them.

TypeScript 6 gotcha: The stricter module resolution in TS 6 means you need explicit paths entries in your root tsconfig.json pointing to the contracts package. Without this, the host compiler can't resolve remote interface types:

{
  "compilerOptions": {
    "paths": {
      "@mfe/contracts": ["./libs/contracts/src/index.ts"],
      "@mfe/contracts/*": ["./libs/contracts/src/*"]
    }
  }
}

Independent Deployment: Ship on Your Own Schedule

Each micro frontend gets its own repository (or monorepo package), its own CI/CD pipeline, and its own CDN path with versioned URLs. This is where you eliminate coordinated releases entirely.

The key is manifest-driven loading. The host shell fetches an mfe-manifest.json at startup that maps route prefixes to remote URLs. When a team ships a new version of their MFE, they update the manifest. The host never needs a rebuild.

{
  "mfe-dashboard": {
    "remoteEntry": "https://cdn.example.com/mfe-dashboard/2.4.1/remoteEntry.json",
    "routePrefix": "/dashboard",
    "version": "2.4.1"
  },
  "mfe-reports": {
    "remoteEntry": "https://cdn.example.com/mfe-reports/1.8.0/remoteEntry.json",
    "routePrefix": "/reports",
    "version": "1.8.0"
  },
  "mfe-settings": {
    "remoteEntry": "https://cdn.example.com/mfe-settings/3.1.2/remoteEntry.json",
    "routePrefix": "/settings",
    "version": "3.1.2"
  }
}

The pipeline itself is straightforward:

# .github/workflows/deploy-mfe-dashboard.yml
name: Deploy MFE Dashboard

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - run: npm ci
      - run: npm run lint
      - run: npm run test -- --no-watch --code-coverage
      - run: npx ng build mfe-dashboard

      - name: Upload to CDN (versioned path)
        run: |
          VERSION=$(node -p "require('./package.json').version")
          aws s3 sync dist/mfe-dashboard s3://mfe-cdn/mfe-dashboard/$VERSION/

      - name: Update manifest registry
        run: |
          VERSION=$(node -p "require('./package.json').version")
          curl -X PATCH "$MANIFEST_API/mfe-dashboard" \
            -H "Authorization: Bearer ${{ secrets.MANIFEST_TOKEN }}" \
            -d "{\"version\": \"$VERSION\"}"

Rollback means updating one line in the manifest to point to the previous version URL. Instant. No redeploy. The old assets are still sitting on the CDN, you just change the pointer.

Angular 22's zoneless default is a genuine win here. With Zone.js gone, there's no polyfill coordination between host and remotes. One fewer shared dependency to worry about, one fewer source of version conflicts.


Versioning: SemVer, Singletons, and Negotiation

Every MFE publishes a SemVer version. The manifest registry tracks latest, stable, and pinned versions per environment. Production pins to stable. Staging auto-promotes latest.

Shared dependency singletons are non-negotiable. Angular core, RxJS, and the Signals runtime must be loaded exactly once. Native Federation's singleton: true, strictVersion: true config enforces this at the import map level. If a remote requests @angular/core ^22.1.0 and the host provides 22.0.0, federation throws at load time. Fail fast, not fail weird.

The version negotiation pattern works like this: the host declares what it provides (with version ranges). Remotes declare what they require. A mismatch means the remote doesn't load and an error boundary activates. This is dramatically better than silent runtime breakage where signals don't propagate or DI fails in mysterious ways.

// In your host's app bootstrap — error boundary for failed remote loads
import { loadRemoteModule } from '@angular-architects/native-federation';

async function loadMfe(config: MfeManifestEntry): Promise<Routes> {
  try {
    const module = await loadRemoteModule({
      remoteEntry: config.remoteEntry,
      exposedModule: './routes',
    });
    return module.routes;
  } catch (error) {
    console.error(`[MFE] Failed to load ${config.routePrefix}:`, error);
    return [{ path: '**', loadComponent: () => import('./mfe-error.component') }];
  }
}

For rollback strategy, I use blue/green manifest entries. The previous version stays deployed on the CDN indefinitely (storage is cheap). Rollback is a manifest pointer change. Zero downtime, zero rebuilds. Your on-call engineer can do it from their phone.


Communication: Events, Signals, and Channels

Rule zero: Micro frontends should communicate rarely. If they talk constantly, your domain boundaries are wrong. Fix the architecture before you optimise the messaging.

With that said, here are the three patterns that work in production:

Custom Events (Framework-Agnostic)

The simplest approach. Works across any framework, any isolation level:

// Emitting (from any MFE)
window.dispatchEvent(
  new CustomEvent('mfe:user-selected', {
    detail: { userId: '42', source: 'mfe-dashboard' },
  })
);

// Listening (in any other MFE)
window.addEventListener('mfe:user-selected', (event: CustomEvent) => {
  const { userId } = event.detail;
  // react to selection
});

Best for loose coupling. No shared runtime required.

Angular Signals via Shared Service

When host and remote share the same Angular runtime (Native Federation singleton mode), you can expose a service with signals. This is the fast path. Zoneless + OnPush means signal changes propagate instantly without zone tricks:

// @mfe/contracts — shared signal service
import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class MfeSessionService {
  readonly currentUser = signal<User | null>(null);
  readonly isAuthenticated = computed(() => this.currentUser() !== null);
  readonly tenant = signal<string>('default');
}

Both host and remotes inject the same singleton instance. Signal changes propagate through the reactive graph. No event buses, no subscriptions to manage, no cleanup.

BroadcastChannel API

For fully isolated MFEs (iframes or different origins), BroadcastChannel provides a clean serialization boundary:

// Sending
const channel = new BroadcastChannel('mfe:notifications');
channel.postMessage({ type: 'alert', payload: { level: 'warning', text: 'Session expiring' } });

// Receiving
const channel = new BroadcastChannel('mfe:notifications');
channel.onmessage = (event) => {
  const { type, payload } = event.data;
  // handle notification
};

The serialization boundary forces clean contracts. You literally can't pass a closure or a mutable reference. That's a feature, not a limitation.

The Hybrid Approach

In production, I use shared signals for same-runtime communication and Custom Events as fallback for cross-framework remotes. One adapter service abstracts the transport:

@Injectable({ providedIn: 'root' })
export class MfeBridgeService {
  private session = inject(MfeSessionService);

  emit<T>(event: string, payload: T): void {
    // Fast path: signal (if available)
    if (event === 'user-selected') {
      this.session.currentUser.set(payload as User);
    }
    // Fallback: Custom Event (cross-framework)
    window.dispatchEvent(new CustomEvent(`mfe:${event}`, { detail: payload }));
  }
}

Anti-pattern callout: Don't use a global Redux/NgRx store across MFEs. That's a distributed monolith with extra steps. If you need shared state that complex, your boundaries are wrong.


The 75% Easier Outcome

Here's what this architecture eliminates:

  • Coordinated releases gone. Each team ships when they're ready.
  • 45-minute monolith builds gone. Each MFE builds in 2-3 minutes.
  • Merge conflicts across teams gone. Separate repos, separate concerns.
  • Risky deployments gone. Rollback is a manifest pointer change.

Native Federation + versioned CDN deployment + manifest-driven loading + signal-based communication = enterprise Angular teams that ship independently without the coordination tax.

Start Small

Don't try to decompose your entire monolith in one sprint. Pick one bounded context, the one team that's always blocked waiting for the release train. Extract it as a remote. Deploy it independently. Prove the pattern works in your CI/CD environment.

Once that first remote ships on its own schedule without breaking anything, the rest of the teams will be lining up to extract theirs. That's how you dismantle a monolith: one successful proof point at a time.

Read more