Monorepo con Turborepo: il setup che scala davvero
Come strutturio i monorepo Turborepo per più app che condividono pacchetti. Configurazione dei workspace, caching delle pipeline, configurazioni TypeScript condivise e gli errori del primo mese.
Ho passato i primi tre anni della mia carriera a copiare funzioni utility tra repository. Non esagero. Avevo un formatDate.ts che viveva in sette progetti diversi, ognuno con bug leggermente diversi. Quando correggevo il problema del fuso orario in uno, mi dimenticavo degli altri sei. Alla fine un utente in Australia apriva un ticket e scoprivo che la correzione non era mai arrivata in produzione in quella specifica app.
Il monorepo ha risolto questo problema. Non perché è di tendenza, non perché lo fa Google, ma perché mi ero stancato di essere un registro di pacchetti umano. Un unico repository, codice condiviso, modifiche atomiche su ogni app che lo consuma.
Ma i monorepo hanno le loro modalità di fallimento. Ho provato tre strumenti diversi, sprecato settimane su caching rotto, combattuto errori di dipendenze circolari a mezzanotte, e imparato che "metti tutto in un unico repo" è un consiglio poco utile senza i dettagli.
Questo è il setup Turborepo che uso davvero. Fa girare quattro app di produzione con dodici pacchetti condivisi. I build impiegano sotto i 90 secondi grazie al remote caching. I nuovi sviluppatori possono clonare e lanciare pnpm dev e avere tutto funzionante in meno di due minuti. Mi ci è voluto circa un mese di errori per arrivare qui.
Perché un monorepo, prima di tutto#
Siamo onesti sui compromessi. Un monorepo non è gratis. Stai scambiando un set di problemi per un altro, e devi sapere cosa stai comprando.
Cosa ottieni#
Condivisione del codice senza pubblicare. Questo è il punto principale. Scrivi una libreria di componenti React in packages/ui. La tua web app, la tua dashboard admin e il tuo sito di documentazione importano tutti da lì. Quando cambi un componente bottone, ogni app lo recepisce immediatamente. Niente version bumping, niente npm publish, niente "quale versione sta usando la produzione?"
// 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";Questi import sembrano pacchetti esterni. Si risolvono tramite dipendenze workspace. Ma puntano al codice sorgente nello stesso repository.
Modifiche atomiche cross-package. Immagina di dover aggiungere una prop isLoading al tuo componente Button condiviso. In un mondo polyrepo, dovresti modificare la libreria di componenti, pubblicare una nuova versione, aggiornare la versione in ogni app che la consuma, poi aprire PR in ogni repo. Sono tre-cinque PR per una sola prop.
In un monorepo, è un unico 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)
Una PR. Una review. Un merge. Tutto resta sincronizzato perché fisicamente non può disallinearsi.
Tooling unificato. Una configurazione ESLint. Una configurazione Prettier. Una configurazione TypeScript base. Una pipeline CI. Quando aggiorni ESLint, lo aggiorni ovunque in un pomeriggio, non in tre sprint su otto repository.
Cosa paghi#
Complessità alla radice. Il tuo package.json alla root diventa infrastruttura. La tua pipeline CI deve capire quali pacchetti sono cambiati. Il tuo IDE potrebbe faticare con il volume di file. Le operazioni Git rallentano man mano che il repo cresce (anche se questo richiede anni per la maggior parte dei team).
Il tempo di CI può esplodere. Se buildi tutto a ogni commit, sprechi enormi quantità di computing. Hai bisogno di uno strumento che comprenda il grafo delle dipendenze e buildi solo ciò che è cambiato. Questo è l'intero motivo per cui esiste Turborepo.
Attrito nell'onboarding. I nuovi sviluppatori devono capire workspace, hoisting, pacchetti interni, la pipeline di build. Non è solo "clona e lancia." Beh, alla fine dovrebbe esserlo, ma arrivarci richiede un impegno deliberato.
La valutazione onesta: se hai una sola app senza codice condiviso, un monorepo è overhead senza benefici. Se hai due o più app che condividono qualcosa — componenti, utility, tipi, configurazioni — il monorepo si ripaga entro il primo mese.
Turborepo vs Nx vs Lerna#
Li ho usati tutti e tre. Ecco la versione breve.
Lerna è stato il primo strumento monorepo per JavaScript. Gestisce il versioning e la pubblicazione. Ma non capisce le pipeline di build né il caching. È stato abbandonato, rilanciato da Nx, e ora sembra più un layer di compatibilità che uno strumento indipendente. Se devi pubblicare pacchetti su npm, Lerna + Nx possono gestirlo. Ma per monorepo interni dove condividi codice solo tra le tue app, è più cerimonia del necessario.
Nx è potente. Davvero potente. Ha generatori, plugin per ogni framework, un grafo visuale delle dipendenze, esecuzione distribuita dei task. Ha anche una curva di apprendimento che sembra una parete verticale. Ho visto team passare due settimane solo a configurare Nx prima di scrivere codice di prodotto. Se sei in un'azienda con 50+ sviluppatori e centinaia di pacchetti, Nx è probabilmente la scelta giusta. Per i miei casi d'uso, è un bulldozer quando mi serve una pala.
Turborepo fa tre cose bene: capisce il tuo grafo delle dipendenze, mette in cache gli output di build, e esegue i task in parallelo. Tutto qui. L'intera configurazione è un unico file turbo.json. Puoi leggerlo tutto in due minuti. Non genera codice, non ha plugin, non cerca di sostituire il tuo build tool. È un task runner che è molto, molto bravo a sapere cosa deve girare e cosa può essere saltato.
Ho scelto Turborepo perché potevo spiegare l'intero setup a un nuovo membro del team in 15 minuti. Con Nx, la stessa conversazione richiedeva un'ora e avevano ancora domande.
Feature | Turborepo | Nx | Lerna
------------------+---------------+-----------------+----------------
Complessità config| Bassa (1 file)| Alta (multipli) | Media
Curva apprendim. | ~1 giorno | ~1 settimana | ~2 giorni
Build caching | Sì (remoto) | Sì (remoto) | No (nativo)
Orchestrazione | Sì | Sì | Base
Code generation | No | Sì (estensiva) | No
Plugin framework | No | Sì | No
Ideale per | Piccoli-medi | Grandi org | Pubblicazione
Struttura del repository#
Ecco l'albero delle directory reale. Non un esempio "getting started" — questo è l'aspetto di un monorepo in produzione dopo sei mesi.
acme-monorepo/
├── apps/
│ ├── web/ # App principale per i clienti
│ │ ├── src/
│ │ ├── public/
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── admin/ # Dashboard admin interna
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── docs/ # Sito documentazione
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── api/ # Server API Express/Fastify
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ ├── ui/ # Libreria componenti React condivisa
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Input.tsx
│ │ │ │ ├── Modal.tsx
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── utils/ # Funzioni utility condivise
│ │ ├── src/
│ │ │ ├── date.ts
│ │ │ ├── string.ts
│ │ │ ├── validation.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── types/ # Tipi TypeScript condivisi
│ │ ├── src/
│ │ │ ├── user.ts
│ │ │ ├── api.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── config-typescript/ # Preset tsconfig condivisi
│ │ ├── base.json
│ │ ├── nextjs.json
│ │ ├── node.json
│ │ └── package.json
│ ├── config-eslint/ # Configurazioni ESLint condivise
│ │ ├── base.js
│ │ ├── next.js
│ │ ├── node.js
│ │ └── package.json
│ └── config-tailwind/ # Preset Tailwind condivisi
│ ├── tailwind.config.ts
│ └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json
La separazione apps/ vs packages/#
Questo è il principio organizzativo fondamentale. apps/ contiene le cose deployabili — web app, API, qualsiasi cosa con un comando dev o start. packages/ contiene le librerie — codice che esiste per essere consumato da app o altri pacchetti.
La regola è semplice: le app consumano i pacchetti. I pacchetti non importano mai dalle app. I pacchetti possono importare da altri pacchetti.
Se un pacchetto inizia a importare da un'app, hai il grafo delle dipendenze al contrario e Turborepo lo segnalerà come un ciclo.
Configurazione del workspace#
Il file pnpm-workspace.yaml alla root dice a pnpm dove trovare i pacchetti:
packages:
- "apps/*"
- "packages/*"Questo è l'intero file. pnpm scansiona quelle directory, legge ogni package.json, e crea una mappa del workspace. Quando apps/web dichiara "@acme/ui": "workspace:*" come dipendenza, pnpm lo collega al packages/ui locale invece di cercarlo nel registro npm.
Root package.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"
}
}Nota: il package.json alla root non ha dipendenze runtime. È pura orchestrazione. turbo è il task runner, prettier gestisce la formattazione (perché è l'unico strumento che non necessita di configurazione per-pacchetto), e typescript è hoistato così che ogni pacchetto usa la stessa versione.
Convenzioni di denominazione#
Ogni pacchetto ha un nome con scope: @acme/ui, @acme/utils, @acme/config-typescript. Lo scope previene collisioni con i pacchetti npm e rende immediatamente ovvio in ogni import se stai usando codice interno o esterno.
// Pacchetto esterno - da npm
import { clsx } from "clsx";
// Pacchetto interno - dal nostro monorepo
import { Button } from "@acme/ui";Prefisso i pacchetti di configurazione con config- per raggrupparli visivamente: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Alcuni team usano @acme/tsconfig, @acme/eslint-config. Entrambi funzionano. L'importante è essere consistenti.
Configurazione pipeline turbo.json#
Qui è dove Turborepo guadagna il suo posto. Il file turbo.json definisce la pipeline dei task — quali task esistono, da cosa dipendono, e cosa producono.
{
"$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
}
}
}Analizziamo ogni campo perché è qui che la maggior parte delle persone si confonde.
dependsOn e la notazione ^#
"dependsOn": ["^build"] significa: "Prima di eseguire questo task nel pacchetto X, prima esegui build in ogni pacchetto da cui X dipende."
Il simbolo ^ significa "dipendenze a monte." Senza di esso, "dependsOn": ["build"] significherebbe "esegui build nello stesso pacchetto prima" — utile se il tuo task test necessita che un build avvenga prima all'interno dello stesso pacchetto.
Ecco un esempio concreto. Il tuo grafo delle dipendenze si presenta così:
apps/web → @acme/ui → @acme/utils
→ @acme/types
Quando esegui turbo build, Turborepo risolve il grafo:
- Build di
@acme/types(nessuna dipendenza) - Build di
@acme/utils(nessuna dipendenza) - Build di
@acme/ui(dipende da types e utils — li aspetta) - Build di
apps/web(dipende da ui — lo aspetta)
I passi 1 e 2 girano in parallelo. Il passo 3 aspetta entrambi. Il passo 4 aspetta il 3. Turborepo lo deduce dalle dichiarazioni delle dipendenze nei tuoi package.json. Non devi specificare l'ordine manualmente.
outputs#
Questo è fondamentale per il caching. Quando Turborepo mette in cache un build, salva i file elencati in outputs. Quando ottiene un cache hit, ripristina quei file. Se dimentichi di elencare una directory di output, la cache "avrà successo" ma i tuoi file non appariranno. Questo è stato il mio più grande errore nella prima settimana — ho dimenticato .next/** e non riuscivo a capire perché la mia app Next.js si ricostruiva sempre.
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]L'esclusione !.next/cache/** è importante. Next.js ha la sua cache dentro .next/cache/. Non vuoi salvare una cache di una cache — gonfia il tuo storage remoto e non aiuta.
inputs#
Per default, Turborepo calcola l'hash di ogni file in un pacchetto per determinare se è cambiato. Il campo inputs restringe questo. Se solo i file in src/ influenzano l'output di build, allora cambiare README.md non dovrebbe invalidare la cache.
"inputs": ["src/**", "package.json", "tsconfig.json"]Fai attenzione con questo. Se aggiungi un file di configurazione che influenza il tuo build (diciamo, postcss.config.js) e non lo includi in inputs, otterrai output cachato obsoleto. Nel dubbio, lascia inputs non impostato e lascia che Turborepo faccia l'hash di tutto.
globalDependencies#
I file elencati qui invalidano la cache per ogni pacchetto quando cambiano. I file di ambiente sono l'esempio classico — se il tuo .env.local cambia, tutto ciò che potrebbe leggere da process.env deve ribuildarsi.
"globalDependencies": ["**/.env.*local"]Aggiungo anche tsconfig.json a livello root qui perché la mia configurazione TypeScript base influenza tutti i pacchetti:
"globalDependencies": [
"**/.env.*local",
"tsconfig.json"
]persistent e dev#
Il task dev ha "cache": false e "persistent": true. Cachare un dev server non ha senso — è un processo a lunga esecuzione. Il flag persistent dice a Turborepo che questo task non termina, quindi non dovrebbe aspettarlo prima di eseguire altri task.
Quando esegui turbo dev, Turborepo avvia tutti i dev server in parallelo — la tua app Next.js, il tuo server API, il tuo sito di documentazione — tutto in un terminale con output interlacciato. Ogni riga è prefissata con il nome del pacchetto così puoi distinguerli.
Pacchetti condivisi in dettaglio#
packages/ui — La libreria di componenti#
Questo è il pacchetto che ogni team costruisce per primo. Componenti React condivisi usati in tutte le app frontend.
{
"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"
}
}Alcune cose da notare:
"version": "0.0.0" — I pacchetti interni non hanno bisogno di versioni reali. Il protocollo workspace:* dice a pnpm di usare sempre la copia locale. I numeri di versione sono irrilevanti.
"private": true — Questo pacchetto non verrà mai pubblicato su npm. Se mai volessi pubblicarlo, rimuovi questo flag e imposta una strategia di versioning appropriata.
Il campo exports — Questo è il modo moderno di definire i punti di ingresso del pacchetto. Sostituisce i campi main, module e types. L'export "." è il percorso di import predefinito. Puoi aggiungere export con sotto-percorsi per import granulari:
{
"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"
}
}Decisione importante: build o no? Alcuni team saltano completamente lo step di build per i pacchetti interni. Invece di compilare con tsup, puntano gli exports direttamente al codice sorgente TypeScript e lasciano che il bundler dell'app che li consuma (Next.js, Vite) gestisca la compilazione. Nella terminologia di Turborepo si chiamano "internal packages" ed è più semplice:
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
}
}Niente step di build. Niente cartella dist. Il next.config.ts dell'app che consuma ha bisogno di transpilePackages:
// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
};
export default nextConfig;Uso l'approccio senza build per la maggior parte dei pacchetti interni. È più veloce durante lo sviluppo e una cosa in meno che può rompersi. L'eccezione sono i pacchetti che devono supportare consumatori non-TypeScript o hanno requisiti di build complessi.
packages/utils — Utility condivise#
{
"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"
}
}Gli export con sotto-percorsi permettono alle app che li consumano di importare solo ciò che serve:
// Importa solo le utility per le date, non l'intero pacchetto
import { formatRelativeDate, parseISO } from "@acme/utils/date";
// Importa tutto (barrel export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";Il barrel export in src/index.ts ri-esporta tutto:
// packages/utils/src/index.ts
export * from "./date";
export * from "./string";
export * from "./validation";Una parola di cautela sui barrel export: sono comodi ma possono uccidere il tree-shaking in alcuni bundler. Se apps/web importa una funzione da @acme/utils, un bundler ingenuo potrebbe includere tutto. Next.js gestisce bene questo con il suo tree-shaking integrato, ma se noti problemi di dimensione del bundle, il pattern degli export con sotto-percorsi è più sicuro.
packages/types — Tipi TypeScript condivisi#
{
"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"
}
}Questo pacchetto ha zero dipendenze runtime. Contiene solo tipi e interfacce TypeScript. Non ha mai bisogno di essere buildato perché i tipi vengono cancellati in fase di compilazione.
// 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;
}// 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 — TSConfig condivise#
Questo è ingannevolmente importante. La configurazione TypeScript in un monorepo è dove le cose si complicano rapidamente.
{
"name": "@acme/config-typescript",
"version": "0.0.0",
"private": true,
"files": [
"base.json",
"nextjs.json",
"node.json",
"react-library.json"
]
}La configurazione base che tutti i pacchetti estendono:
// 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 configurazione specifica per Next.js:
// 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 configurazione Node.js per i pacchetti backend:
// 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"
}
}Ora ogni app o pacchetto estende il preset appropriato:
// 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/api/tsconfig.json
{
"extends": "@acme/config-typescript/node",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}// 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"]
}L'alias di percorso @/*#
Ogni app ha lo stesso alias di percorso @/* che punta alla propria directory src/. Questo significa che @/components/Header in apps/web si risolve a apps/web/src/components/Header, e @/components/Header in apps/admin si risolve a apps/admin/src/components/Header.
Questi sono percorsi locali all'app. Non attraversano i confini dei pacchetti. Per gli import cross-package, usi sempre il nome del pacchetto: @acme/ui, non @/../../packages/ui. Se mai vedi un import relativo che sale oltre la root del pacchetto (../../packages/something), è un code smell. Usa la dipendenza workspace invece.
composite e Project References#
I project references di TypeScript (composite: true) permettono a tsc di buildare i pacchetti in modo incrementale e comprendere le dipendenze cross-progetto. È opzionale con Turborepo — Turborepo gestisce l'orchestrazione del build lui stesso — ma può velocizzare il type checking nel tuo IDE.
// packages/ui/tsconfig.json (con composite)
{
"extends": "@acme/config-typescript/base",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src"],
"references": [
{ "path": "../types" },
{ "path": "../utils" }
]
}Sarò onesto: i project references aggiungono complessità e li ho eliminati dalla maggior parte dei miei setup. Il --filter e dependsOn di Turborepo gestiscono già l'ordine di build. Aggiungo composite: true solo quando le performance dell'IDE diventano un problema (di solito oltre i 15+ pacchetti).
packages/config-eslint — Linting condiviso#
// 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" }
],
},
}
);// 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",
},
},
];Ogni app riferisce la sua configurazione:
// apps/web/eslint.config.mjs
import nextConfig from "@acme/config-eslint/next";
export default [
...nextConfig,
{
ignores: [".next/"],
},
];Remote caching#
Il caching locale è carino — ti evita di ricostruire pacchetti invariati durante lo sviluppo. Ma il remote caching è trasformativo. Significa che il tuo server CI beneficia dei tuoi build locali e viceversa.
Come funziona#
Quando Turborepo esegue un task, calcola un hash basato su:
- I file sorgente (hash del contenuto)
- La configurazione
inputsdel task - Le variabili d'ambiente
- Gli hash delle dipendenze a monte
Se l'hash corrisponde a un risultato precedentemente cachato, Turborepo ripristina gli outputs dalla cache invece di eseguire il task. Con il remote caching, quegli output cachati sono salvati in una posizione condivisa a cui qualsiasi macchina — il tuo portatile, quello del tuo collega, la CI — può accedere.
Remote cache Vercel#
Il setup più semplice. Vercel mantiene l'infrastruttura della cache gratuitamente (con limiti):
# Login a Vercel (configurazione una tantum)
npx turbo login
# Collega il tuo repo a un progetto Vercel
npx turbo linkTutto qui. Turborepo ora invierà e recupererà artefatti di cache dai server di Vercel. In CI, imposti le variabili d'ambiente:
# .github/workflows/ci.yml
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Genera il token dalla dashboard di Vercel nelle impostazioni del tuo team. Il TURBO_TEAM è lo slug del tuo team.
Remote cache self-hosted#
Se non puoi usare Vercel (ambiente air-gapped, requisiti di residenza dei dati), puoi fare il self-hosting. L'API del remote cache di Turborepo è ben documentata e ci sono diverse implementazioni open-source:
- ducktors/turborepo-remote-cache — Un server Node.js che salva gli artefatti su S3, GCS, o filesystem locale
- fox1t/turborepo-remote-cache — Simile, con supporto Azure Blob
# docker-compose.yml per cache self-hosted
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"Poi configura il tuo monorepo per usarlo:
// .turbo/config.json
{
"teamId": "team_acme",
"apiUrl": "https://turbo-cache.internal.acme.com"
}Percentuali di cache hit#
Nella mia esperienza, un Turborepo ben configurato raggiunge il 80-95% di cache hit in CI. Ciò significa che solo il 5-20% dei task gira effettivamente in una data pipeline CI. Il resto viene ripristinato dalla cache in secondi.
Le chiavi per alte percentuali di cache hit:
- Restringi i tuoi
inputs. Se solosrc/influenza il build, non lasciare che un cambio al README invalidi la cache. - Non mettere dati volatili in
globalDependencies. Un file.envche cambia ad ogni deploy uccide la tua cache. - Blocca le tue dipendenze. I cambiamenti al lockfile invalidano tutto ciò che sta a valle.
- Mantieni i pacchetti piccoli e focalizzati. Un gigantesco
packages/utilscon 200 file avrà la sua cache invalidata frequentemente perché qualsiasi modifica a qualsiasi utility invalida l'intero pacchetto.
CI/CD con Turborepo#
Ecco il workflow GitHub Actions che uso. È stato raffinato in decine di iterazioni.
# .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 # Serve il commit genitore per la rilevazione dei cambiamenti
- 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 testIl flag --filter#
Il flag --filter è il modo in cui esegui task per pacchetti specifici o in base a cosa è cambiato. È essenziale per una CI efficiente:
# Build solo la web app e le sue dipendenze
pnpm turbo build --filter=apps/web...
# Build solo i pacchetti che sono cambiati rispetto a main
pnpm turbo build --filter=...[main]
# Build solo @acme/ui e tutto ciò che dipende da esso
pnpm turbo build --filter=...@acme/ui
# Build solo i pacchetti nella directory packages/
pnpm turbo build --filter="./packages/*"La sintassi a tre punti è importante:
--filter=@acme/ui...— Il pacchetto e tutte le sue dipendenze (cose di cui ha bisogno)--filter=...@acme/ui— Il pacchetto e tutti i suoi dipendenti (cose che hanno bisogno di lui)--filter=@acme/ui— Solo il pacchetto stesso
Rilevamento dei pacchetti modificati#
Per le pull request, probabilmente non vuoi buildare tutto. Vuoi buildare solo ciò che è interessato dalle modifiche:
# Build/test solo ciò che è cambiato rispetto a main
- name: Build affected
run: pnpm turbo build --filter="...[origin/main]"
- name: Test affected
run: pnpm turbo test --filter="...[origin/main]"La sintassi [origin/main] dice a Turborepo di confrontare il commit corrente con origin/main e eseguire i task solo per i pacchetti che hanno cambiamenti. Combinato con il remote caching, la maggior parte delle pipeline delle PR finisce in meno di 2 minuti anche per monorepo grandi.
Strategia matrix per i deployment#
Se ogni app viene deployata indipendentemente, usa una strategia matrix:
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 }}Ogni job matrix builda un'app e le sue dipendenze. Grazie al remote caching, i pacchetti condivisi vengono buildati una volta e cachati — ogni job matrix successivo li ripristina dalla cache.
Insidie comuni#
Dedico un'intera sezione a questo perché le ho colpite tutte nel mio primo mese. Imparare dalla documentazione è efficiente. Imparare dai miei errori è gratis.
1. Dipendenze circolari#
Il grafo delle dipendenze deve essere un DAG (grafo aciclico diretto). Se @acme/ui importa da @acme/utils e @acme/utils importa da @acme/ui, hai un ciclo. Turborepo si lamenterà e rifiuterà di buildare.
La soluzione è quasi sempre estrarre il codice condiviso in un terzo pacchetto:
PRIMA (rotto):
@acme/ui → @acme/utils → @acme/ui ← ciclo!
DOPO (corretto):
@acme/ui → @acme/types
@acme/utils → @acme/types
Sposta i tipi o le costanti condivise in @acme/types e fai dipendere entrambi i pacchetti da esso.
Un'altra causa comune: metti un hook React in @acme/utils che importa un componente da @acme/ui. Gli hook che dipendono da componenti UI appartengono a @acme/ui, non a @acme/utils. Le utility dovrebbero essere framework-agnostiche quando possibile.
2. outputs mancanti che invalidano la cache#
Questo è il bug più frustrante. Tutto sembra funzionare in locale. I build CI passano. Ma ogni build impiega il tempo pieno perché niente è cachato.
Scenario: il tuo pacchetto builda in build/ invece che in dist/, ma il tuo turbo.json elenca solo dist/** negli output:
// turbo.json
"build": {
"outputs": ["dist/**"] // ma il tuo pacchetto builda in build/
}Turborepo cacha il task (perché è stato eseguito con successo) ma non salva nessun file di output. La volta successiva, ottiene un cache "hit" e non ripristina niente. L'app che consuma non trova l'output di build e fallisce, o — peggio — usa silenziosamente file obsoleti.
Soluzione: audita la directory di output di build di ogni pacchetto e assicurati che turbo.json le copra tutte:
"build": {
"outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}Oppure usa override per-pacchetto in turbo.json:
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
},
// Override per-pacchetto
"packages": {
"apps/web": {
"build": {
"outputs": [".next/**", "!.next/cache/**"]
}
},
"apps/api": {
"build": {
"outputs": ["build/**"]
}
}
}
}3. Coordinamento dei dev server#
Eseguire turbo dev avvia tutti i dev server simultaneamente. Va bene finché le tue app non provano a usare la stessa porta. L'assegnazione esplicita delle porte è obbligatoria:
// 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" // Usa la porta 4000 dalla configurazione
}Un altro problema: l'hot module replacement per i pacchetti condivisi. Quando modifichi un file in packages/ui, il cambiamento deve propagarsi al dev server Next.js in esecuzione. Se usi l'approccio "internal packages" (niente step di build, import TypeScript diretti), questo funziona automaticamente — Next.js osserva i file sorgente attraverso i node_modules con symlink.
Se il tuo pacchetto ha uno step di build, hai bisogno che lo script dev del pacchetto giri in modalità watch:
// packages/ui/package.json
"scripts": {
"dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch"
}Turborepo esegue tutti i task dev in parallelo, quindi il pacchetto si ricostruisce al cambio e l'app che lo consuma recepisce il nuovo output.
4. Drift delle versioni tra pacchetti#
Questo è sottile e pericoloso. Se apps/web usa React 19 e apps/admin accidentalmente usa React 18, potresti non accorgertene finché un componente da @acme/ui non si comporta diversamente in ogni app.
Soluzione: gestisci le dipendenze condivise a livello root. Il campo overrides di pnpm nel package.json root forza una singola versione ovunque:
// Root package.json
{
"pnpm": {
"overrides": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
}
}
}Esegui pnpm ls react --recursive regolarmente per verificare che tutto usi la stessa versione.
5. Variabili d'ambiente nei pacchetti condivisi#
I pacchetti condivisi non dovrebbero leggere process.env direttamente. Se @acme/utils legge process.env.API_URL, crea un accoppiamento invisibile con l'ambiente dell'app che lo consuma. Invece, passa la configurazione esplicitamente:
// MALE: packages/utils/src/api.ts
const API_URL = process.env.API_URL; // Quale env? Quale app?
export function fetchData(path: string) {
return fetch(`${API_URL}${path}`);
}
// BENE: packages/utils/src/api.ts
export function createApiClient(baseUrl: string) {
return {
fetch: (path: string) => fetch(`${baseUrl}${path}`),
};
}L'app che consuma fornisce la configurazione:
// apps/web/src/lib/api.ts
import { createApiClient } from "@acme/utils";
export const api = createApiClient(process.env.NEXT_PUBLIC_API_URL!);Questo mantiene i pacchetti puri e testabili.
6. Ghost dependencies#
pnpm è rigoroso sulle dipendenze per default — non hoista i pacchetti come fa npm. Questa è una feature, non un bug. Significa che se @acme/ui importa clsx ma non lo elenca nel suo package.json, pnpm lancerà un errore. npm lo risolverebbe silenziosamente da un node_modules genitore.
Dichiara sempre ogni import nel package.json del pacchetto che lo consuma. Non affidarti più all'hoisting.
7. Degradazione delle performance dell'IDE#
Con 15+ pacchetti, il tuo language server TypeScript potrebbe iniziare a faticare. Alcuni consigli:
- Aggiungi
"exclude": ["node_modules", "**/dist/**"]al tuotsconfig.jsonalla root - Usa "Files: Exclude" di VS Code per nascondere le cartelle
dist/,.next/, e.turbo/ - Considera
"disableSourceOfProjectReferenceRedirect": truenel tsconfig se Go to Definition ti manda a file.d.tsnelle profondità dinode_modules
La struttura starter: tutto insieme#
Mettiamo tutto insieme. Ecco ogni file necessario per fare il bootstrap di un monorepo Turborepo con due app Next.js e tre pacchetti condivisi.
Root#
// 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"
}
}
}# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// 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
}
}
}// tsconfig.json (root — solo per l'IDE, non usato dai build)
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"exclude": ["node_modules", "**/dist/**", "**/.next/**"]
}apps/web#
// 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"
}
}// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
reactStrictMode: true,
};
export default nextConfig;// 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#
// 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#
// 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"
}
}// 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";
// Ri-esporta i tipi
export type { ButtonProps } from "./components/Button";
export type { InputProps } from "./components/Input";
export type { ModalProps } from "./components/Modal";// 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#
// 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"
}
}// 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));
}// 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);
}// 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);
}// packages/utils/src/index.ts
export { cn } from "./cn";
export { formatRelativeDate, formatDate } from "./date";
export { slugify, truncate, capitalize } from "./string";packages/types#
// 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#
# Dipendenze
node_modules/
# Output di build
dist/
build/
.next/
out/
# Turborepo
.turbo/
# Ambiente
.env
.env.local
.env.*.local
# IDE
.vscode/settings.json
.idea/
# OS
.DS_Store
Thumbs.db
Partire da zero#
Se parti da zero, ecco la sequenza esatta di comandi:
# Crea il repo
mkdir acme-monorepo && cd acme-monorepo
git init
# Inizializza il pacchetto root
pnpm init
# Crea il file workspace
echo 'packages:\n - "apps/*"\n - "packages/*"' > pnpm-workspace.yaml
# Installa Turborepo
pnpm add -D turbo -w
# Crea le directory
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
# Inizializza le app (dalle loro directory)
cd apps/web && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../admin && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../..
# Aggiungi le dipendenze workspace
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 ../..
# Installa tutto
pnpm install
# Verifica che funziona
pnpm turbo build
pnpm turbo devIl primo pnpm turbo build sarà lento — tutto viene buildato da zero. Il secondo dovrebbe essere quasi istantaneo se niente è cambiato. Questa è la cache che funziona.
Scalare oltre le basi#
Una volta superato il setup iniziale, emergono alcuni pattern man mano che il monorepo cresce.
Generatori di pacchetti#
Dopo il decimo pacchetto, crearne uno nuovo copiando e modificando diventa tedioso. Crea uno script:
// 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");npx tsx scripts/create-package.ts email
# Crea packages/email con tutto il boilerplateVisualizzazione del grafo delle dipendenze del workspace#
Turborepo ha un comando di grafo integrato:
pnpm turbo build --graph=graph.svgQuesto genera un SVG del tuo grafo delle dipendenze. Lo eseguo prima di refactoring importanti per capire il raggio d'azione di un cambiamento. Se toccare @acme/types ricostruisce 12 pacchetti, forse è il momento di dividerlo in @acme/types-user e @acme/types-billing.
Pruning per Docker#
Quando deployi una singola app dal monorepo, non vuoi copiare l'intero repo nella tua immagine Docker. Turborepo ha un comando prune:
pnpm turbo prune --scope=apps/web --dockerQuesto genera una directory out/ contenente solo i file necessari per apps/web e le sue dipendenze. Il tuo Dockerfile usa questo output ridotto:
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"]L'immagine Docker contiene solo la web app e le sue dipendenze. Niente admin app, niente server API, niente pacchetti inutilizzati. Le dimensioni dell'immagine restano ragionevoli anche mentre il monorepo cresce.
Un mese dopo: cosa farei diversamente#
Se dovessi ricominciare, sapendo quello che so ora:
-
Parti con i pacchetti interni (senza step di build). Ho sprecato due settimane a configurare build con tsup per pacchetti che solo le mie app Next.js consumavano.
transpilePackagesnella configurazione di Next.js è più semplice e veloce. Aggiungi uno step di build solo quando ne hai bisogno. -
Mantieni basso il numero di
packages/inizialmente. Ho diviso troppo aggressivamente all'inizio.packages/utils,packages/typesepackages/uibastano per iniziare. Puoi sempre dividere dopo quando un pacchetto diventa ingestibile. Dividere prematuramente significa più filepackage.jsonda mantenere e più archi nel tuo grafo delle dipendenze. -
Configura il remote caching dal primo giorno.
npx turbo login && npx turbo linkrichiede 30 secondi. Il tempo risparmiato sui build nella prima settimana da solo lo giustifica. -
Documenta i comandi del workspace. I nuovi sviluppatori non sanno che
pnpm --filter @acme/ui add lodashinstalla in un pacchetto specifico, o chepnpm turbo build --filter=apps/web...builda solo ciò che serve. Una semplice sezione "Cheatsheet Monorepo" nella tua guida per i contributori fa risparmiare tempo a tutti. -
Applica la direzione delle dipendenze dal primo giorno. Se permetti anche un solo import da un'app in un pacchetto, il confine si erode rapidamente. Aggiungi una regola di lint o un check CI. La direzione è
apps → packages → packages. Mai il contrario.
Il monorepo non è il fine. Il fine è rilasciare funzionalità senza lottare contro il proprio codebase. Turborepo è lo strumento più leggero che ho trovato per far funzionare tutto questo. Gestisce il grafo dei task, gestisce il caching, e per tutto il resto se ne sta fuori dai piedi.
Parti semplice. Aggiungi complessità quando il repo lo richiede. E assegna le porte.