← Back to blog

Microfrontends in Angular with pnpm, without dying in the attempt

Which options I looked at, why I ended up with pnpm and how to set up a minimal Angular example to see every piece.

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:

  1. Workspaces. One repository with several apps and libraries, each with its own package.json, that can use each other without publishing anything.
  2. 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 of loadRemoteModule.
  • What federation.config.js exposes ('./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.

Found it useful?

If you want to discuss it or have a similar project, get in touch.

Let's talk