+1 (726) 227-3745

Micro-Frontends for a Big MEAN App: Angular 22 Native Federation with an Express 5 Shell

Every enterprise MEAN app we inherit eventually hits the same wall: one Angular workspace, 400 components, six teams, and a release train where a typo in the billing module blocks the shipping team's deploy. Micro-frontends are the usual answer, and in Angular 22 the practical way to build them is Native Federation (@angular-architects/native-federation), which does what Webpack Module Federation did but on top of native ES modules and import maps, so it works with the esbuild-based application builder that Angular now uses by default.

This tutorial splits a monolithic Angular app into a shell plus two remotes, keeps a single Express 5 BFF in front of MongoDB, and covers the parts that actually bite in production: shared dependency versions, auth state across remotes, routing, and independent deploys.

Everything below is current as of Angular 22 and Node 24 LTS.

When you should NOT do this

Say this out loud before you start, because it saves clients a lot of money.

  • One team, one release cadence? Stay monolithic. Use lazy-loaded routes and @defer. You get most of the load-time win with none of the operational cost.
  • Fewer than about three independently deploying teams? A monorepo with enforced module boundaries (Nx tags, or eslint-plugin-boundaries) solves the coupling problem without a distributed runtime.
  • You just want a smaller bundle? That is a bundle-budget and deferrable-views problem, not an architecture problem.

Micro-frontends buy independent deployment. They cost you a runtime integration surface, version drift between remotes, and harder end-to-end debugging. Buy them when the deploy coupling is the actual pain.

The target shape

