Saltar al contenido
·31 min de lectura

Monorepos con Turborepo: La configuración que realmente escala

Cómo estructuro monorepos con Turborepo para múltiples apps compartiendo paquetes. Configuración de workspaces, caché de pipelines, configs compartidas de TypeScript y los errores que cometí el primer mes.

Compartir:X / TwitterLinkedIn

Pasé los primeros tres años de mi carrera copiando funciones utilitarias entre repositorios. Sin exagerar. Tenía un formatDate.ts que vivía en siete proyectos diferentes, cada uno con bugs ligeramente diferentes. Cuando arreglaba el problema de zona horaria en uno, me olvidaba de los otros seis. Eventualmente un usuario en Australia abría un ticket y descubría que la corrección nunca llegó a producción en esa app en particular.

El monorepo resolvió esto. No porque esté de moda, no porque Google lo haga, sino porque me cansé de ser un registro de paquetes humano. Un repositorio, código compartido, cambios atómicos en cada app que lo consume.

Pero los monorepos tienen sus propios modos de fallo. He probado tres herramientas diferentes, perdí semanas con caché rota, luché contra errores de dependencias circulares a medianoche, y aprendí que "solo pon todo en un repo" es un consejo tan útil como lo es sin los detalles.

Esta es la configuración de Turborepo que realmente uso. Corre cuatro apps en producción con doce paquetes compartidos. Los builds toman menos de 90 segundos gracias a la caché remota. Los nuevos desarrolladores pueden clonar y ejecutar pnpm dev y tener todo funcionando en menos de dos minutos. Me tomó aproximadamente un mes de errores llegar aquí.

Por qué un monorepo en primer lugar#

Seamos honestos sobre las compensaciones. Un monorepo no es gratis. Estás intercambiando un conjunto de problemas por otro, y necesitas saber qué estás comprando.

Lo que obtienes#

Compartir código sin publicar. Esta es la grande. Escribes una librería de componentes React en packages/ui. Tu app web, tu panel de administración y tu sitio de documentación importan de ella. Cuando cambias un componente de botón, cada app lo recoge inmediatamente. Sin incrementar versiones, sin npm publish, sin "¿qué versión está usando producción?"

typescript
// In apps/web/src/components/Header.tsx
import { Button, Avatar, Dropdown } from "@acme/ui";
import { formatDate } from "@acme/utils";
import { SITE_CONFIG } from "@acme/config";

Estos imports parecen paquetes externos. Se resuelven a través de dependencias del workspace. Pero apuntan a código fuente en el mismo repositorio.

Cambios atómicos entre paquetes. Imagina que necesitas agregar un prop isLoading a tu componente Button compartido. En un mundo polyrepo, cambiarías la librería de componentes, publicarías una nueva versión, actualizarías la versión en cada app que lo consume, y luego abrirías PRs en cada repo. Son tres a cinco PRs por un prop.

En un monorepo, es un solo commit:

feat: add isLoading prop to Button component

- packages/ui/src/Button.tsx (add prop, render spinner)
- apps/web/src/pages/checkout.tsx (use isLoading)
- apps/admin/src/pages/users.tsx (use isLoading)

Un PR. Una revisión. Un merge. Todo se mantiene sincronizado porque físicamente no puede desviarse.

Herramientas unificadas. Una config de ESLint. Una config de Prettier. Una config base de TypeScript. Un pipeline de CI. Cuando actualizas ESLint, lo actualizas en todas partes en una tarde, no a lo largo de tres sprints en ocho repos.

Lo que pagas#

Complejidad en la raíz. Tu package.json en la raíz se convierte en infraestructura. Tu pipeline de CI necesita entender qué paquetes cambiaron. Tu IDE puede tener dificultades con la cantidad de archivos. Las operaciones de Git se ralentizan a medida que el repo crece (aunque esto toma años para la mayoría de los equipos).

El tiempo de CI puede inflarse. Si construyes todo en cada commit, desperdiciarás enormes cantidades de cómputo. Necesitas una herramienta que entienda el grafo de dependencias y solo construya lo que cambió. Esa es toda la razón por la que existe Turborepo.

Fricción en el onboarding. Los nuevos desarrolladores necesitan entender workspaces, hoisting, paquetes internos, el pipeline de build. No es solo "clona y ejecuta". Bueno, eventualmente debería serlo, pero llegar ahí requiere esfuerzo deliberado.

La evaluación honesta: si tienes una app sin código compartido, un monorepo es sobrecarga sin beneficio. Si tienes dos o más apps que comparten algo — componentes, utilidades, tipos, configs — el monorepo se paga solo dentro del primer mes.

Turborepo vs Nx vs Lerna#

He usado los tres. Aquí va la versión corta.

Lerna fue la herramienta original de monorepo para JavaScript. Gestiona versionado y publicación. Pero no entiende pipelines de build ni caché. Fue abandonada, revivida por Nx, y ahora se siente más como una capa de compatibilidad que como una herramienta independiente. Si necesitas publicar paquetes en npm, Lerna + Nx pueden manejar eso. Pero para monorepos internos donde solo compartes código entre tus propias apps, es más ceremonia de la que necesitas.

Nx es poderoso. Realmente poderoso. Tiene generadores, plugins para cada framework, un grafo de dependencias visual, ejecución distribuida de tareas. También tiene una curva de aprendizaje que parece un precipicio. He visto equipos pasar dos semanas solo configurando Nx antes de escribir cualquier código de producto. Si estás en una empresa con más de 50 desarrolladores y cientos de paquetes, Nx es probablemente la elección correcta. Para mis casos de uso, es una excavadora cuando necesito una pala.

Turborepo hace tres cosas bien: entiende tu grafo de dependencias, cachea salidas de build y ejecuta tareas en paralelo. Eso es todo. Toda la configuración es un archivo turbo.json. Puedes leerlo entero en dos minutos. No genera código, no tiene plugins, no intenta reemplazar tu herramienta de build. Es un ejecutor de tareas que es muy, muy bueno en saber qué necesita ejecutarse y qué se puede omitir.

