← Volver al blog

Microfrontends en Angular con pnpm, sin morir en el intento

Qué opciones miré, por qué acabé con pnpm y cómo montar un ejemplo mínimo con Angular para ver todas las piezas.

Hace un tiempo empecé un proyecto frontend propio, de esos que montas para ti, y tenía claro que quería hacerlo con microfrontends. Quería estudiar esa arquitectura de verdad, no leer sobre ella. Lo que no tenía claro era cómo montarla sin acabar con una herramienta que lo hiciera todo por mí y que yo no entendiera.

Este artículo es lo que me habría gustado leer entonces: qué opciones miré, por qué acabé con pnpm y cómo montar un ejemplo mínimo con Angular para ver todas las piezas.

Primero, ¿qué es un microfrontend?

Piensa en un centro comercial.

El edificio pone la entrada, los pasillos, los carteles y la seguridad. Dentro hay tiendas, y cada una tiene su propio equipo, sus reformas y su escaparate. Si la zapatería cierra una semana para cambiar el suelo, el resto del centro sigue abierto.

En un frontend con microfrontends:

  • El shell es el edificio: el layout, el menú, el login, la navegación.
  • Cada microfrontend es una tienda: una aplicación Angular independiente que se compila y se despliega por su cuenta.
  • El shell no sabe cómo está hecha cada tienda por dentro. Solo sabe dónde está y qué puerta tiene que abrir.

¿Cuándo no merece la pena? Si sois dos personas con una app pequeña, probablemente nunca. Un microfrontend añade piezas que hay que mantener. Tiene sentido cuando hay partes del producto que van a crecer por separado.

Nx, pnpm y por qué elegí lo segundo

La opción que más aparece cuando buscas "monorepo Angular" es Nx. Lo miré y lo descarté. No porque sea malo, sino porque no era lo que yo buscaba: necesitaba mucha configuración y hacía muchas cosas por debajo que yo no veía.

Yo quería algo sencillo. Quería tocar barro: ver qué pasa por detrás, entender por qué una app carga a otra, saber dónde se rompe cuando se rompe. Con Nx tenía la sensación de que había demasiada magia.

pnpm me daba justo lo contrario. No es un framework, es un gestor de paquetes como npm, pero con dos cosas que aquí importan mucho:

  1. Workspaces. Un repositorio con varias apps y librerías, cada una con su package.json, que se pueden usar entre ellas sin publicar nada.
  2. Dependencias compartidas en disco. pnpm guarda cada versión de un paquete una sola vez y en cada proyecto pone un enlace. Si tres apps usan Angular 19, Angular está una vez en tu disco, no tres. Las instalaciones son más rápidas y ocupan mucho menos.

La pieza que hace que el shell cargue los microfrontends en el navegador es otra: Native Federation, de Angular Architects. pnpm organiza el repositorio y Native Federation conecta las apps cuando ya están corriendo.

El ejemplo: un shell y una tienda

Vamos a montar lo mínimo: un shell y un microfrontend mf-catalogo con Angular 19. Nada de lógica real, solo lo justo para ver cómo encajan.

mi-frontend/
├── pnpm-workspace.yaml
├── package.json
├── apps/
│   ├── shell/
│   └── mf-catalogo/
└── libs/
    └── ui/

1. El workspace

En la raíz, un fichero le dice a pnpm dónde están los proyectos:

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'libs/*'

Y un package.json con el script que levantará todo (lo vemos al final):

{
  "name": "mi-frontend",
  "private": true,
  "scripts": {
    "dev": "concurrently \"pnpm --filter mf-catalogo start\" \"pnpm --filter shell start\""
  }
}
pnpm add -D -w concurrently

El -w instala el paquete en la raíz del workspace, no en una app.

2. Crear las dos 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

Fíjate en --package-manager pnpm. Si no lo pones, Angular CLI usa npm por defecto, y el día que hagas un ng add te creará un package-lock.json y una carpeta node_modules de npm dentro de la app. Dos gestores de paquetes mezclados es buscarse problemas.

Un solo pnpm install en la raíz instala las dos apps. Verás un aviso de scripts de instalación ignorados (esbuild y alguno más): desde la versión 10, pnpm no los ejecuta si no le das permiso. Para este ejemplo no hace falta tocar nada.

3. Convertirlas en shell y microfrontend

Native Federation trae un schematic que hace el trabajo inicial. En cada app, primero instalamos el paquete y luego lo añadimos:

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

¿Por qué el pnpm add antes? Porque con la segunda app, ng add ve que el paquete ya está descargado y no lo apunta en su package.json. Con npm daría igual, pero pnpm es estricto: cada paquete solo ve las dependencias que declara. Si no está en su package.json, para esa app no existe.

Este comando también toca el angular.json de cada app: cambia los builders y añade ficheros nuevos (federation.config.js, bootstrap.ts). Fue de las partes donde más me peleé. Si algo no arranca, abre el angular.json y comprueba que el builder es el de federación:

"build": {
  "builder": "@angular-architects/native-federation:build"
}

4. Una librería compartida

En libs/ui van los componentes que usan todas las apps: botones, tablas, el layout. En el ejemplo, un simple título:

// libs/ui/src/titulo.component.ts
@Component({
  selector: 'ui-titulo',
  template: `<h1>{{ texto() }}</h1>`,
})
export class TituloComponent {
  texto = input.required<string>();
}

Su 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" }
}