shell/            Angular 22 host: shell routing, nav, auth, layout
mfe-orders/       Angular 22 remote: /orders/**
mfe-billing/      Angular 22 remote: /billing/**
shared-auth/      plain TS library: session signal shared as a singleton
api/              Express 5 BFF on Node 24, Mongoose 8 -> MongoDB 8

The shell owns the URL, the session and the chrome. Remotes own their routes and their own API calls. The BFF stays single, because splitting the API at the same seams as the UI is a separate decision, and usually a later one.

Step 1: scaffold and add Native Federation

ng new shell --style=css --ssr=false --skip-tests
ng new mfe-orders --style=css --ssr=false --skip-tests
ng new mfe-billing --style=css --ssr=false --skip-tests

cd shell       && ng add @angular-architects/native-federation --project shell --type dynamic-host && cd ..
cd mfe-orders  && ng add @angular-architects/native-federation --project mfe-orders --type remote --port 4201 && cd ..
cd mfe-billing && ng add @angular-architects/native-federation --project mfe-billing --type remote --port 4202 && cd ..

The schematic adds a federation.config.js to each project and rewrites main.ts into a two-file bootstrap: main.ts initialises federation, then dynamically imports bootstrap.ts. Do not undo that indirection; it is what lets the import map load before Angular does.

Step 2: what each remote exposes

mfe-orders/federation.config.js:

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

module.exports = withNativeFederation({
  name: 'mfe-orders',

  exposes: {
    './routes': './src/app/orders/orders.routes.ts',
  },

  shared: {
    ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
  },

  skip: [
    'rxjs/ajax',
    'rxjs/testing',
    'rxjs/webSocket',
  ],
});

Two rules prevent most micro-frontend incidents:

  1. Expose routes, not components. A remote that exposes Routes can grow pages without the shell changing. A remote that exposes OrderTableComponent has just published a UI API you now have to version.
  2. singleton: true, strictVersion: true for Angular packages. Two copies of @angular/core on one page produce the infamous NG0203: inject() must be called from an injection context at runtime, in production, only on the page that loads both remotes. Strict version turns a silent duplicate into a loud boot error.

mfe-orders/src/app/orders/orders.routes.ts:

import { Routes } from '@angular/router';

export const ORDERS_ROUTES: Routes = [
  { path: '', loadComponent: () => import('./order-list').then(m => m.OrderList) },
  { path: ':id', loadComponent: () => import('./order-detail').then(m => m.OrderDetail) },
];

export default ORDERS_ROUTES;

Step 3: the shell loads remotes at runtime

The manifest is the seam. In development it is a local file; in production it is served by the BFF, so you can point a remote at a new build without rebuilding the shell.

shell/public/federation.manifest.json:

{
  "mfe-orders": "http://localhost:4201/remoteEntry.json",
  "mfe-billing": "http://localhost:4202/remoteEntry.json"
}

shell/src/main.ts:

import { initFederation } from '@angular-architects/native-federation';

initFederation('/federation.manifest.json')
  .catch(err => console.error('federation init failed', err))
  .then(() => import('./bootstrap'))
  .catch(err => console.error(err));

shell/src/app/app.routes.ts:

import { Routes } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/native-federation';
import { authGuard } from './auth.guard';

export const routes: Routes = [
  { path: '', loadComponent: () => import('./home').then(m => m.Home) },
  {
    path: 'orders',
    canMatch: [authGuard],
    loadChildren: () => loadRemoteModule('mfe-orders', './routes').then(m => m.ORDERS_ROUTES),
  },
  {
    path: 'billing',
    canMatch: [authGuard],
    loadChildren: () => loadRemoteModule('mfe-billing', './routes').then(m => m.BILLING_ROUTES),
  },
];

Note canMatch rather than canActivate: with canMatch the guard runs before the remote is fetched, so an unauthenticated user never downloads the billing bundle.

Fail soft when a remote is down

A remote is a network dependency. Treat it like one:

async function loadRemoteRoutes(remote: string, exposed: string, key: string): Promise<Routes> {
  try {
    const m = await loadRemoteModule(remote, exposed);
    return m[key] as Routes;
  } catch (err) {
    console.error(`[shell] remote ${remote} unavailable`, err);
    const { RemoteUnavailable } = await import('./remote-unavailable');
    return [{ path: '**', component: RemoteUnavailable }];
  }
}

Use that in loadChildren instead of calling loadRemoteModule directly. Without it, one bad CDN deploy turns a section outage into a white screen for the whole app.

Step 4: sharing auth without sharing a mess

The temptation is to let each remote read localStorage and build its own interceptor. Then one team rotates to cookies and nothing works. Publish a tiny shared library instead and let the shell own the state.

shared-auth/src/session.ts:

import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class Session {
  readonly accessToken = signal<string | null>(null);
  readonly user = signal<{ id: string; email: string; roles: string[] } | null>(null);
  readonly isAuthenticated = computed(() => this.user() !== null);

  hasRole(role: string) {
    return (this.user()?.roles ?? []).includes(role);
  }
}

Share it as a singleton in every federation.config.js:

shared: {
  ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
  '@acme/shared-auth': { singleton: true, strictVersion: true, requiredVersion: '^1.0.0' },
},

Because the library is a true singleton, providedIn: 'root' resolves against the shell's root injector, and a remote that calls inject(Session) sees exactly the session the shell logged in. Skip the singleton flag and each remote gets its own Session, both look empty, and you will lose an afternoon to it.

Keep the shared surface minimal: session state, a feature-flag reader, maybe a design-token stylesheet. Shared business logic across remotes recreates the coupling you paid to remove.

Step 5: one Express 5 BFF, many callers

The remotes should not each invent an auth handshake. Keep one BFF that holds the refresh token in an HttpOnly cookie and hands short-lived access tokens to the browser:

import express from 'express';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import { rateLimit } from 'express-rate-limit';

export const app = express();
app.use(helmet());
app.use(express.json());
app.use(cookieParser());
app.use('/api', rateLimit({ windowMs: 60_000, limit: 300, standardHeaders: 'draft-7' }));

// Serve the federation manifest so remote URLs are runtime config, not a build artifact.
app.get('/federation.manifest.json', (req, res) => {
  res.set('Cache-Control', 'no-cache');
  res.json({
    'mfe-orders': process.env.MFE_ORDERS_URL,
    'mfe-billing': process.env.MFE_BILLING_URL,
  });
});

app.post('/api/session/refresh', async (req, res) => {
  const token = req.cookies.rt;
  if (!token) return res.status(401).json({ error: 'no session' });
  res.json({ accessToken: await mintAccessToken(token) });
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(err.status ?? 500).json({ error: 'internal error' });
});

Serving the manifest from the BFF with Cache-Control: no-cache is what makes independent deploys real: shipping a remote becomes a manifest URL change, not a shell rebuild. If the same process also serves the shell's static build, remember the Express 5 wildcard syntax, app.get('/{*splat}', ...); the old app.get('*', ...) throws.

Step 6: deploying without breaking each other

  • Immutable, versioned remote URLs. Deploy each remote to https://cdn.example.com/mfe-orders/<git-sha>/. The manifest points at one sha; rollback is a manifest edit.
  • Long-cache the assets, never the manifest. remoteEntry.json under a sha path and the hashed chunks are immutable; the manifest is no-cache.
  • CORS. Remotes load cross-origin, so the CDN must send Access-Control-Allow-Origin for the shell's origin. Sort this out before you debug a blank page.
  • CSP. Import maps and dynamic imports need script-src to include the CDN origin. Native Federation works with a nonce-based policy, but you have to plumb the nonce; check it early, not in the security review.
  • Version skew. Run a smoke test after every remote deploy that boots the shell against the real manifest and hits every top-level route. It catches Angular version drift, the number one failure mode in this architecture.
  • Upgrade policy. Write it down: all remotes move to the next Angular major within one sprint of the shell. strictVersion: true makes drift fail loudly, which is what you want, but only if someone owns the calendar.

Testing the seams

Unit tests inside a remote are unchanged, Vitest as usual. The interesting tests are the integration ones:

  • A contract test per remote: boot the exposed Routes in isolation and assert the paths it claims to own.
  • A shell smoke test in Playwright against the real manifest: log in, visit /orders and /billing, assert no NG0203 and no duplicate-Angular warning in the console.
  • A degradation test: block the remote origin in Playwright with page.route('**/mfe-orders/**', r => r.abort()) and assert the fallback renders instead of a white screen.

What this buys you

After the split, the orders team deploys to production without a shell release, the billing team can sit one Angular minor behind for a sprint, and a broken remote degrades one route instead of the whole app. That is the entire return. If it does not describe a problem you have today, keep the monolith and spend the budget on performance tuning instead.

If you are staring at a 300k-line Angular workspace and trying to work out whether the real seams are teams, domains or just accident, that assessment is where our enterprise MEAN stack app development and MEAN stack consulting engagements start. Get in touch with a sketch of your workspace and release process and we will tell you honestly whether micro-frontends are the answer.