La estructura típica: apps y packages
El layout canónico de un monorepo: apps como sumideros desplegables y packages como fuentes reutilizables, librerías internas consumidas con workspace, el sabor JIT frente al compilado, la dirección del grafo y las convenciones de scope, private y tooling que sostienen la escala.
Casi todos los monorepos del ecosistema JavaScript convergen en la misma silueta: una carpeta apps/ con lo que se despliega y una carpeta packages/ con lo que se comparte. No es una moda estética. Detrás de esas dos carpetas hay una distinción de fondo entre sumideros —cosas que se consumen pero de las que nadie depende— y fuentes —cosas de las que todo lo demás depende—. Entender esa dirección del grafo, y las convenciones que la protegen, es lo que separa un monorepo que escala de una carpeta grande que se convierte en barro.
- Distinguir
apps/como sumideros desplegables depackages/como fuentes reutilizables. - Consumir librerías internas con el protocolo
workspace:y elegir sabor JIT o compilado. - Razonar la dirección del grafo: acíclico, por capas, sin que nadie importe de
apps/. - Aplicar convenciones que escalan: scope,
private,tooling/y una API pública limpia.
apps/ y packages/: sumideros y fuentes
El pnpm-workspace.yaml de la raíz declara dónde viven los paquetes, casi siempre con dos globs:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "tooling/*"
apps/ contiene las unidades desplegables: la web, el panel de administración, la API, la app móvil. Son sumideros del grafo —nodos hoja—: consumen paquetes pero nadie las consume a ellas. Por eso se marcan "private": true, para que un pnpm publish -r accidental nunca las suba a un registro, y por eso rara vez llevan versión semántica: no se distribuyen, se despliegan.
packages/ contiene las fuentes: la librería de componentes, los utilitarios, el cliente de API, los tipos compartidos. Son nodos de los que cuelga el resto. Un paquete puede ser puramente interno —solo lo consume el propio repo con workspace:*— o estar destinado a publicarse en npm. Muchos monorepos añaden una tercera carpeta, tooling/ o config/, para las configuraciones compartidas: el tsconfig base, la config de ESLint u Oxlint, la de Tailwind. Son fuentes también, pero de reglas en lugar de código de ejecución.
Una nota sobre las apps: aunque sean sumideros, a veces hay que sacar una sola de ellas del monorepo para meterla en una imagen de contenedor sin arrastrar el repo entero. Para eso existe pnpm deploy, que genera una copia autónoma de la app con sus dependencias ya resueltas y sin symlinks al resto del árbol.
# saca una sola app del monorepo, autonoma y sin symlinks
pnpm --filter @acme/web deploy ./out
apps/
Desplegables y privados. Sumideros del grafo: consumen, nadie los consume. "private": true para que jamás se publiquen por error.
packages/
Librerías compartidas, internas o publicables. Fuentes del grafo: de aquí cuelga todo lo demás. Con API pública explícita vía exports.
tooling/
Configuración compartida: tsconfig base, ESLint/Oxlint, Tailwind. Una sola fuente de verdad para “cómo se comprueba el código aquí”.
@acme/*
Todo paquete lleva scope. El nombre del paquete es su identidad en el grafo; la carpeta es solo dónde vive en disco.
Librerías internas: JIT frente a compiladas
Un paquete interno se consume declarándolo con el protocolo workspace: y pnpm lo enlaza por symlink en lugar de descargarlo. Pero hay una decisión de diseño que casi nadie explica y que determina la velocidad de todo el repo: qué expone ese paquete, código fuente o código compilado.
// packages/ui/package.json (paquete JIT: expone el fuente)
{
"name": "@acme/ui",
"exports": { ".": "./src/index.ts" }
}
En el sabor JIT —just-in-time, o “internal package” sin build— el paquete apunta su exports directamente a ./src/index.ts. No tiene paso de compilación propio: es el bundler de la app consumidora (Vite, Turbopack) quien transpila ese TypeScript al vuelo, como si fuera código de la propia app. La ventaja es enorme: cero configuración de build por paquete, cero carpeta dist/ que mantener, y el HMR cruza fronteras de paquete sin fricción. Es el patrón recomendado para todo lo que solo se consume dentro del monorepo.
Hay un matiz de tipos que conviene anticipar: en un paquete JIT, TypeScript resuelve los tipos directamente del fuente, así que el consumidor ve los tipos exactos sin que nadie genere .d.ts. En un paquete compilado, en cambio, los tipos viajan como archivos .d.ts emitidos en el build, y su fidelidad depende de que el paquete los genere bien.
El sabor compilado es lo contrario: el paquete tiene su propio script de build que emite dist/ con JavaScript y sus .d.ts, y exports apunta ahí. Lo necesitas cuando el paquete se va a publicar en npm —los consumidores externos no van a transpilar tu fuente—, cuando cruza una frontera de herramienta que no entiende TypeScript, o cuando quieres aislar el paquete de la config del consumidor. El coste es un paso de build más en el grafo de tareas, justo lo que en la próxima lección veremos que hay que cachear.
// packages/sdk/package.json (paquete compilado: expone dist)
{
"name": "@acme/sdk",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"scripts": { "build": "tsup src/index.ts --format esm --dts" }
}
La heurística de 2026 es simple: si un paquete solo lo consume el propio monorepo, hazlo JIT y ahórrate el build. Cambia a compilado únicamente cuando aparezca una razón concreta —publicarlo, cruzar a una herramienta ajena a TS, o fijar un contrato de salida estable—. Empezar compilando todo “por si acaso” es sembrar el repo de builds lentos que no aportan nada y que luego habrá que cachear para recuperar la velocidad que regalaste.
La dirección del grafo
Un monorepo sano no es una maraña donde todo importa de todo: es un grafo dirigido acíclico con capas. Las apps dependen de los paquetes; los paquetes de más alto nivel dependen de los de más bajo nivel; y nadie —nunca— importa de apps/. Si un paquete de packages/ necesita algo que vive en una app, ese algo está en el sitio equivocado y debe bajar a un paquete.
flowchart TD W[apps web] --> F[packages features] A[apps admin] --> F F --> UI[packages ui] F --> U[packages utils] UI --> U T[tooling tsconfig y eslint] -.-> W T -.-> A T -.-> F style W fill:#89b4fa,color:#11111b style A fill:#89b4fa,color:#11111b style U fill:#a6e3a1,color:#11111b
Dos reglas sostienen esta forma. La primera es la aciclicidad: si ui importa de features y features importa de ui, tienes un ciclo, y los ciclos rompen el orden topológico del que dependen la instalación, el build y la caché. La segunda es el layering: se decide qué capa puede importar de qué capa —por ejemplo, features puede usar ui, pero ui nunca usa features— y esa regla se hace cumplir con tooling, no con buena voluntad. Nx tiene sus module boundaries con etiquetas; con ESLint existen reglas de fronteras; el punto es que la topología deseada esté verificada por una máquina, porque en un repo con cuarenta paquetes ningún humano la mantiene en la cabeza.
Un ciclo entre paquetes no siempre rompe en desarrollo —el bundler a veces lo tolera—, pero envenena todo lo que depende del orden topológico: la instalación, el build incremental y la caché de tareas dejan de tener un orden bien definido. Detéctalos pronto con pnpm ls o con la comprobación de grafo de tu orquestador, y trátalos como lo que son: acoplamiento que tarde o temprano se paga en tiempo de build o en un bug de inicialización difícil de rastrear.
Convenciones que escalan
A partir de cierto tamaño, la coherencia importa más que cualquier optimización puntual. Un puñado de convenciones separan el monorepo que crece con gracia del que colapsa bajo su propio peso.
- Scope obligatorio. Todo paquete lleva
@acme/*. El nombre es su identidad en el grafo y en los--filter; la carpeta es un mero detalle de disco. Nombre de paquete y nombre de carpeta deben ir alineados para que nadie tenga que adivinar dónde vive@acme/ui. privateen las apps. Cinturón y tirantes contra la publicación accidental.- API pública explícita. Un paquete expone lo que quiere que se use vía
exports, no todo su árbol de archivos. Evita el barrel gigante que reexporta el paquete entero: sabotea el tree shaking y crea dependencias fantasma. La superficie pública es un contrato, no un accidente de qué archivos existen. tooling/compartido. Eltsconfigbase, la config de lint y de formato viven una vez y se extienden. Un cambio de regla se propaga a todo el repo sin copiar y pegar.
// tooling/tsconfig/base.json consumido por un paquete
{ "extends": "@acme/tsconfig/base.json", "include": ["src"] }
El workspace:* en un paquete de tooling/ es la misma idea aplicada a la configuración: @acme/tsconfig no se publica, se enlaza, y editar la regla base se propaga a sus consumidores en el acto. La configuración deja de ser texto copiado y pasa a ser una dependencia más del grafo, con las mismas garantías de fuente única que el código de ejecución.
En un --filter, en un import y en un error de CI verás el nombre del paquete (@acme/ui), no su ruta en disco. Por eso alinear nombre y carpeta no es cosmético: cuando @acme/ui vive en packages/ui, cualquiera salta de uno a otro sin pensar; cuando divergen —@acme/ui enterrado en packages/design/legacy-kit— cada búsqueda cuesta un paso mental de traducción que se paga cientos de veces al día.
La suma de estas convenciones produce un efecto que no se ve hasta que falta: cualquiera puede abrir un paquete cualquiera y saber, sin preguntar, dónde está su entrada, qué expone, de qué depende y cómo se comprueba. Esa previsibilidad es el verdadero producto de la estructura.
Cuando dudes dónde colocar algo, deja que mande el grafo: si de un fragmento va a depender más de un paquete, es una fuente y vive en packages/; si no depende nadie de él y se despliega, es un sumidero y vive en apps/; si son reglas que otros extienden, es tooling/. Colocar por el papel en el grafo —y no por afinidad temática— es lo que mantiene la estructura legible cuando el repo pasa de cinco a cincuenta paquetes.
Es fácil leer apps/ y packages/ como una simple convención de orden, la versión monorepo de “pon los tests en su carpeta”. Pero la estructura de un monorepo maduro no es decorativa: es la materialización física de un grafo dirigido acíclico con capas. apps/ son los sumideros, packages/ las fuentes, tooling/ las reglas, y las flechas —quién puede importar de quién— son la arquitectura real del sistema. Cuando la carpeta refleja el grafo, la estructura se vuelve una herramienta de razonamiento: la ubicación de un archivo te dice su papel, sus dependencias legales y su superficie pública, sin abrir el código. Y cuando el grafo se hace cumplir con tooling —aciclicidad garantizada, fronteras de capa verificadas, private que impide fugas— la coherencia deja de depender de que cuarenta personas recuerden la convención y pasa a ser una propiedad que el sistema no permite violar. Ahí está la diferencia entre un monorepo y una carpeta grande: en la carpeta grande, la dirección del grafo vive en la cabeza de quien lo montó y se erosiona con cada incorporación; en el monorepo bien estructurado, vive en el pnpm-workspace.yaml, en los exports, en las reglas de frontera y en el orden topológico que pnpm recorre. La estructura típica es típica porque resuelve, una y otra vez, el mismo problema: hacer que la forma del código en disco y la forma del grafo en ejecución sean la misma cosa, para que razonar sobre una sea razonar sobre la otra.
- En un monorepo real, ejecuta
pnpm ls -r --depth -1y dibuja el grafo: ¿quién depende de quién? ¿hay algún ciclo? - Localiza un paquete de
packages/y comprueba suexports: ¿expone una API deliberada o reexporta todo su árbol con un barrel? - Busca cualquier import que apunte hacia
apps/desde un paquete depackages/. Si lo encuentras, has hallado una fuente mal colocada. - Convierte un paquete interno de compilado a JIT: apunta
exportsa./src/index.ts, elimina subuildy comprueba que la app consumidora lo transpila sin quejarse. - Crea
tooling/tsconfigcon unbase.jsony haz que dos paquetes lo extiendan conworkspace:*. Cambia una opción y verifica que se propaga.