Monorepos con Astro: pnpm workspaces y Turborepo
Cuando un producto deja de ser un sitio y pasa a ser varios que comparten identidad, el monorepo ordena la casa: pnpm workspaces reúne apps y paquetes en un solo repositorio con el protocolo workspace, los paquetes internos comparten UI, configuración y tipos sin publicarlos en npm, y Turborepo orquesta las tareas respetando el grafo de dependencias y cacheando lo que no cambió.
Un solo repo, muchos paquetes
La base del monorepo es el espacio de trabajo, y pnpm lo declara en un fichero en la raíz. pnpm-workspace.yaml enumera qué carpetas contienen paquetes; la convención más extendida separa apps/ —las cosas que se despliegan, como cada sitio Astro— de packages/ —las librerías internas que esas apps consumen—.
# pnpm-workspace.yamlpackages: - "apps/*" - "packages/*"Con eso, pnpm trata cada subcarpeta con su package.json como un paquete del mismo universo. Instala las dependencias una sola vez en un almacén compartido y las enlaza donde hagan falta, de modo que dos apps que usan Astro no guardan dos copias sino una, referenciada desde ambas. Esa deduplicación no es un detalle de disco: es lo que mantiene el árbol de dependencias coherente y evita que dos apps convivan sin querer con versiones distintas de la misma librería.
La pieza que teje el monorepo por dentro es el protocolo workspace:*. Cuando la app web declara que depende de @repo/ui, no pone un número de versión de npm sino workspace:*, y pnpm entiende que ha de resolver esa dependencia desde el propio repositorio, enlazándola a la carpeta local. El paquete interno se comporta como cualquier dependencia —lo importas por su nombre—, pero su código vive a unos metros, en packages/ui, y cualquier cambio se refleja al instante sin publicar ni reinstalar.
// apps/web/package.json{ "name": "web", "type": "module", "dependencies": { "@repo/ui": "workspace:*", "@repo/config": "workspace:*", "astro": "^7.0.0" }}Compartir UI, config y tipos sin publicarlos
El corazón del monorepo son los paquetes internos, y con Astro hay una decisión de fondo que tomar: ¿el paquete de UI se compila antes de usarse, o se consume en crudo? La opción que mejor encaja con Astro son los llamados paquetes just-in-time: no tienen paso de build propio, sino que exponen sus ficheros .astro tal cual y dejan que sea el pipeline de Vite de la app que los importa quien los compile. Un componente Astro no es JavaScript que haya que transpilar por separado; es una plantilla que el compilador de la app entiende de forma nativa, así que interponer un build intermedio solo añade latencia y complejidad.
El paquete declara qué expone mediante el campo exports de su package.json, el mapa que decide qué rutas de importación son públicas y a qué fichero apuntan. Todo lo que no esté ahí queda como detalle interno, invisible para quien lo consume.
// packages/ui/package.json{ "name": "@repo/ui", "type": "module", "exports": { "./Boton.astro": "./src/Boton.astro", "./styles.css": "./src/styles.css" }}Desde cualquier app, ese componente se importa como si viniera de npm —import Boton from '@repo/ui/Boton.astro'— y Vite lo compila junto al resto. La misma técnica sirve para compartir mucho más que UI: un @repo/config con la configuración común de Astro o Vite, un @repo/tsconfig con la base de TypeScript que cada app extiende, un @repo/lib con el cliente de datos y los tipos del dominio. Cada preocupación transversal se convierte en un paquete con una fachada clara, y las apps se vuelven delgadas: casi todo lo sustancial vive en packages/, y cada sitio es sobre todo su enrutado y su despliegue.
Orquestar las tareas con Turborepo
pnpm resuelve dónde vive el código; falta resolver cómo se ejecutan las tareas cuando hay diez paquetes y cada uno tiene su build, su check, su dev. Lanzarlas a mano, en el orden correcto, es inviable: si @repo/ui ha de construirse antes que la app que lo usa, alguien tiene que saberlo. Turborepo lee el grafo de dependencias del workspace y ejecuta las tareas respetándolo, en paralelo cuando puede y en serie cuando debe.
// turbo.json{ "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "check": { "dependsOn": ["^build"] }, "dev": { "cache": false, "persistent": true } }}La clave está en dependsOn y en ese acento circunflejo. "^build" significa antes de construirme yo, construye el build de mis dependencias: Turborepo remonta el grafo y garantiza que @repo/ui esté listo antes de tocar la app. Pero su golpe maestro es la caché: declara en outputs qué produce cada tarea, calcula una huella de las entradas —ficheros, dependencias, variables— y, si esa huella no ha cambiado desde la última vez, no reejecuta nada; restaura el resultado cacheado en un instante. En un monorepo donde solo tocaste el blog, reconstruir el blog y saltarse las otras tres apps intactas es la diferencia entre una CI de treinta segundos y una de diez minutos.
pnpm --filter web dev # arranca solo la app webpnpm --filter web add @repo/ui # enlaza el paquete interno en webturbo run build # construye todo el grafo en orden, con cacheEsa caché puede además compartirse. Con la caché remota, la huella y el resultado de cada tarea viajan a un almacén común, de modo que si un compañero —o la CI— ya construyó exactamente ese estado, tu máquina lo descarga en vez de recalcularlo. El trabajo se hace una vez en todo el equipo y se reutiliza en todas partes; es la economía del monorepo llevada también al tiempo de cómputo.