Elegí Turborepo porque podía explicar toda la configuración a un nuevo miembro del equipo en 15 minutos. Con Nx, esa misma conversación tomaba una hora y aún tenían preguntas.

Feature           | Turborepo     | Nx              | Lerna
------------------+---------------+-----------------+----------------
Config complexity | Low (1 file)  | High (multiple) | Medium
Learning curve    | ~1 day        | ~1 week         | ~2 days
Build caching     | Yes (remote)  | Yes (remote)    | No (native)
Task orchestration| Yes           | Yes             | Basic
Code generation   | No            | Yes (extensive) | No
Framework plugins | No            | Yes             | No
Best for          | Small-medium  | Large orgs      | Publishing

Estructura del repositorio#

Aquí está el árbol de directorios real. No un ejemplo de "primeros pasos" — esto es lo que parece un monorepo en producción después de seis meses.

acme-monorepo/
├── apps/
│   ├── web/                    # Main customer-facing app
│   │   ├── src/
│   │   ├── public/
│   │   ├── next.config.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── admin/                  # Internal admin dashboard
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── docs/                   # Documentation site
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── api/                    # Express/Fastify API server
│       ├── src/
│       ├── package.json
│       └── tsconfig.json
├── packages/
│   ├── ui/                     # Shared React component library
│   │   ├── src/
│   │   │   ├── components/
│   │   │   │   ├── Button.tsx
│   │   │   │   ├── Input.tsx
│   │   │   │   ├── Modal.tsx
│   │   │   │   └── index.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── utils/                  # Shared utility functions
│   │   ├── src/
│   │   │   ├── date.ts
│   │   │   ├── string.ts
│   │   │   ├── validation.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── types/                  # Shared TypeScript types
│   │   ├── src/
│   │   │   ├── user.ts
│   │   │   ├── api.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── config-typescript/      # Shared tsconfig presets
│   │   ├── base.json
│   │   ├── nextjs.json
│   │   ├── node.json
│   │   └── package.json
│   ├── config-eslint/          # Shared ESLint configurations
│   │   ├── base.js
│   │   ├── next.js
│   │   ├── node.js
│   │   └── package.json
│   └── config-tailwind/        # Shared Tailwind presets
│       ├── tailwind.config.ts
│       └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json

La separación apps/ vs packages/#

Este es el principio organizativo fundamental. apps/ contiene cosas desplegables — apps web, APIs, cualquier cosa con un comando dev o start. packages/ contiene librerías — código que existe para ser consumido por apps u otros paquetes.

La regla es simple: las apps consumen paquetes. Los paquetes nunca importan de apps. Los paquetes pueden importar de otros paquetes.

Si un paquete empieza a importar de una app, tienes el grafo de dependencias al revés y Turborepo lo detectará como un ciclo.

Configuración del workspace#

El pnpm-workspace.yaml en la raíz le dice a pnpm dónde encontrar los paquetes:

yaml
packages:
  - "apps/*"
  - "packages/*"

Ese es todo el archivo. pnpm escanea esos directorios, lee cada package.json y crea un mapa del workspace. Cuando apps/web declara "@acme/ui": "workspace:*" como dependencia, pnpm lo enlaza al packages/ui local en lugar de buscar en el registro de npm.

package.json raíz#

json
{
  "name": "acme-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo build",
    "dev": "turbo dev",
    "lint": "turbo lint",
    "test": "turbo test",
    "clean": "turbo clean",
    "format": "prettier --write \"**/*.{ts,tsx,md,json}\""
  },
  "devDependencies": {
    "prettier": "^3.4.2",
    "turbo": "^2.4.4",
    "typescript": "^5.7.3"
  },
  "packageManager": "pnpm@9.15.4",
  "engines": {
    "node": ">=20"
  }
}

Observa: el package.json raíz no tiene dependencias de runtime. Es puramente orquestación. turbo es el ejecutor de tareas, prettier maneja el formateo (porque es la única herramienta que no necesita configuración por paquete), y typescript se eleva para que cada paquete use la misma versión.

Convenciones de nombres#

Cada paquete recibe un nombre con scope: @acme/ui, @acme/utils, @acme/config-typescript. El scope previene colisiones con paquetes de npm y hace inmediatamente obvio en cualquier declaración de import si estás usando código interno o externo.

typescript
// External package - from npm
import { clsx } from "clsx";
 
// Internal package - from our monorepo
import { Button } from "@acme/ui";

Yo prefijo los paquetes de configuración con config- para agruparlos visualmente: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Algunos equipos usan @acme/tsconfig, @acme/eslint-config. Cualquiera funciona. Solo sé consistente.

Configuración del pipeline en turbo.json#

Aquí es donde Turborepo demuestra su valor. El archivo turbo.json define tu pipeline de tareas — qué tareas existen, de qué dependen y qué producen.

json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [
    "**/.env.*local"
  ],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "inputs": ["src/**", "package.json", "tsconfig.json"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "cache": true
    },
    "test": {
      "dependsOn": ["^build"],
      "cache": true,
      "inputs": ["src/**", "test/**", "**/*.test.ts", "**/*.test.tsx"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "clean": {
      "cache": false
    },
    "type-check": {
      "dependsOn": ["^build"],
      "cache": true
    }
  }
}

Déjame desglosar cada campo porque aquí es donde la mayoría de la gente se confunde.

dependsOn y la notación ^#

"dependsOn": ["^build"] significa: "Antes de ejecutar esta tarea en el paquete X, primero ejecuta build en cada paquete del que X depende."

El símbolo ^ significa "dependencias upstream". Sin él, "dependsOn": ["build"] significaría "ejecuta build en el mismo paquete primero" — útil si tu tarea test necesita que un build ocurra primero dentro del mismo paquete.

Aquí hay un ejemplo concreto. Tu grafo de dependencias se ve así:

apps/web → @acme/ui → @acme/utils
                    → @acme/types

Cuando ejecutas turbo build, Turborepo resuelve el grafo:

  1. Build @acme/types (sin dependencias)
  2. Build @acme/utils (sin dependencias)
  3. Build @acme/ui (depende de types y utils — espera por ellos)
  4. Build apps/web (depende de ui — espera por él)

