Most “microfrontends in Angular” tutorials make you pick a side: Webpack-era Module Federation, or the newer esbuild-native approach. angular-microfrontends is a small reference architecture I put together to answer a different question. What does it actually look like to host all of them, in the same shell, at the same time?
Why mix federation strategies?
The honest answer is that real organisations don’t get to pick once. One team is still on a Webpack-era Angular CLI build because their Module Federation config is load-bearing and they can’t migrate this quarter. Another team has already moved to esbuild and Native Federation and isn’t going back. And there’s almost always a third team, usually the one shipping a widget that has to embed inside something non-Angular, who needs a Web Component instead of a federated module.
A host application that can only consume one of those is a host application that quietly forces every team onto the same release calendar. So the interesting design question stops being “which federation strategy wins?” and starts being “can a single shell load all three without that choice leaking into product code?”
The repo is my attempt to answer “yes, it can, and here’s the shape of it.” It’s deliberately small (five Angular apps and an nginx box), but the interesting decisions live in the seams between them.
The shape of the system
Five processes and a proxy:
host-app :4200 Vite + Native Federation (the shell)
mfe1-app :4201 Webpack Module Federation remote
mfe2-app :4202 Webpack Module Federation remote
mfe3-app :4203 Native Federation remote
mfe4-app :4204 Angular Elements / Web Component
nginx-gateway :80 production reverse proxy
The host is the only thing the browser ever talks to in development. Each remote runs on its own port so that you can hack on it in isolation. Change the routing in mfe2-app, restart only that container, and the host picks it back up the next time it resolves the manifest.
Every remote publishes a federation manifest under src/assets/federation.manifest*.json. The host doesn’t hardcode where MFE3 lives; it reads MFE3’s manifest, and the manifest tells it where to find the entry point and what’s exposed. That indirection is what makes “deploy MFE3 independently” mean something concrete: you can move it behind a different URL without rebuilding the shell.
In production, the whole thing collapses behind nginx-gateway on port 80. The browser sees a single origin; nginx fans the requests out to the right container based on the path. More on why that matters in a moment.
How each remote loads
The point of mixing strategies is that from the host’s perspective the contracts look almost identical: a string key resolves to a lazily loaded chunk. The differences are in what the build emits and what the browser fetches.
Module Federation (mfe1, mfe2)
These two are Webpack-era. The remote builds a remoteEntry.js, a tiny JavaScript file that, when evaluated, registers a container the host can pull modules out of. The host loads it with a dynamic import() and asks for the exposed module by name:
const { AppModule } = await loadRemoteModule({
type: 'module',
remoteEntry: 'http://localhost:4201/remoteEntry.js',
exposedModule: './Module',
});
The cost of admission is a shared config on both ends so Angular itself doesn’t get loaded twice. Get that wrong and you’ll get the same error that haunts every Module Federation setup: two copies of @angular/core, two zone.js instances, and dependency injection failing in ways that look like ghosts.
Native Federation (mfe3)
This is the newer, esbuild-friendly approach. Same mental model from the host’s side, but the entry file is a JSON manifest rather than a JavaScript shim:
const { AppComponent } = await loadRemoteModule({
remoteName: 'mfe3',
exposedModule: './Component',
});
The host resolves mfe3 against federation.manifest.json, which points at remoteEntry.json, which describes the chunks. It plays nicely with esbuild and import maps and feels closer to the platform: no Webpack runtime, no plugin chain to maintain. If I were starting fresh today, this is the default I’d reach for.
Angular Elements (mfe4)
This one is the escape hatch. mfe4-app doesn’t expose a federated module at all. It builds a single self-contained bundle that registers a custom element:
const el = createCustomElement(Mfe4Component, { injector });
customElements.define('mfe4-root', el);
The host drops <mfe4-root></mfe4-root> into a template and forgets about it. Nothing is shared between the host and the element: not Angular, not zone, not anything. That’s the point. It’s the same contract any non-Angular host could honour, and it’s what you reach for when the consuming application isn’t Angular at all (or is a different major version of it).
The nginx gateway is doing more than you think
The gateway looks like a footnote in the diagram. It isn’t.
In development everything runs on its own port and the browser talks to each remote directly. That works because dev servers ship with permissive CORS and the host is hardcoded to localhost:420x. In production neither is true. If host-app lives at https://example.com and tries to fetch https://mfe3.example.com/remoteEntry.json, you’re now in CORS territory. Worse, the manifest’s relative URLs to its chunks suddenly resolve against the wrong origin.
The fix isn’t to enable CORS on every remote. The fix is to put everything behind one origin:
/ → host-app
/mfe1-app/* → mfe1
/mfe2-app/* → mfe2
/mfe3-app/* → mfe3
/mfe4-app/* → mfe4
The browser thinks it’s talking to one server. The federation manifests can use relative URLs and they just work. There is no preflight, no Access-Control-* choreography, and the security boundary is whatever the gateway enforces. This is the part most “hello world” microfrontend posts skip, and it’s the part that makes the whole thing deployable.
What this trades away
I don’t want to oversell it. Five Angular bundles is more JavaScript than one Angular bundle, and no amount of shared-deps configuration changes that arithmetic. Version skew between Module Federation’s shared config and Native Federation’s shared config is a real footgun. The two systems don’t understand each other, and “Angular 17 vs Angular 17.0.3” can be enough to break dependency injection at runtime. Debugging means knowing, before you open DevTools, which remote you’re looking at, because the source maps and the network tab will not tell you in a way that’s pleasant.
The dev experience is also five processes. ./scripts/dev/start.sh papers over that, but it’s still five things that can fail independently, and you will eventually find yourself running tail -f logs/mfe2-app.log to figure out why the host’s lazy load 404s.
The win, when it pays for itself, is org-shaped rather than user-shaped. You’re trading a small amount of runtime cost for a large amount of organisational independence. Whether that trade is worth making is a question about your team topology, not your bundle size budget.
When I’d actually reach for this
I’d reach for this pattern when I’m consolidating apps that already exist on different stacks and a rewrite isn’t on the table. I wouldn’t reach for it on a greenfield project where one team owns everything. That’s a monolith pretending to have problems it doesn’t have.
Most of what I learned building this came from Manfred Steyer’s writing at Angular Architects and the mf-nf-poc reference repo. The README has the full acknowledgments.