Un monorepo no te hace profesional. Tener reglas claras para no romperlo, sí.
De repo único a monorepo: el contexto
Un repo funciona perfecto mientras tenés una sola app, poco código compartido, y
todavía podés entender todo el proyecto con un ls y un café.
El problema aparece cuando el proyecto deja de ser un side project y pasa a
ser algo más parecido a un producto: web pública, panel admin, API, schemas
compartidos, validaciones, lógica de negocio y UI reutilizable. En ese punto,
seguir metiendo todo en la misma carpeta src es pedirle al futuro que te odie.
Ahí es donde un monorepo con Turborepo empieza a tener sentido.
Visión general de mi monorepo
Mi monorepo se organiza con una idea muy simple: apps/ para cosas que los usuarios finales usan, packages/ para cosas que las apps usan. Nada revolucionario, pero la diferencia está en ser consistente.
Apps: web, admin, api
En mi caso, lo típico es:
apps/web— Sitio público (Astro + React islands) con blog, landing y páginas de marketingapps/admin— Panel admin (TanStack Start + React) con gestión interna, dashboards y backofficeapps/api— API HTTP (Hono) con exposición de datos, auth y lógica de negocio
Packages compartidos
Dentro de packages/ vive todo lo que quiero reutilizar en más de una app:
@repo/schemas— Validaciones con Zod y tipos inferidos viaz.infer<>@repo/db— Drizzle schemas, models que extiendenBaseModel, y acceso a datos@repo/service-core— Lógica de negocio, services que extiendenBaseCrudService@repo/config— Manejo centralizado de configuración y variables de entorno@repo/logger— Logging consistente entre apps@repo/i18n— Traducciones y helpers de internacionalización@repo/utils— Utilidades compartidas@repo/auth-ui— Componentes de UI para autenticación@repo/icons— Iconos del proyecto@repo/seed— Seeds para desarrollo y testing
Y algunos packages de configuración compartida:
@repo/typescript-config— Configuraciones base de TypeScript@repo/biome-config— Configuración de linting y formatting con Biome@repo/tailwind-config— Configuración compartida de Tailwind
En forma de árbol:
.├─ apps/│ ├─ web/ # Astro + React islands│ ├─ admin/ # TanStack Start│ └─ api/ # Hono└─ packages/ ├─ schemas/ # Zod schemas + tipos inferidos ├─ db/ # Drizzle ORM + BaseModel ├─ service-core/ # BaseCrudService + lógica de negocio ├─ config/ ├─ logger/ ├─ i18n/ ├─ utils/ ├─ auth-ui/ ├─ icons/ ├─ seed/ ├─ typescript-config/ ├─ biome-config/ └─ tailwind-config/Y en diagrama:
graph TD subgraph Apps Web["apps/web"] Admin["apps/admin"] Api["apps/api"] end
subgraph Packages Schemas["@repo/schemas"] DB["@repo/db"] Services["@repo/service-core"] Config["@repo/config"] Logger["@repo/logger"] I18n["@repo/i18n"] Utils["@repo/utils"] end
Web --> Schemas Web --> I18n Web --> Config
Admin --> Schemas Admin --> I18n Admin --> Config
Api --> Schemas Api --> DB Api --> Services Api --> Logger Api --> Config
Services --> DB Services --> Schemas DB --> Schemas[!tip] Regla mental: las apps consumen packages, los packages nunca consumen apps.
Qué vive en apps/ y qué vive en packages/
La pregunta clave del monorepo no es “cómo lo nombro”, sino dónde va cada cosa.
En apps/ va:
- Código pegado a un framework particular (páginas de Astro, rutas de TanStack Router, handlers de Hono)
- Entradas de build (
main.tsx,main.ts,entry.server.ts, etc.) - Wiring específico (providers de React solo relevantes para esa app, layouts específicos)
En packages/ va:
- Dominio (schemas Zod de negocio como
AccommodationSchema,DestinationSchema, reglas de negocio compartidas) - Infraestructura reutilizable (configuración de DB sin credenciales, services de acceso a datos, validaciones)
- Herramientas (logger, i18n, config, utils)
Regla práctica: si imaginás que algún día podrías usarlo en otra app,
probablemente sea un packages/ y no un apps/.
Convenciones de nombres, paths y dependencias
Si no definís reglas claras acá, el monorepo se te transforma en un spaghetti de imports cruzados.
Convenciones de nombres
Uso un scope común para todo lo compartido: @repo/schemas, @repo/db,
@repo/service-core, @repo/config, @repo/logger, etc.
Ventajas: a simple vista sabés que eso viene del monorepo, no de npm. Autocompletado más limpio. Menos magia de paths relativos raros.
En el package.json de un package típico:
{ "name": "@repo/schemas", "version": "0.0.0", "private": true, "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsup src/index.ts --format esm,cjs --dts", "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit", "lint": "biome check ." }, "dependencies": { "@repo/utils": "workspace:*", "@repo/config": "workspace:*", "zod": "^4.0.8" }}Reglas de dependencias
Regla de oro: dependencias en capas.
- Capa núcleo:
@repo/configy@repo/utils - Capa dominio:
@repo/schemas(depende de utils),@repo/db(depende de schemas, config),@repo/logger(depende de config) - Capa servicios:
@repo/service-core(depende de db, schemas)
graph TD CoreConfig["@repo/config"] CoreUtils["@repo/utils"]
Schemas["@repo/schemas"] DB["@repo/db"] Logger["@repo/logger"] Services["@repo/service-core"]
Apps["apps/*"]
CoreUtils --> Schemas CoreConfig --> DB CoreConfig --> Logger
Schemas --> DB DB --> Services Schemas --> Services
Services --> Apps DB --> Apps Logger --> Apps Schemas --> Apps[!warning] Reglas que aplico: packages de nivel bajo no pueden depender de packages de nivel alto. Si necesito algo común, lo bajo de nivel y lo pongo en un package más genérico. Nada en
packages/depende de algo enapps/.
Imports limpios
En código, quedan imports muy legibles:
import { AccommodationSchema, type Accommodation } from '@repo/schemas';import { AccommodationModel } from '@repo/db';import { AccommodationService } from '@repo/service-core';Los tipos se infieren de los Zod schemas con z.infer<>, no hay un package
separado de tipos. Todo sale de @repo/schemas.
Cómo uso Turborepo: cache, tasks y scripts
Turborepo es el que se encarga de que todo esto sea trabajable y no una pila infinita de scripts en paralelo.
Estructura básica de turbo.json
Mi turbo.json real:
{ "$schema": "https://turborepo.com/schema.json", "ui": "tui", "globalDependencies": ["**/.env.*local"], "globalEnv": [ "NODE_ENV", "CI", "HOSPEDA_DATABASE_URL", "HOSPEDA_API_URL", "HOSPEDA_SITE_URL" ], "tasks": { "build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, "lint": { "outputs": [] }, "dev": { "cache": false, "persistent": true }, "typecheck": { "dependsOn": ["^typecheck"] }, "test": { "dependsOn": ["build"], "outputs": [], "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"] }, "test:coverage": { "dependsOn": ["build"], "outputs": ["coverage/**"] } }, "concurrency": "20"}Traducción humana:
buildse ejecuta después delbuildde las dependencias (^build) y cachea resultados endist/lintno genera archivos de salida, así que no cacheo outputstestdepende debuildy define qué archivos afectan el cachedevva sin cache y conpersistent: trueporque corre indefinidamente
Scripts en el root
En package.json de la raíz:
{ "scripts": { "dev": "turbo dev", "build": "turbo build", "lint": "turbo lint", "typecheck": "turbo typecheck", "test": "turbo test", "test:coverage": "turbo test:coverage" }}Y en cada app/package:
{ "scripts": { "dev": "astro dev", "build": "astro build", "lint": "biome check .", "typecheck": "tsc --noEmit", "test": "vitest run" }}Turborepo se encarga de ejecutar cada task donde corresponde, reusar
resultados de builds previos (cache local y remoto si querés), y respetar el
grafo de dependencias. Resultado práctico: turbo build no recompila todo desde
cero cada vez.
Beneficios y trade-offs
No hay almuerzo gratis. Un monorepo bien armado trae muchas cosas buenas, pero también algunas complicaciones.
Lo bueno
- Reutilización real de schemas, validaciones, lógica de negocio — todo compartido sin copiar y pegar
- Coherencia con una sola versión de cada cosa: schemas Zod de dominio, services, modelos de datos
- DX fuerte con un solo
pnpm install, scripts globales, y Turborepo manejando tasks - Cambios coordinados — Cambiás un schema y ves qué apps se rompen en el mismo lugar. Podés refactorizar de forma más segura porque el compilador te marca todo
Lo que complica
- Onboarding más pesado — Para alguien nuevo, entender el monorepo lleva más tiempo que entender un repo aislado
- Disciplina — Si no respetás las reglas de dependencias, terminás con imports cruzados raros, paquetes que dependen de todo, y dificultad para extraer partes
- Tooling más sofisticado — No alcanza con tres scripts en el root. Hay
que pensar bien el
turbo.json, alinear tsconfigs, y mantener versiones internas sincronizadas
Cuándo NO usar un monorepo
- Proyecto chico, una sola app, sin planes de crecer — Un repo simple te alcanza y te ahorra complejidad
- Equipos con proyectos casi independientes con diferentes ciclos de release, diferentes stacks, y poca lógica compartida — A veces varios repos bien definidos son más sanos
- Si tu equipo todavía está luchando con cosas más básicas como testing, CI, o code review — Meter un monorepo encima puede sumar ruido antes de sumar valor
Checklist para tu propio monorepo
Si estás pensando en armar o reestructurar un monorepo, yo revisaría esto:
- Tenés claro qué apps va a haber en
apps/ - Tenés claro qué va a vivir en
packages/y por qué - Definiste un scope común (
@repo/*) para tus packages - Tenés reglas explícitas de dependencias (qué puede depender de qué)
- Tenés un
turbo.jsoncon tasks para:build,lint,typecheck,test,dev - Sabés cómo se relacionan los tipos y schemas (¿Zod con
z.infer<>? ¿Package separado?) - Tenés un README interno que explique todo esto para gente nueva
Si marcás la mayoría de estas, tu monorepo está mucho más cerca de ser una herramienta a favor y no una fuente de caos.
Cierre
Mi estrategia de monorepo con Turborepo no es perfecta ni única, pero tiene algo que para mí es clave: es mantenible.
- Apps bien separadas por responsabilidad
- Packages compartidos que realmente tienen sentido compartir
- Reglas simples de dependencias
- Turborepo como orquestador de builds, tests y lint
Si tu proyecto ya no es un juguete y empezó a parecerse a un producto con varias piezas, tomarte el tiempo de armar un monorepo con cabeza puede ahorrarte muchos disgustos a futuro.
La idea no es complicarse la vida “porque monorepo está de moda”. La idea es que la estructura acompañe al proyecto en lugar de frenarlo.