Los pasos 1 y 2 se ejecutan en paralelo. El paso 3 espera a ambos. El paso 4 espera al 3. Turborepo resuelve esto a partir de las declaraciones de dependencias en tu package.json. No tienes que especificar el orden manualmente.

outputs#

Esto es crítico para la caché. Cuando Turborepo cachea un build, almacena los archivos listados en outputs. Cuando obtiene un cache hit, restaura esos archivos. Si olvidas listar un directorio de salida, la caché "tendrá éxito" pero tus archivos no aparecerán. Este fue mi mayor error en la primera semana — olvidé .next/** y no podía entender por qué mi app de Next.js siempre se reconstruía.

json
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]

La exclusión !.next/cache/** es importante. Next.js tiene su propia caché dentro de .next/cache/. No quieres almacenar una caché de una caché — infla tu almacenamiento de caché remota y no ayuda.

inputs#

Por defecto, Turborepo hashea cada archivo en un paquete para determinar si cambió. El campo inputs limita eso. Si solo los archivos en src/ afectan la salida del build, entonces cambiar README.md no debería invalidar la caché.

json
"inputs": ["src/**", "package.json", "tsconfig.json"]

Ten cuidado con esto. Si agregas un archivo de configuración que afecta tu build (digamos, postcss.config.js) y no lo incluyes en inputs, obtendrás salida cacheada desactualizada. En caso de duda, deja inputs sin configurar y deja que Turborepo hashee todo.

globalDependencies#

Los archivos listados aquí invalidan la caché de todos los paquetes cuando cambian. Los archivos de entorno son el ejemplo clásico — si tu .env.local cambia, todo lo que pueda leer de process.env necesita reconstruirse.

json
"globalDependencies": ["**/.env.*local"]

Yo también agrego tsconfig.json a nivel raíz aquí porque mi configuración base de TypeScript afecta a todos los paquetes:

json
"globalDependencies": [
  "**/.env.*local",
  "tsconfig.json"
]

persistent y dev#

La tarea dev tiene "cache": false y "persistent": true. Cachear un servidor de desarrollo no tiene sentido — es un proceso de larga duración. El flag persistent le dice a Turborepo que esta tarea no termina, así que no debería esperar por ella antes de ejecutar otras tareas.

Cuando ejecutas turbo dev, Turborepo inicia todos los servidores de desarrollo en paralelo — tu app de Next.js, tu servidor de API, tu sitio de documentación — todos en una terminal con salida intercalada. Cada línea tiene el prefijo del nombre del paquete para que puedas distinguirlos.

Paquetes compartidos en detalle#

packages/ui — La librería de componentes#

Este es el paquete que todo equipo construye primero. Componentes React compartidos usados en todas las apps frontend.

json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./src/index.ts"
    },
    "./styles.css": "./src/styles.css"
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts --external react",
    "dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch",
    "clean": "rm -rf dist",
    "lint": "eslint src/",
    "type-check": "tsc --noEmit"
  },
  "dependencies": {
    "@acme/utils": "workspace:*",
    "@acme/types": "workspace:*"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "@acme/config-eslint": "workspace:*",
    "tsup": "^8.3.6",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "typescript": "^5.7.3"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  }
}

Algunas cosas a notar:

"version": "0.0.0" — Los paquetes internos no necesitan versiones reales. El protocolo workspace:* le dice a pnpm que siempre use la copia local. Los números de versión son irrelevantes.

"private": true — Este paquete nunca será publicado en npm. Si alguna vez quieres publicarlo, elimina este flag y configura una estrategia de versionado adecuada.

El campo exports — Esta es la forma moderna de definir puntos de entrada del paquete. Reemplaza los campos main, module y types. La exportación "." es la ruta de importación por defecto. Puedes agregar exportaciones de sub-rutas para imports granulares:

json
{
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./src/index.ts"
    },
    "./Button": {
      "types": "./src/components/Button.tsx",
      "default": "./src/components/Button.tsx"
    },
    "./styles.css": "./src/styles.css"
  }
}

Decisión importante: ¿construir o no? Algunos equipos omiten el paso de build por completo para paquetes internos. En lugar de compilar con tsup, apuntan las exports directamente al código fuente TypeScript y dejan que el bundler de la app que lo consume (Next.js, Vite) maneje la compilación. Esto se llama "paquetes internos" en la terminología de Turborepo y es más simple:

json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts"
  }
}

Sin paso de build. Sin carpeta dist. El next.config.ts de la app que consume necesita transpilePackages:

typescript
// apps/web/next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@acme/ui", "@acme/utils"],
};
 
export default nextConfig;

Yo uso el enfoque sin build para la mayoría de los paquetes internos. Es más rápido durante el desarrollo y una cosa menos que puede romperse. La excepción son los paquetes que necesitan soportar consumidores sin TypeScript o tienen requisitos de build complejos.

packages/utils — Utilidades compartidas#

json
{
  "name": "@acme/utils",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./date": "./src/date.ts",
    "./string": "./src/string.ts",
    "./validation": "./src/validation.ts"
  },
  "dependencies": {
    "@acme/types": "workspace:*"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "typescript": "^5.7.3"
  }
}

Las exportaciones de sub-rutas permiten que las apps que consumen importen solo lo que necesitan:

typescript
// Only imports date utilities, not the entire package
import { formatRelativeDate, parseISO } from "@acme/utils/date";
 
// Imports everything (barrel export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";

La exportación barrel en src/index.ts re-exporta todo:

typescript
// packages/utils/src/index.ts
export * from "./date";
export * from "./string";
export * from "./validation";

Una advertencia sobre las exportaciones barrel: son convenientes pero pueden matar el tree-shaking en algunos bundlers. Si apps/web importa una función de @acme/utils, un bundler ingenuo podría incluir todo. Next.js maneja esto bien con su tree-shaking integrado, pero si notas problemas de tamaño de bundle, el patrón de exportaciones de sub-rutas es más seguro.

packages/types — Tipos TypeScript compartidos#

json
{
  "name": "@acme/types",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./api": "./src/api.ts",
    "./user": "./src/user.ts"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "typescript": "^5.7.3"
  }
}