Otra vez la regla de pnpm: la librería importa @angular/core, así que tiene que declararlo. Si no, falla con un Cannot find module '@angular/core' que desconcierta bastante la primera vez. Y no, no acabas con Angular duplicado: pnpm enlaza la librería y las apps a la misma copia en disco.

Para que el microfrontend la use:

cd apps/mf-catalogo
pnpm add @mi-frontend/ui@workspace:*
cd ../.. && pnpm install

workspace:* significa "no la busques en npm, usa la que está aquí al lado". El pnpm install final en la raíz instala las dependencias de la propia librería.

Falta un detalle. La librería no se compila por separado: la compila cada app junto con su código. Para eso, TypeScript tiene que saber dónde está el código fuente. En el tsconfig.json de la app:

"compilerOptions": {
  "paths": {
    "@mi-frontend/ui": ["../../libs/ui/src/index.ts"]
  }
}

5. La tienda dice qué enseña

Cada microfrontend tiene un federation.config.js donde decide qué expone al exterior. Es su escaparate:

// 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'],
});

Lo que expone son sus rutas:

// apps/mf-catalogo/src/app/catalogo.routes.ts
export const CATALOGO_ROUTES: Routes = [
  { path: '', component: ListaProductosComponent },
  { path: ':id', component: DetalleProductoComponent },
];

Fíjate en que las rutas empiezan en '', no en 'catalogo'. La tienda no sabe en qué pasillo la va a colocar el centro comercial.

Ese fichero tiene que formar parte de la compilación. Si no, el build falla con File 'src/app/catalogo.routes.ts' is missing from the TypeScript compilation. Se añade en tsconfig.app.json:

"files": [
  "src/main.ts",
  "src/app/catalogo.routes.ts"
]

shareAll con singleton: true es la otra mitad de "compartir dependencias". pnpm comparte en disco y esto comparte en el navegador: Angular se descarga y arranca una sola vez aunque haya varias apps. @mi-frontend/ui va en skip para que no la empaquete aparte como dependencia compartida, porque cada app ya la compila con su código.

6. El edificio sabe dónde está cada tienda

El shell lee un manifest, un JSON con la dirección de cada microfrontend:

// apps/shell/public/federation.manifest.json
{
  "mf-catalogo": "http://localhost:4201/remoteEntry.json"
}

El main.ts del shell ya lo dejó preparado el 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));

Primero averigua dónde está todo y después arranca. Por eso el arranque real de Angular está en otro fichero, bootstrap.ts.

7. Abrir la puerta

En las rutas del shell decidimos en qué pasillo va la tienda:

// 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),
  },
];

Cuando el usuario entra en /catalogo, el shell busca mf-catalogo en el manifest, descarga su ./routes y las monta debajo de catalogo. /catalogo/42 acaba en el DetalleProductoComponent del microfrontend.

8. Levantarlo todo con un comando

pnpm dev

pnpm --filter ejecuta un script solo en un paquete del workspace, y concurrently lanza los dos a la vez. Abre http://localhost:4200/catalogo y tienes el centro comercial abierto.

Donde tropecé

Las rutas de los hijos no cargaban. No recuerdo el detalle exacto, pero el problema estaba en los paths entre el shell y los microfrontends: el shell no llegaba a cargar a los hijos. Si te pasa, hay tres nombres que tienen que coincidir y conviene revisarlos uno a uno:

  • La clave del manifest ("mf-catalogo") y el primer argumento de loadRemoteModule.
  • Lo que expone el federation.config.js ('./routes') y el segundo argumento.
  • El nombre de la constante exportada (CATALOGO_ROUTES).

Y las rutas del hijo, relativas. Si el hijo pone path: 'catalogo' y el shell también, acabas en /catalogo/catalogo.

Un truco: si el microfrontend no está arrancado, el shell lo dice claro en la consola: unknown remote mf-catalogo. Antes de revisar rutas, comprueba que http://localhost:4201/remoteEntry.json responde.

Las URLs quedaban fijas. Al principio las direcciones de los microfrontends estaban escritas en la configuración del shell. Native Federation las resuelve al compilar y las deja dentro del bundle, así que el shell compilado solo sabía buscar las tiendas en localhost. Para tener un entorno de pruebas por cada pull request, o para producción, habría que recompilar el shell en cada sitio. La solución fue el manifest del paso 6: un JSON que se lee al arrancar. Para cambiar de entorno, cambias ese fichero y ya.

No todo se puede compartir. Una librería de PDFs, pdfmake, se importaba por subrutas (pdfmake/build/pdfmake). Native Federation solo registra el nombre exacto del paquete, así que al compartirla las subrutas no se encontraban en el navegador. Se arregla sacándola de la compartición, porque solo la usaba un microfrontend:

skip: ['pdfmake'],

Lo que me llevo

  • Antes de elegir herramienta, decide cuánta magia estás dispuesto a aceptar. Yo prefería ver los cables.
  • pnpm es estricto: cada paquete ve solo lo que declara. Al principio molesta, pero te obliga a saber de qué depende cada cosa.
  • pnpm comparte dependencias en disco y Native Federation las comparte en el navegador. Son dos cosas distintas y conviene no mezclarlas.
  • Las direcciones de los microfrontends, fuera del código. Un manifest te ahorra recompilar.
  • Cuando algo no carga, el problema casi siempre está en un nombre que no coincide entre dos ficheros.

¿Te ha resultado útil?

Si quieres comentarlo o tienes un proyecto parecido, escríbeme.

Hablemos