A while ago I started a frontend side project of my own, the kind you build for yourself, and I knew I wanted to do it with microfrontends. I wanted to really study that architecture, not just read about it. What I was not sure about was how to set it up without ending up with a tool that did everything for me and that I did not understand.
This article is what I would have liked to read back then: which options I looked at, why I ended up with pnpm and how to set up a minimal Angular example to see every piece.
First, what is a microfrontend?
Think of a shopping centre.
The building provides the entrance, the corridors, the signs and the security. Inside there are shops, and each one has its own team, its own refurbishments and its own shop window. If the shoe shop closes for a week to change the floor, the rest of the centre stays open.
In a frontend with microfrontends:
- The shell is the building: the layout, the menu, the login, the navigation.
- Each microfrontend is a shop: an independent Angular application that is built and deployed on its own.
- The shell does not know how each shop is built inside. It only knows where it is and which door to open.
When is it not worth it? If you are a team of two with a small app, probably never. A microfrontend adds pieces that have to be maintained. It makes sense when parts of the product are going to grow separately.
Nx, pnpm and why I chose the latter
The option that comes up most when you search for "Angular monorepo" is Nx. I looked at it and ruled it out. Not because it is bad, but because it was not what I was after: it needed a lot of configuration and did a lot of things under the hood that I could not see.
I wanted something simple. I wanted to get my hands dirty: see what happens behind the scenes, understand why one app loads another, know where it breaks when it breaks. With Nx I had the feeling there was too much magic.
pnpm gave me exactly the opposite. It is not a framework, it is a package manager like npm, but with two things that matter a lot here:
- Workspaces. One repository with several apps and libraries, each
with its own
package.json, that can use each other without publishing anything. - Dependencies shared on disk. pnpm stores each version of a package only once and puts a link in each project. If three apps use Angular 19, Angular is on your disk once, not three times. Installs are faster and take up much less space.
The piece that makes the shell load the microfrontends in the browser is a different one: Native Federation, by Angular Architects. pnpm organises the repository and Native Federation connects the apps once they are running.
The example: a shell and a shop
We are going to build the bare minimum: a shell and a microfrontend
called mf-catalogo (Spanish for "catalogue") with Angular 19. No real
logic, just enough to see how the pieces fit together.
mi-frontend/
├── pnpm-workspace.yaml
├── package.json
├── apps/
│ ├── shell/
│ └── mf-catalogo/
└── libs/
└── ui/
1. The workspace
At the root, a file tells pnpm where the projects are:
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'libs/*'
And a package.json with the script that will start everything (we
will see it at the end):
{
"name": "mi-frontend",
"private": true,
"scripts": {
"dev": "concurrently \"pnpm --filter mf-catalogo start\" \"pnpm --filter shell start\""
}
}
pnpm add -D -w concurrently
The -w installs the package at the root of the workspace, not in an
app.
2. Creating the two apps
mkdir apps libs && cd apps
pnpm dlx @angular/cli@19 new shell --package-manager pnpm --skip-git --skip-install --style css --ssr false --defaults
pnpm dlx @angular/cli@19 new mf-catalogo --package-manager pnpm --skip-git --skip-install --style css --ssr false --defaults
cd .. && pnpm install
Note the --package-manager pnpm. If you leave it out, Angular CLI uses
npm by default, and the day you run an ng add it will create a
package-lock.json and an npm node_modules folder inside the app.
Mixing two package managers is asking for trouble.
A single pnpm install at the root installs both apps. You will see a
warning about ignored install scripts (esbuild and a few others):
since version 10, pnpm does not run them unless you allow it. For this
example there is nothing to change.
3. Turning them into a shell and a microfrontend
Native Federation comes with a schematic that does the initial work. In each app, we first install the package and then add it:
cd apps/mf-catalogo
pnpm add @angular-architects/native-federation@^19
pnpm ng add @angular-architects/native-federation --project mf-catalogo --port 4201 --type remote
cd ../shell
pnpm add @angular-architects/native-federation@^19
pnpm ng add @angular-architects/native-federation --project shell --port 4200 --type dynamic-host
Why the pnpm add first? Because with the second app, ng add sees
that the package is already downloaded and does not write it to its
package.json. With npm it would not matter, but pnpm is strict:
each package only sees the dependencies it declares. If it is not in
its package.json, it does not exist for that app.
This command also changes each app's angular.json: it switches the
builders and adds new files (federation.config.js, bootstrap.ts).
It was one of the parts I struggled with most. If something does not
start, open angular.json and check that the builder is the federation
one:
"build": {
"builder": "@angular-architects/native-federation:build"
}
4. A shared library
libs/ui holds the components every app uses: buttons, tables, the
layout. In the example, just a title:
// libs/ui/src/titulo.component.ts
@Component({
selector: 'ui-titulo',
template: `<h1>{{ texto() }}</h1>`,
})
export class TituloComponent {
texto = input.required<string>();
}
Its package.json:
{
"name": "@mi-frontend/ui",
"private": true,
"main": "src/index.ts",
"peerDependencies": { "@angular/core": "^19.2.0" },
"devDependencies": { "@angular/core": "^19.2.0", "tslib": "^2.3.0" }
}
pnpm's rule again: the library imports @angular/core, so it has to
declare it. Otherwise it fails with a Cannot find module '@angular/core' that is pretty confusing the first time. And no, you
do not end up with Angular duplicated: pnpm links the library and the
apps to the same copy on disk.
For the microfrontend to use it:
cd apps/mf-catalogo
pnpm add @mi-frontend/ui@workspace:*
cd ../.. && pnpm install
workspace:* means "do not look for it on npm, use the one right next
to you". The final pnpm install at the root installs the library's
own dependencies.
One detail is missing. The library is not built separately: each app
builds it together with its own code. For that, TypeScript needs to
know where the source code is. In the app's tsconfig.json:
"compilerOptions": {
"paths": {
"@mi-frontend/ui": ["../../libs/ui/src/index.ts"]
}
}
5. The shop decides what it shows
Each microfrontend has a federation.config.js where it decides what it
exposes to the outside. It is its shop window:
// apps/mf-catalogo/federation.config.js
const { withNativeFederation, shareAll } = require('@angular-architects/native-federation/config');
module.exports = withNativeFederation({
name: 'mf-catalogo',
exposes: {
'./routes': './src/app/catalogo.routes.ts',
},
shared: {
...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
},
skip: ['rxjs/ajax', 'rxjs/fetch', 'rxjs/testing', 'rxjs/webSocket', '@mi-frontend/ui'],
});
What it exposes are its routes:
// apps/mf-catalogo/src/app/catalogo.routes.ts
export const CATALOGO_ROUTES: Routes = [
{ path: '', component: ListaProductosComponent },
{ path: ':id', component: DetalleProductoComponent },
];
Notice that the routes start at '', not at 'catalogo'. The shop
does not know which corridor the shopping centre will put it in.
That file has to be part of the build. Otherwise the build fails with
File 'src/app/catalogo.routes.ts' is missing from the TypeScript compilation. You add it in tsconfig.app.json:
"files": [
"src/main.ts",
"src/app/catalogo.routes.ts"
]
shareAll with singleton: true is the other half of "sharing
dependencies". pnpm shares on disk and this shares in the browser:
Angular is downloaded and started only once even if there are several
apps. @mi-frontend/ui goes in skip so it is not bundled separately
as a shared dependency, because each app already builds it with its own
code.
6. The building knows where each shop is
The shell reads a manifest, a JSON file with the address of each microfrontend:
// apps/shell/public/federation.manifest.json
{
"mf-catalogo": "http://localhost:4201/remoteEntry.json"
}
The shell's main.ts was already set up by ng add:
// apps/shell/src/main.ts
import { initFederation } from '@angular-architects/native-federation';
initFederation('federation.manifest.json')
.catch(err => console.error(err))
.then(_ => import('./bootstrap'))
.catch(err => console.error(err));
First it finds out where everything is and then it starts. That is why
the actual Angular bootstrap lives in another file, bootstrap.ts.
7. Opening the door
In the shell's routes we decide which corridor the shop goes in:
// apps/shell/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/native-federation';
export const routes: Routes = [
{
path: 'catalogo',
loadChildren: () =>
loadRemoteModule('mf-catalogo', './routes').then(m => m.CATALOGO_ROUTES),
},
];
When the user goes to /catalogo, the shell looks up mf-catalogo in
the manifest, downloads its ./routes and mounts them under
catalogo. /catalogo/42 ends up in the microfrontend's
DetalleProductoComponent.
8. Starting everything with one command
pnpm dev
pnpm --filter runs a script in just one package of the workspace, and
concurrently starts both at the same time. Open
http://localhost:4200/catalogo and the shopping centre is open.
Where I stumbled
The child routes did not load. I do not remember the exact details, but the problem was in the paths between the shell and the microfrontends: the shell could not load the children. If it happens to you, there are three names that have to match, and it is worth checking them one by one:
- The manifest key (
"mf-catalogo") and the first argument ofloadRemoteModule. - What
federation.config.jsexposes ('./routes') and the second argument. - The name of the exported constant (
CATALOGO_ROUTES).
And the child routes have to be relative. If the child uses
path: 'catalogo' and so does the shell, you end up at
/catalogo/catalogo.
A tip: if the microfrontend is not running, the shell says so clearly
in the console: unknown remote mf-catalogo. Before checking routes,
make sure http://localhost:4201/remoteEntry.json responds.
The URLs were hardcoded. At first the addresses of the
microfrontends were written in the shell's configuration. Native
Federation resolves them at build time and leaves them inside the
bundle, so the built shell only knew how to look for the shops on
localhost. To have a test environment for each pull request, or for
production, you would have to rebuild the shell for each one. The
solution was the manifest from step 6: a JSON file read at startup. To
switch environments, you change that file and that is it.
Not everything can be shared. A PDF library, pdfmake, was
imported through subpaths (pdfmake/build/pdfmake). Native Federation
only registers the exact package name, so when it was shared the
subpaths could not be found in the browser. The fix is to take it out of
sharing, since only one microfrontend used it:
skip: ['pdfmake'],
What I take away
- Before choosing a tool, decide how much magic you are willing to accept. I preferred to see the wires.
- pnpm is strict: each package only sees what it declares. It is annoying at first, but it forces you to know what each thing depends on.
- pnpm shares dependencies on disk and Native Federation shares them in the browser. They are two different things and it is worth keeping them apart.
- Keep the microfrontend addresses out of the code. A manifest saves you from rebuilding.
- When something does not load, the problem is almost always a name that does not match between two files.