Este paquete tiene cero dependencias de runtime. Solo contiene tipos e interfaces de TypeScript. Nunca necesita ser construido porque los tipos se borran en tiempo de compilación.

typescript
// packages/types/src/user.ts
export interface User {
  id: string;
  email: string;
  name: string;
  role: "admin" | "user" | "viewer";
  createdAt: Date;
  updatedAt: Date;
}
 
export interface CreateUserInput {
  email: string;
  name: string;
  role?: User["role"];
}
 
export interface UserFilters {
  role?: User["role"];
  search?: string;
  page?: number;
  limit?: number;
}
typescript
// packages/types/src/api.ts
export interface ApiResponse<T> {
  data: T;
  meta?: {
    page: number;
    limit: number;
    total: number;
  };
}
 
export interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}
 
export type ApiResult<T> =
  | { success: true; data: T }
  | { success: false; error: ApiError };

packages/config-typescript — TSConfigs compartidos#

Este es engañosamente importante. La configuración de TypeScript en un monorepo es donde las cosas se complican rápido.

json
{
  "name": "@acme/config-typescript",
  "version": "0.0.0",
  "private": true,
  "files": [
    "base.json",
    "nextjs.json",
    "node.json",
    "react-library.json"
  ]
}

La configuración base que todos los paquetes extienden:

json
// packages/config-typescript/base.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true
  },
  "exclude": ["node_modules"]
}

La configuración específica de Next.js:

json
// packages/config-typescript/nextjs.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "noEmit": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      { "name": "next" }
    ]
  }
}

La configuración de Node.js para paquetes backend:

json
// packages/config-typescript/node.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

Ahora cada app o paquete extiende el preset apropiado:

