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:
- Workspaces. Un repositorio con varias apps y librerías, cada una
con su
package.json, que se pueden usar entre ellas sin publicar nada. - 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 deloadRemoteModule. - 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.