json
// apps/web/tsconfig.json
{
  "extends": "@acme/config-typescript/nextjs",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
json
// apps/api/tsconfig.json
{
  "extends": "@acme/config-typescript/node",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}
json
// packages/ui/tsconfig.json
{
  "extends": "@acme/config-typescript/base",
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

El alias de ruta @/*#

Cada app obtiene el mismo alias de ruta @/* apuntando a su propio directorio src/. Esto significa que @/components/Header en apps/web se resuelve a apps/web/src/components/Header, y @/components/Header en apps/admin se resuelve a apps/admin/src/components/Header.

Estas son rutas locales de la app. No cruzan límites de paquetes. Para imports entre paquetes, siempre usas el nombre del paquete: @acme/ui, no @/../../packages/ui. Si alguna vez ves un import relativo que sube más allá de la raíz del paquete (../../packages/something), eso es un code smell. Usa la dependencia del workspace en su lugar.

composite y project references#

Las project references de TypeScript (composite: true) permiten que tsc construya paquetes incrementalmente y entienda las dependencias entre proyectos. Esto es opcional con Turborepo — Turborepo maneja la orquestación del build por sí mismo — pero puede acelerar la verificación de tipos en tu IDE.

json
// packages/ui/tsconfig.json (with composite)
{
  "extends": "@acme/config-typescript/base",
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "jsx": "react-jsx"
  },
  "include": ["src"],
  "references": [
    { "path": "../types" },
    { "path": "../utils" }
  ]
}

Seré honesto: las project references agregan complejidad y las he eliminado de la mayoría de mis configuraciones. El --filter y dependsOn de Turborepo ya manejan el orden del build. Solo agrego composite: true cuando el rendimiento del IDE se convierte en un problema (generalmente con más de 15 paquetes).

packages/config-eslint — Linting compartido#

javascript
// packages/config-eslint/base.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
 
export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.recommended,
  {
    rules: {
      "@typescript-eslint/no-unused-vars": [
        "error",
        { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }
      ],
      "@typescript-eslint/no-explicit-any": "warn",
      "@typescript-eslint/consistent-type-imports": [
        "error",
        { prefer: "type-imports" }
      ],
    },
  }
);
javascript
// packages/config-eslint/next.js
import baseConfig from "./base.js";
import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
 
export default [
  ...baseConfig,
  {
    plugins: {
      "@next/next": nextPlugin,
      "react": reactPlugin,
      "react-hooks": hooksPlugin,
    },
    rules: {
      ...nextPlugin.configs.recommended.rules,
      ...nextPlugin.configs["core-web-vitals"].rules,
      "react-hooks/exhaustive-deps": "warn",
    },
  },
];

Cada app referencia su configuración:

javascript
// apps/web/eslint.config.mjs
import nextConfig from "@acme/config-eslint/next";
 
export default [
  ...nextConfig,
  {
    ignores: [".next/"],
  },
];

Caché remota#

La caché local es agradable — evita que reconstruyas paquetes sin cambios durante el desarrollo. Pero la caché remota es transformadora. Significa que tu servidor de CI se beneficia de tus builds locales y viceversa.

Cómo funciona#

Cuando Turborepo ejecuta una tarea, calcula un hash basado en:

  • Los archivos fuente (hash de contenido)
  • La configuración de inputs de la tarea
  • Variables de entorno
  • Los hashes de las dependencias upstream

Si el hash coincide con un resultado previamente cacheado, Turborepo restaura los outputs desde la caché en lugar de ejecutar la tarea. Con caché remota, esas salidas cacheadas se almacenan en una ubicación compartida a la que cualquier máquina — tu laptop, la laptop de tu colega, CI — puede acceder.

Caché remota de Vercel#

La configuración más simple. Vercel mantiene la infraestructura de caché gratis (con límites):

bash
# Login to Vercel (one-time setup)
npx turbo login
 
# Link your repo to a Vercel project
npx turbo link

Eso es todo. Turborepo ahora enviará y recuperará artefactos de caché de los servidores de Vercel. En CI, configuras variables de entorno:

yaml
# .github/workflows/ci.yml
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Genera el token desde el dashboard de Vercel bajo la configuración de tu equipo. El TURBO_TEAM es el slug de tu equipo.

Caché remota auto-alojada#

Si no puedes usar Vercel (entorno aislado, requisitos de residencia de datos), puedes auto-alojar. La API de caché remota de Turborepo está bien documentada y hay varias implementaciones open source:

  • ducktors/turborepo-remote-cache — Un servidor Node.js que almacena artefactos en S3, GCS o sistema de archivos local
  • fox1t/turborepo-remote-cache — Similar, con soporte para Azure Blob
bash
# docker-compose.yml for self-hosted cache
services:
  turbo-cache:
    image: ducktors/turborepo-remote-cache:latest
    ports:
      - "3000:3000"
    environment:
      TURBO_TOKEN: "your-secret-token"
      STORAGE_PROVIDER: "s3"
      S3_ACCESS_KEY: "${AWS_ACCESS_KEY_ID}"
      S3_SECRET_KEY: "${AWS_SECRET_ACCESS_KEY}"
      S3_REGION: "eu-central-1"
      S3_ENDPOINT: "https://s3.eu-central-1.amazonaws.com"
      S3_BUCKET: "turbo-cache"

Luego configura tu monorepo para usarla:

json
// .turbo/config.json
{
  "teamId": "team_acme",
  "apiUrl": "https://turbo-cache.internal.acme.com"
}

Tasas de cache hit#

En mi experiencia, un Turborepo bien configurado logra tasas de cache hit del 80-95% en CI. Eso significa que solo el 5-20% de las tareas realmente se ejecutan en cualquier pipeline de CI. El resto se restauran desde la caché en segundos.

Las claves para altas tasas de cache hit:

  1. Limita tus inputs. Si solo src/ afecta el build, no dejes que un cambio en README invalide la caché.
  2. No pongas datos volátiles en globalDependencies. Un archivo .env que cambia en cada deploy mata tu caché.
  3. Fija tus dependencias. Los cambios en el lockfile invalidan todo downstream.
  4. Mantén los paquetes pequeños y enfocados. Un gigante packages/utils con 200 archivos tendrá su caché invalidada frecuentemente porque cualquier cambio en cualquier utilidad invalida todo el paquete.

CI/CD con Turborepo#

Aquí está el workflow de GitHub Actions que uso. Ha sido refinado a través de docenas de iteraciones.

yaml
# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
 
jobs:
  ci:
    name: Build, Lint, Test
    runs-on: ubuntu-latest
    timeout-minutes: 15
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 2   # Need parent commit for change detection
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Build
        run: pnpm turbo build
 
      - name: Lint
        run: pnpm turbo lint
 
      - name: Type Check
        run: pnpm turbo type-check
 
      - name: Test
        run: pnpm turbo test

El flag --filter#

El flag --filter es cómo ejecutas tareas para paquetes específicos o basándote en lo que cambió. Esto es esencial para CI eficiente:

bash
# Build only the web app and its dependencies
pnpm turbo build --filter=apps/web...
 
# Build only packages that changed since main
pnpm turbo build --filter=...[main]
 
# Build only @acme/ui and everything that depends on it
pnpm turbo build --filter=...@acme/ui
 
# Build only packages in the packages/ directory
pnpm turbo build --filter="./packages/*"

La sintaxis de tres puntos es importante:

  • --filter=@acme/ui... — El paquete y todas sus dependencias (cosas que necesita)
  • --filter=...@acme/ui — El paquete y todos sus dependientes (cosas que lo necesitan)
  • --filter=@acme/ui — Solo el paquete en sí

Detección de paquetes afectados#

Para pull requests, probablemente no quieras construir todo. Quieres construir solo lo que se ve afectado por los cambios:

yaml
# Only build/test what changed compared to main
- name: Build affected
  run: pnpm turbo build --filter="...[origin/main]"
 
- name: Test affected
  run: pnpm turbo test --filter="...[origin/main]"

La sintaxis [origin/main] le dice a Turborepo que compare el commit actual contra origin/main y solo ejecute tareas para paquetes que tienen cambios. Combinado con caché remota, la mayoría de los pipelines de PR terminan en menos de 2 minutos incluso para monorepos grandes.

Estrategia de matrix para despliegues#

Si cada app se despliega independientemente, usa una estrategia de matrix:

yaml
deploy:
  needs: ci
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/main'
  strategy:
    matrix:
      app: [web, admin, docs, api]
  steps:
    - name: Checkout
      uses: actions/checkout@v4
 
    - name: Setup pnpm
      uses: pnpm/action-setup@v4
 
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: 20
        cache: "pnpm"
 
    - name: Install dependencies
      run: pnpm install --frozen-lockfile
 
    - name: Build
      run: pnpm turbo build --filter=apps/${{ matrix.app }}...
 
    - name: Deploy ${{ matrix.app }}
      run: ./scripts/deploy.sh ${{ matrix.app }}
      env:
        DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Cada job de matrix construye una app y sus dependencias. Gracias a la caché remota, los paquetes compartidos se construyen una vez y se cachean — cada job de matrix posterior los restaura desde la caché.

Errores comunes#

Dedico toda una sección a esto porque caí en cada uno de estos en mi primer mes. Aprender de la documentación es eficiente. Aprender de mis errores es gratis.

1. Dependencias circulares#

El grafo de dependencias debe ser un DAG (grafo acíclico dirigido). Si @acme/ui importa de @acme/utils y @acme/utils importa de @acme/ui, tienes un ciclo. Turborepo se quejará y se negará a construir.

La solución es casi siempre extraer el código compartido a un tercer paquete:

ANTES (roto):
@acme/ui → @acme/utils → @acme/ui  ← ¡ciclo!

DESPUÉS (arreglado):
@acme/ui     → @acme/types
@acme/utils  → @acme/types

Mueve los tipos o constantes compartidos a @acme/types y haz que ambos paquetes dependan de él.

Otra causa común: pones un hook de React en @acme/utils que importa un componente de @acme/ui. Los hooks que dependen de componentes de UI pertenecen a @acme/ui, no a @acme/utils. Las utilidades deben ser agnósticas al framework siempre que sea posible.

2. outputs faltantes que invalidan la caché#

Este es el bug más frustrante. Todo parece funcionar localmente. Los builds de CI pasan. Pero cada build toma el tiempo completo porque nada se cachea.

Escenario: tu paquete construye en build/ en lugar de dist/, pero tu turbo.json solo lista dist/** en outputs:

json
// turbo.json
"build": {
  "outputs": ["dist/**"]  // but your package builds to build/
}

Turborepo cachea la tarea (porque se ejecutó exitosamente) pero no almacena ningún archivo de salida. La próxima vez, obtiene un cache "hit" y no restaura nada. La app que consume no puede encontrar la salida del build y falla, o — peor — silenciosamente usa archivos desactualizados.

Solución: audita el directorio de salida del build de cada paquete y asegúrate de que turbo.json los cubra todos:

json
"build": {
  "outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}

O usa sobrecargas por paquete en turbo.json:

json
{
  "tasks": {
    "build": {
      "outputs": ["dist/**"]
    }
  },
  // Per-package overrides
  "packages": {
    "apps/web": {
      "build": {
        "outputs": [".next/**", "!.next/cache/**"]
      }
    },
    "apps/api": {
      "build": {
        "outputs": ["build/**"]
      }
    }
  }
}

3. Coordinación de servidores de desarrollo#

Ejecutar turbo dev inicia todos los servidores de desarrollo simultáneamente. Esto está bien hasta que tus apps intentan usar el mismo puerto. La asignación explícita de puertos es obligatoria:

json
// apps/web/package.json
"scripts": {
  "dev": "next dev --port 3000"
}
 
// apps/admin/package.json
"scripts": {
  "dev": "next dev --port 3001"
}
 
// apps/docs/package.json
"scripts": {
  "dev": "next dev --port 3002"
}
 
// apps/api/package.json
"scripts": {
  "dev": "tsx watch src/index.ts"  // Uses port 4000 from config
}

Otro problema: hot module replacement para paquetes compartidos. Cuando editas un archivo en packages/ui, el cambio necesita propagarse al servidor de desarrollo de Next.js que está corriendo. Si estás usando el enfoque de "paquetes internos" (sin paso de build, imports directos de TypeScript), esto funciona automáticamente — Next.js observa los archivos fuente a través de los node_modules enlazados.

Si tu paquete tiene un paso de build, necesitas que el script dev del paquete se ejecute en modo watch:

json
// packages/ui/package.json
"scripts": {
  "dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch"
}

Turborepo ejecuta todas las tareas dev en paralelo, así que el paquete se reconstruye al cambiar y la app que consume recoge la nueva salida.

4. Desviación de versiones entre paquetes#

Esto es sutil y peligroso. Si apps/web usa React 19 y apps/admin accidentalmente usa React 18, podrías no notarlo hasta que un componente de @acme/ui se comporte diferente en cada app.

Solución: gestiona las dependencias compartidas a nivel raíz. El campo overrides de pnpm en el package.json raíz fuerza una sola versión en todas partes:

json
// Root package.json
{
  "pnpm": {
    "overrides": {
      "react": "^19.0.0",
      "react-dom": "^19.0.0",
      "typescript": "^5.7.3"
    }
  }
}

Ejecuta pnpm ls react --recursive regularmente para verificar que todo usa la misma versión.

5. Variables de entorno en paquetes compartidos#

Los paquetes compartidos no deberían leer process.env directamente. Si @acme/utils lee process.env.API_URL, crea un acoplamiento invisible con el entorno de la app que lo consume. En su lugar, pasa la configuración explícitamente:

typescript
// MAL: packages/utils/src/api.ts
const API_URL = process.env.API_URL;  // What env? Which app?
 
export function fetchData(path: string) {
  return fetch(`${API_URL}${path}`);
}
 
// BIEN: packages/utils/src/api.ts
export function createApiClient(baseUrl: string) {
  return {
    fetch: (path: string) => fetch(`${baseUrl}${path}`),
  };
}

La app que consume provee la configuración:

typescript
// apps/web/src/lib/api.ts
import { createApiClient } from "@acme/utils";
 
export const api = createApiClient(process.env.NEXT_PUBLIC_API_URL!);

Esto mantiene los paquetes puros y testeables.

6. Dependencias fantasma#

pnpm es estricto con las dependencias por defecto — no eleva paquetes como lo hace npm. Esto es una característica, no un bug. Significa que si @acme/ui importa clsx pero no lo lista en su package.json, pnpm lanzará un error. npm lo resolvería silenciosamente desde un node_modules padre.

Siempre declara cada import en el package.json del paquete que consume. No más depender del hoisting.

7. Degradación del rendimiento del IDE#

Con más de 15 paquetes, tu servidor de lenguaje TypeScript podría empezar a tener dificultades. Algunos tips:

  • Agrega "exclude": ["node_modules", "**/dist/**"] a tu tsconfig.json raíz
  • Usa "Files: Exclude" de VS Code para ocultar las carpetas dist/, .next/ y .turbo/
  • Considera "disableSourceOfProjectReferenceRedirect": true en tsconfig si "Go to Definition" te envía a archivos .d.ts profundos en node_modules

La estructura inicial: Todo junto#

Déjame ponerlo todo junto. Aquí está cada archivo que necesitas para iniciar un monorepo Turborepo con dos apps Next.js y tres paquetes compartidos.

Raíz#

json
// package.json
{
  "name": "acme-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo build",
    "dev": "turbo dev",
    "lint": "turbo lint",
    "test": "turbo test",
    "type-check": "turbo type-check",
    "clean": "turbo clean",
    "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
    "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
  },
  "devDependencies": {
    "prettier": "^3.4.2",
    "turbo": "^2.4.4",
    "typescript": "^5.7.3"
  },
  "packageManager": "pnpm@9.15.4",
  "engines": {
    "node": ">=20"
  },
  "pnpm": {
    "overrides": {
      "react": "^19.0.0",
      "react-dom": "^19.0.0"
    }
  }
}
yaml
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
json
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [
    "**/.env.*local",
    "tsconfig.json"
  ],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "inputs": ["src/**", "package.json", "tsconfig.json"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "cache": true
    },
    "test": {
      "dependsOn": ["^build"],
      "cache": true,
      "inputs": [
        "src/**",
        "test/**",
        "**/*.test.ts",
        "**/*.test.tsx",
        "vitest.config.ts"
      ]
    },
    "type-check": {
      "dependsOn": ["^build"],
      "cache": true
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "clean": {
      "cache": false
    }
  }
}
json
// tsconfig.json (root — for IDE only, not used by builds)
{
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "exclude": ["node_modules", "**/dist/**", "**/.next/**"]
}

apps/web#

json
// apps/web/package.json
{
  "name": "web",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev --port 3000",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "type-check": "tsc --noEmit",
    "test": "vitest run",
    "clean": "rm -rf .next"
  },
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/utils": "workspace:*",
    "@acme/types": "workspace:*",
    "next": "^15.2.1",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "@acme/config-eslint": "workspace:*",
    "@types/node": "^22.13.5",
    "@types/react": "^19.0.10",
    "@types/react-dom": "^19.0.4",
    "typescript": "^5.7.3",
    "vitest": "^3.0.7"
  }
}
typescript
// apps/web/next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@acme/ui", "@acme/utils"],
  reactStrictMode: true,
};
 
export default nextConfig;
json
// apps/web/tsconfig.json
{
  "extends": "@acme/config-typescript/nextjs",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

apps/admin#

json
// apps/admin/package.json
{
  "name": "admin",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev --port 3001",
    "build": "next build",
    "start": "next start --port 3001",
    "lint": "next lint",
    "type-check": "tsc --noEmit",
    "clean": "rm -rf .next"
  },
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/utils": "workspace:*",
    "@acme/types": "workspace:*",
    "next": "^15.2.1",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "@acme/config-eslint": "workspace:*",
    "@types/node": "^22.13.5",
    "@types/react": "^19.0.10",
    "@types/react-dom": "^19.0.4",
    "typescript": "^5.7.3"
  }
}

packages/ui#

json
// packages/ui/package.json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts"
  },
  "scripts": {
    "lint": "eslint src/",
    "type-check": "tsc --noEmit",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "@acme/utils": "workspace:*",
    "@acme/types": "workspace:*"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "@acme/config-eslint": "workspace:*",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "typescript": "^5.7.3"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  }
}
typescript
// packages/ui/src/index.ts
export { Button } from "./components/Button";
export { Input } from "./components/Input";
export { Modal } from "./components/Modal";
export { Card } from "./components/Card";
export { Badge } from "./components/Badge";
 
// Re-export types
export type { ButtonProps } from "./components/Button";
export type { InputProps } from "./components/Input";
export type { ModalProps } from "./components/Modal";
typescript
// packages/ui/src/components/Button.tsx
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "@acme/utils";
 
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive";
  size?: "sm" | "md" | "lg";
  isLoading?: boolean;
}
 
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = "primary", size = "md", isLoading, children, disabled, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={cn(
          "inline-flex items-center justify-center rounded-lg font-medium transition-colors",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
          "disabled:pointer-events-none disabled:opacity-50",
          {
            "bg-blue-600 text-white hover:bg-blue-700": variant === "primary",
            "bg-gray-100 text-gray-900 hover:bg-gray-200": variant === "secondary",
            "border border-gray-300 bg-transparent hover:bg-gray-100": variant === "outline",
            "bg-transparent hover:bg-gray-100": variant === "ghost",
            "bg-red-600 text-white hover:bg-red-700": variant === "destructive",
          },
          {
            "h-8 px-3 text-sm": size === "sm",
            "h-10 px-4 text-sm": size === "md",
            "h-12 px-6 text-base": size === "lg",
          },
          className
        )}
        disabled={disabled || isLoading}
        {...props}
      >
        {isLoading ? (
          <>
            <svg
              className="mr-2 h-4 w-4 animate-spin"
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 24 24"
              aria-hidden="true"
            >
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
            </svg>
            {children}
          </>
        ) : (
          children
        )}
      </button>
    );
  }
);
 
Button.displayName = "Button";

packages/utils#

json
// packages/utils/package.json
{
  "name": "@acme/utils",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./date": "./src/date.ts",
    "./string": "./src/string.ts",
    "./cn": "./src/cn.ts"
  },
  "scripts": {
    "lint": "eslint src/",
    "type-check": "tsc --noEmit",
    "test": "vitest run",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "clsx": "^2.1.1",
    "tailwind-merge": "^3.0.1"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "typescript": "^5.7.3",
    "vitest": "^3.0.7"
  }
}
typescript
// packages/utils/src/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
typescript
// packages/utils/src/date.ts
export function formatRelativeDate(date: Date): string {
  const now = new Date();
  const diffMs = now.getTime() - date.getTime();
  const diffSeconds = Math.floor(diffMs / 1000);
  const diffMinutes = Math.floor(diffSeconds / 60);
  const diffHours = Math.floor(diffMinutes / 60);
  const diffDays = Math.floor(diffHours / 24);
 
  if (diffSeconds < 60) return "just now";
  if (diffMinutes < 60) return `${diffMinutes}m ago`;
  if (diffHours < 24) return `${diffHours}h ago`;
  if (diffDays < 7) return `${diffDays}d ago`;
 
  return date.toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
  });
}
 
export function formatDate(date: Date, locale = "en-US"): string {
  return new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "long",
    day: "numeric",
  }).format(date);
}
typescript
// packages/utils/src/string.ts
export function slugify(text: string): string {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, "")
    .replace(/[\s_]+/g, "-")
    .replace(/-+/g, "-");
}
 
export function truncate(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength).trimEnd() + "...";
}
 
export function capitalize(text: string): string {
  return text.charAt(0).toUpperCase() + text.slice(1);
}
typescript
// packages/utils/src/index.ts
export { cn } from "./cn";
export { formatRelativeDate, formatDate } from "./date";
export { slugify, truncate, capitalize } from "./string";

packages/types#

json
// packages/types/package.json
{
  "name": "@acme/types",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./user": "./src/user.ts",
    "./api": "./src/api.ts"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "typescript": "^5.7.3"
  }
}

.gitignore#

# Dependencies
node_modules/

# Build outputs
dist/
build/
.next/
out/

# Turborepo
.turbo/

# Environment
.env
.env.local
.env.*.local

# IDE
.vscode/settings.json
.idea/

# OS
.DS_Store
Thumbs.db

Empezando desde cero#

Si estás empezando desde cero, aquí está la secuencia exacta de comandos:

bash
# Create the repo
mkdir acme-monorepo && cd acme-monorepo
git init
 
# Initialize the root package
pnpm init
 
# Create the workspace file
echo 'packages:\n  - "apps/*"\n  - "packages/*"' > pnpm-workspace.yaml
 
# Install Turborepo
pnpm add -D turbo -w
 
# Create directories
mkdir -p apps/web apps/admin
mkdir -p packages/ui/src packages/utils/src packages/types/src
mkdir -p packages/config-typescript packages/config-eslint
 
# Initialize apps (from their directories)
cd apps/web && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../admin && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../..
 
# Add workspace dependencies
cd packages/ui && pnpm add @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
cd apps/web && pnpm add @acme/ui@workspace:* @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
 
# Install everything
pnpm install
 
# Verify it works
pnpm turbo build
pnpm turbo dev

El primer pnpm turbo build será lento — todo se construye desde cero. El segundo debería ser casi instantáneo si nada cambió. Esa es la caché funcionando.

Escalando más allá de lo básico#

Una vez que pasas la configuración inicial, algunos patrones emergen a medida que el monorepo crece.

Generadores de paquetes#

Después de tu décimo paquete, crear uno nuevo copiando y editando se vuelve tedioso. Crea un script:

typescript
// scripts/create-package.ts
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
 
const name = process.argv[2];
if (!name) {
  console.error("Usage: tsx scripts/create-package.ts <package-name>");
  process.exit(1);
}
 
const dir = join(process.cwd(), "packages", name);
mkdirSync(join(dir, "src"), { recursive: true });
 
writeFileSync(
  join(dir, "package.json"),
  JSON.stringify(
    {
      name: `@acme/${name}`,
      version: "0.0.0",
      private: true,
      exports: { ".": "./src/index.ts" },
      scripts: {
        lint: "eslint src/",
        "type-check": "tsc --noEmit",
      },
      devDependencies: {
        "@acme/config-typescript": "workspace:*",
        typescript: "^5.7.3",
      },
    },
    null,
    2
  )
);
 
writeFileSync(
  join(dir, "tsconfig.json"),
  JSON.stringify(
    {
      extends: "@acme/config-typescript/base",
      compilerOptions: {
        outDir: "./dist",
        rootDir: "./src",
      },
      include: ["src"],
      exclude: ["node_modules", "dist"],
    },
    null,
    2
  )
);
 
writeFileSync(join(dir, "src", "index.ts"), "// Entry point\n");
 
console.log(`Created packages/${name}`);
console.log("Run: pnpm install");
bash
npx tsx scripts/create-package.ts email
# Creates packages/email with all the boilerplate

Visualización del grafo de dependencias del workspace#

Turborepo tiene un comando de grafo integrado:

bash
pnpm turbo build --graph=graph.svg

Esto genera un SVG de tu grafo de dependencias. Lo ejecuto antes de grandes refactorizaciones para entender el radio de impacto de un cambio. Si tocar @acme/types reconstruye 12 paquetes, quizás es hora de dividirlo en @acme/types-user y @acme/types-billing.

Pruning para Docker#

Cuando despliegas una sola app del monorepo, no quieres copiar todo el repo en tu imagen Docker. Turborepo tiene un comando prune:

bash
pnpm turbo prune --scope=apps/web --docker

Esto genera un directorio out/ que contiene solo los archivos necesarios para apps/web y sus dependencias. Tu Dockerfile usa esta salida podada:

dockerfile
FROM node:20-alpine AS base
 
FROM base AS builder
WORKDIR /app
COPY out/json/ .
COPY out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN corepack enable pnpm && pnpm install --frozen-lockfile
 
COPY out/full/ .
RUN pnpm turbo build --filter=web...
 
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
 
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
 
EXPOSE 3000
CMD ["node", "apps/web/server.js"]

La imagen Docker solo contiene la app web y sus dependencias. Sin app de admin, sin servidor de API, sin paquetes no utilizados. Los tamaños de imagen se mantienen razonables incluso a medida que el monorepo crece.

Un mes después: Lo que haría diferente#

Si estuviera empezando de nuevo, sabiendo lo que sé ahora:

  1. Empieza con paquetes internos (sin paso de build). Perdí dos semanas configurando builds con tsup para paquetes que solo consumían mis apps de Next.js. transpilePackages en la config de Next.js es más simple y más rápido. Solo agrega un paso de build cuando lo necesites.

  2. Mantén el número de packages/ bajo inicialmente. Dividí demasiado agresivamente al principio. packages/utils, packages/types y packages/ui son suficientes para empezar. Siempre puedes dividir después cuando un paquete crezca demasiado. Dividir prematuramente significa más archivos package.json que mantener y más aristas en tu grafo de dependencias.

  3. Configura la caché remota desde el día uno. npx turbo login && npx turbo link toma 30 segundos. El tiempo ahorrado en builds solo en la primera semana lo justifica.

  4. Documenta los comandos del workspace. Los nuevos desarrolladores no saben que pnpm --filter @acme/ui add lodash instala en un paquete específico, o que pnpm turbo build --filter=apps/web... construye solo lo necesario. Una simple sección de "Cheatsheet del Monorepo" en tu guía de contribución ahorra tiempo a todos.

  5. Impón la dirección de dependencias desde el día uno. Si permites aunque sea un import de una app a un paquete, el límite se erosiona rápido. Agrega una regla de lint o una verificación en CI. La dirección es apps → packages → packages. Nunca al revés.

El monorepo no es el objetivo. El objetivo es lanzar funcionalidades sin pelear contra tu propio codebase. Turborepo es la herramienta más ligera que he encontrado para hacer que eso funcione. Hace el grafo de tareas, hace la caché, y se mantiene fuera de tu camino para todo lo demás.

Empieza simple. Agrega complejidad cuando el repo lo demande. Y fija tus puertos.

Artículos relacionados