Aller au contenu
·31 min de lecture

Monorepos Turborepo : La configuration qui passe réellement à l'échelle

Comment je structure mes monorepos Turborepo pour plusieurs apps partageant des packages. Configuration des workspaces, cache de pipeline, configs TypeScript partagées et les erreurs que j'ai commises le premier mois.

Partager:X / TwitterLinkedIn

J'ai passé les trois premières années de ma carrière à copier des fonctions utilitaires entre les dépôts. Sans exagérer. J'avais un formatDate.ts qui vivait dans sept projets différents, chacun avec des bugs légèrement différents. Quand je corrigeais le problème de fuseau horaire dans l'un, j'oubliais les six autres. Finalement, un utilisateur en Australie ouvrait un ticket et je découvrais que le correctif n'avait jamais atteint la production dans cette application en particulier.

Le monorepo a résolu cela. Pas parce que c'est tendance, pas parce que Google le fait, mais parce que j'en avais assez d'être un registre de packages humain. Un seul dépôt, du code partagé, des changements atomiques dans chaque application qui le consomme.

Mais les monorepos ont leurs propres modes de défaillance. J'ai essayé trois outils différents, gaspillé des semaines sur du cache cassé, combattu des erreurs de dépendance circulaire à minuit, et appris que « mets juste tout dans un seul dépôt » est à peu près aussi utile comme conseil sans les détails.

Voici la configuration Turborepo que j'utilise réellement. Elle fait tourner quatre applications en production avec douze packages partagés. Les builds prennent moins de 90 secondes grâce au cache distant. Les nouveaux développeurs peuvent cloner et lancer pnpm dev et tout fonctionne en moins de deux minutes. Il m'a fallu environ un mois d'erreurs pour en arriver là.

Pourquoi un monorepo en premier lieu#

Soyons honnêtes sur les compromis. Un monorepo n'est pas gratuit. Vous échangez un ensemble de problèmes contre un autre, et vous devez savoir ce que vous achetez.

Ce que vous obtenez#

Partage de code sans publication. C'est le point principal. Vous écrivez une bibliothèque de composants React dans packages/ui. Votre application web, votre tableau de bord admin et votre site de documentation l'importent tous. Quand vous changez un composant bouton, chaque application le récupère immédiatement. Pas de bump de version, pas de npm publish, pas de « quelle version est en production déjà ? »

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

Ces imports ressemblent à des packages externes. Ils se résolvent via les dépendances de workspace. Mais ils pointent vers du code source dans le même dépôt.

Changements atomiques inter-packages. Imaginez que vous devez ajouter une prop isLoading à votre composant Button partagé. Dans un monde polyrepo, vous changeriez la bibliothèque de composants, publieriez une nouvelle version, mettriez à jour la version dans chaque application consommatrice, puis ouvririez des PRs dans chaque dépôt. C'est trois à cinq PRs pour une seule prop.

Dans un monorepo, c'est un seul 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)

Une seule PR. Une seule review. Un seul merge. Tout reste synchronisé parce que physiquement ça ne peut pas dériver.

Outillage unifié. Une seule config ESLint. Une seule config Prettier. Une seule config TypeScript de base. Un seul pipeline CI. Quand vous mettez à jour ESLint, vous le mettez à jour partout en un après-midi, pas sur trois sprints à travers huit dépôts.

Ce que vous payez#

Complexité à la racine. Votre package.json à la racine devient de l'infrastructure. Votre pipeline CI doit comprendre quels packages ont changé. Votre IDE pourrait peiner avec le nombre de fichiers. Les opérations Git ralentissent au fur et à mesure que le dépôt grandit (bien que cela prenne des années pour la plupart des équipes).

Le temps CI peut exploser. Si vous buildez tout à chaque commit, vous gaspillerez d'énormes quantités de compute. Vous avez besoin d'un outil qui comprend le graphe de dépendances et ne builde que ce qui a changé. C'est la raison d'être de Turborepo.

Friction d'onboarding. Les nouveaux développeurs doivent comprendre les workspaces, le hoisting, les packages internes, le pipeline de build. Ce n'est pas juste « clone et lance ». Enfin, ça devrait l'être au final, mais y arriver demande un effort délibéré.

L'évaluation honnête : si vous avez une seule application sans code partagé, un monorepo est de la surcharge sans bénéfice. Si vous avez deux applications ou plus qui partagent quoi que ce soit — composants, utilitaires, types, configs — le monorepo est rentabilisé dès le premier mois.

Turborepo vs Nx vs Lerna#

J'ai utilisé les trois. Voici la version courte.

Lerna a été l'outil monorepo original pour JavaScript. Il gère le versioning et la publication. Mais il ne comprend pas les pipelines de build ni le cache. Il a été abandonné, relancé par Nx, et ressemble maintenant plus à une couche de compatibilité qu'à un outil autonome. Si vous devez publier des packages sur npm, Lerna + Nx peut gérer ça. Mais pour les monorepos internes où vous partagez juste du code entre vos propres applications, c'est plus de cérémonie que nécessaire.

Nx est puissant. Vraiment puissant. Il a des générateurs, des plugins pour chaque framework, un graphe de dépendances visuel, une exécution de tâches distribuée. Il a aussi une courbe d'apprentissage qui ressemble à une paroi rocheuse. J'ai vu des équipes passer deux semaines à configurer Nx avant d'écrire du code produit. Si vous êtes dans une entreprise avec 50+ développeurs et des centaines de packages, Nx est probablement le bon choix. Pour mes cas d'usage, c'est un bulldozer quand j'ai besoin d'une pelle.

Turborepo fait trois choses bien : il comprend votre graphe de dépendances, il met en cache les sorties de build, et il exécute les tâches en parallèle. C'est tout. La configuration complète tient dans un seul fichier turbo.json. Vous pouvez le lire entièrement en deux minutes. Il ne génère pas de code, il n'a pas de plugins, il n'essaye pas de remplacer votre outil de build. C'est un gestionnaire de tâches qui est très, très bon pour savoir ce qui doit tourner et ce qui peut être sauté.

J'ai choisi Turborepo parce que je pouvais expliquer toute la configuration à un nouveau membre de l'équipe en 15 minutes. Avec Nx, cette même conversation prenait une heure et ils avaient encore des questions.

Fonctionnalité    | Turborepo     | Nx              | Lerna
------------------+---------------+-----------------+----------------
Complexité config | Faible (1 fichier) | Élevée (multiple) | Moyenne
Courbe d'apprentissage | ~1 jour   | ~1 semaine      | ~2 jours
Cache de build    | Oui (distant) | Oui (distant)   | Non (natif)
Orchestration     | Oui           | Oui             | Basique
Génération de code| Non           | Oui (extensive) | Non
Plugins framework | Non           | Oui             | Non
Idéal pour        | Petite-moyenne| Grandes orgas   | Publication

Structure du dépôt#

Voici l'arborescence réelle. Pas un exemple « pour démarrer » — c'est à quoi ressemble un monorepo de production après six mois.

acme-monorepo/
├── apps/
│   ├── web/                    # Application principale client
│   │   ├── src/
│   │   ├── public/
│   │   ├── next.config.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── admin/                  # Tableau de bord admin interne
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── docs/                   # Site de documentation
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── api/                    # Serveur API Express/Fastify
│       ├── src/
│       ├── package.json
│       └── tsconfig.json
├── packages/
│   ├── ui/                     # Bibliothèque de composants React partagés
│   │   ├── src/
│   │   │   ├── components/
│   │   │   │   ├── Button.tsx
│   │   │   │   ├── Input.tsx
│   │   │   │   ├── Modal.tsx
│   │   │   │   └── index.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── utils/                  # Fonctions utilitaires partagées
│   │   ├── src/
│   │   │   ├── date.ts
│   │   │   ├── string.ts
│   │   │   ├── validation.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── types/                  # Types TypeScript partagés
│   │   ├── src/
│   │   │   ├── user.ts
│   │   │   ├── api.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── config-typescript/      # Préréglages tsconfig partagés
│   │   ├── base.json
│   │   ├── nextjs.json
│   │   ├── node.json
│   │   └── package.json
│   ├── config-eslint/          # Configurations ESLint partagées
│   │   ├── base.js
│   │   ├── next.js
│   │   ├── node.js
│   │   └── package.json
│   └── config-tailwind/        # Préréglages Tailwind partagés
│       ├── tailwind.config.ts
│       └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json

La séparation apps/ vs packages/#

C'est le principe d'organisation fondamental. apps/ contient les éléments déployables — applications web, APIs, tout ce qui a une commande dev ou start. packages/ contient les bibliothèques — du code qui existe pour être consommé par les applications ou d'autres packages.

La règle est simple : les applications consomment les packages. Les packages n'importent jamais depuis les applications. Les packages peuvent importer depuis d'autres packages.

Si un package commence à importer depuis une application, vous avez le graphe de dépendances à l'envers et Turborepo le détectera comme un cycle.

Configuration du workspace#

Le pnpm-workspace.yaml à la racine indique à pnpm où trouver les packages :

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

C'est le fichier complet. pnpm scanne ces répertoires, lit chaque package.json et crée une carte du workspace. Quand apps/web déclare "@acme/ui": "workspace:*" comme dépendance, pnpm le lie au packages/ui local au lieu de chercher dans le registre npm.

package.json racine#

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"
  }
}

Remarque : le package.json racine n'a pas de dépendances runtime. C'est purement de l'orchestration. turbo est le gestionnaire de tâches, prettier gère le formatage (parce que c'est le seul outil qui n'a pas besoin de configuration par package), et typescript est remonté pour que chaque package utilise la même version.

Conventions de nommage#

Chaque package reçoit un nom scopé : @acme/ui, @acme/utils, @acme/config-typescript. Le scope évite les collisions avec les packages npm et rend immédiatement évident dans toute instruction d'import si vous utilisez du code interne ou externe.

typescript
// Package externe - depuis npm
import { clsx } from "clsx";
 
// Package interne - depuis notre monorepo
import { Button } from "@acme/ui";

Je préfixe les packages de configuration avec config- pour les regrouper visuellement : @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Certaines équipes utilisent @acme/tsconfig, @acme/eslint-config. Les deux fonctionnent. Soyez juste cohérent.

Configuration du pipeline turbo.json#

C'est là que Turborepo justifie son existence. Le fichier turbo.json définit votre pipeline de tâches — quelles tâches existent, de quoi elles dépendent, et ce qu'elles produisent.

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écortiquons chaque champ car c'est là que la plupart des gens se perdent.

dependsOn et la notation ^#

"dependsOn": ["^build"] signifie : « Avant d'exécuter cette tâche dans le package X, d'abord exécuter build dans chaque package dont X dépend. »

Le symbole ^ signifie « dépendances amont ». Sans lui, "dependsOn": ["build"] signifierait « exécuter build dans le même package d'abord » — utile si votre tâche test a besoin d'un build d'abord au sein du même package.

Voici un exemple concret. Votre graphe de dépendances ressemble à ceci :

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

Quand vous exécutez turbo build, Turborepo résout le graphe :

  1. Build @acme/types (pas de dépendances)
  2. Build @acme/utils (pas de dépendances)
  3. Build @acme/ui (dépend de types et utils — attend qu'ils soient finis)
  4. Build apps/web (dépend de ui — attend qu'il soit fini)

Les étapes 1 et 2 s'exécutent en parallèle. L'étape 3 attend les deux. L'étape 4 attend la 3. Turborepo détermine cela à partir de vos déclarations de dépendances package.json. Vous n'avez pas à spécifier l'ordre manuellement.

outputs#

C'est critique pour le cache. Quand Turborepo met en cache un build, il stocke les fichiers listés dans outputs. Quand il obtient un cache hit, il restaure ces fichiers. Si vous oubliez de lister un répertoire de sortie, le cache « réussira » mais vos fichiers n'apparaîtront pas. C'était ma plus grosse erreur la première semaine — j'avais oublié .next/** et je ne comprenais pas pourquoi mon application Next.js se rebuildait toujours.

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

L'exclusion !.next/cache/** est importante. Next.js a son propre cache dans .next/cache/. Vous ne voulez pas stocker un cache d'un cache — ça gonfle votre stockage de cache distant et n'aide pas.

inputs#

Par défaut, Turborepo hashe chaque fichier d'un package pour déterminer s'il a changé. Le champ inputs restreint cela. Si seuls les fichiers dans src/ affectent la sortie du build, alors changer README.md ne devrait pas invalider le cache.

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

Soyez prudent avec cela. Si vous ajoutez un fichier de config qui affecte votre build (disons, postcss.config.js) et ne l'incluez pas dans inputs, vous obtiendrez une sortie en cache obsolète. En cas de doute, laissez inputs non défini et laissez Turborepo hasher tout.

globalDependencies#

Les fichiers listés ici invalident le cache de chaque package quand ils changent. Les fichiers d'environnement sont l'exemple classique — si votre .env.local change, tout ce qui pourrait lire process.env doit être rebuildé.

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

J'ajoute aussi tsconfig.json au niveau racine ici parce que ma configuration TypeScript de base affecte tous les packages :

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

persistent et dev#

La tâche dev a "cache": false et "persistent": true. Mettre en cache un serveur de développement n'a pas de sens — c'est un processus long. Le flag persistent indique à Turborepo que cette tâche ne se termine pas, donc il ne devrait pas l'attendre avant d'exécuter d'autres tâches.

Quand vous lancez turbo dev, Turborepo démarre tous les serveurs de développement en parallèle — votre application Next.js, votre serveur API, votre site de docs — le tout dans un seul terminal avec les sorties entrelacées. Chaque ligne est préfixée avec le nom du package pour que vous puissiez les distinguer.

Packages partagés en détail#

packages/ui — La bibliothèque de composants#

C'est le package que chaque équipe construit en premier. Des composants React partagés utilisés par toutes les applications 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"
  }
}

Quelques points à noter :

"version": "0.0.0" — Les packages internes n'ont pas besoin de vraies versions. Le protocole workspace:* indique à pnpm de toujours utiliser la copie locale. Les numéros de version sont sans importance.

"private": true — Ce package ne sera jamais publié sur npm. Si vous voulez un jour le publier, supprimez ce flag et mettez en place une vraie stratégie de versioning.

Le champ exports — C'est la manière moderne de définir les points d'entrée d'un package. Il remplace les champs main, module et types. L'export "." est le chemin d'import par défaut. Vous pouvez ajouter des exports par sous-chemin pour des imports granulaires :

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"
  }
}

Décision importante : builder ou pas ? Certaines équipes sautent complètement l'étape de build pour les packages internes. Au lieu de compiler avec tsup, elles pointent les exports directement vers le code source TypeScript et laissent le bundler de l'application consommatrice (Next.js, Vite) gérer la compilation. C'est ce qu'on appelle les « packages internes » dans la terminologie de Turborepo et c'est plus simple :

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

Pas d'étape de build. Pas de dossier dist. Le next.config.ts de l'application consommatrice a besoin de transpilePackages :

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

J'utilise l'approche sans build pour la plupart des packages internes. C'est plus rapide pendant le développement et une chose de moins qui peut casser. L'exception concerne les packages qui doivent supporter des consommateurs non-TypeScript ou qui ont des exigences de build complexes.

packages/utils — Utilitaires partagés#

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"
  }
}

Les exports par sous-chemin permettent aux applications consommatrices d'importer uniquement ce dont elles ont besoin :

typescript
// N'importe que les utilitaires de date, pas tout le package
import { formatRelativeDate, parseISO } from "@acme/utils/date";
 
// Importe tout (export barrel)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";

L'export barrel dans src/index.ts réexporte tout :

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

Un avertissement sur les exports barrel : ils sont pratiques mais peuvent tuer le tree-shaking dans certains bundlers. Si apps/web importe une seule fonction de @acme/utils, un bundler naïf pourrait tout inclure. Next.js gère bien cela avec son tree-shaking intégré, mais si vous constatez des problèmes de taille de bundle, le pattern d'exports par sous-chemin est plus sûr.

packages/types — Types TypeScript partagés#

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"
  }
}

Ce package a zéro dépendance runtime. Il ne contient que des types et interfaces TypeScript. Il n'a jamais besoin d'être buildé car les types sont effacés à la compilation.

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 partagés#

Celui-ci est étonnamment important. La configuration TypeScript dans un monorepo est l'endroit où les choses se compliquent vite.

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

La configuration de base que tous les packages étendent :

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 configuration spécifique à 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 configuration Node.js pour les packages 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"
  }
}

Maintenant chaque application ou package étend le préréglage approprié :

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"]
}

L'alias de chemin @/*#

Chaque application reçoit le même alias de chemin @/* pointant vers son propre répertoire src/. Cela signifie que @/components/Header dans apps/web se résout en apps/web/src/components/Header, et @/components/Header dans apps/admin se résout en apps/admin/src/components/Header.

Ce sont des chemins locaux à l'application. Ils ne traversent pas les frontières de packages. Pour les imports inter-packages, vous utilisez toujours le nom du package : @acme/ui, pas @/../../packages/ui. Si jamais vous voyez un import relatif qui remonte au-delà de la racine du package (../../packages/something), c'est un code smell. Utilisez la dépendance de workspace à la place.

composite et les références de projet#

Les références de projet TypeScript (composite: true) permettent à tsc de builder les packages de manière incrémentale et de comprendre les dépendances inter-projets. C'est optionnel avec Turborepo — Turborepo gère lui-même l'orchestration du build — mais ça peut accélérer la vérification de types dans votre IDE.

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

Soyons honnêtes : les références de projet ajoutent de la complexité et je les ai supprimées de la plupart de mes configurations. Le --filter et le dependsOn de Turborepo gèrent déjà l'ordre de build. Je n'ajoute composite: true que quand la performance de l'IDE devient un problème (généralement à 15+ packages).

packages/config-eslint — Linting partagé#

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",
    },
  },
];

Chaque application référence sa configuration :

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

Cache distant#

Le cache local est agréable — il vous empêche de rebuilder des packages inchangés pendant le développement. Mais le cache distant est transformateur. Il signifie que votre serveur CI bénéficie de vos builds locaux et vice versa.

Comment ça fonctionne#

Quand Turborepo exécute une tâche, il calcule un hash basé sur :

  • Les fichiers source (hash du contenu)
  • La configuration inputs de la tâche
  • Les variables d'environnement
  • Les hashs des dépendances amont

Si le hash correspond à un résultat précédemment mis en cache, Turborepo restaure les outputs depuis le cache au lieu d'exécuter la tâche. Avec le cache distant, ces sorties mises en cache sont stockées dans un emplacement partagé que n'importe quelle machine — votre laptop, celui de votre collègue, la CI — peut accéder.

Cache distant Vercel#

La configuration la plus simple. Vercel maintient l'infrastructure de cache gratuitement (avec des limites) :

bash
# Se connecter à Vercel (configuration unique)
npx turbo login
 
# Lier votre dépôt à un projet Vercel
npx turbo link

C'est tout. Turborepo va maintenant pousser et récupérer les artefacts de cache depuis les serveurs de Vercel. En CI, vous définissez des variables d'environnement :

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

Générez le token depuis le tableau de bord Vercel dans les paramètres de votre équipe. Le TURBO_TEAM est le slug de votre équipe.

Cache distant auto-hébergé#

Si vous ne pouvez pas utiliser Vercel (environnement air-gapped, exigences de résidence des données), vous pouvez auto-héberger. L'API du cache distant de Turborepo est bien documentée et il existe plusieurs implémentations open-source :

  • ducktors/turborepo-remote-cache — Un serveur Node.js qui stocke les artefacts dans S3, GCS ou le système de fichiers local
  • fox1t/turborepo-remote-cache — Similaire, avec support Azure Blob
bash
# docker-compose.yml pour le cache auto-hébergé
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"

Puis configurez votre monorepo pour l'utiliser :

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

Taux de cache hit#

D'après mon expérience, un Turborepo bien configuré atteint 80 à 95% de taux de cache hit en CI. Cela signifie que seulement 5 à 20% des tâches s'exécutent réellement à chaque pipeline CI. Le reste est restauré depuis le cache en quelques secondes.

Les clés pour des taux de cache hit élevés :

  1. Restreignez vos inputs. Si seul src/ affecte le build, ne laissez pas un changement de README invalider le cache.
  2. Ne mettez pas de données volatiles dans globalDependencies. Un fichier .env qui change à chaque déploiement tue votre cache.
  3. Verrouillez vos dépendances. Les changements de lockfile invalident tout en aval.
  4. Gardez les packages petits et ciblés. Un énorme packages/utils avec 200 fichiers verra son cache invalidé fréquemment parce que tout changement dans n'importe quel utilitaire invalide tout le package.

CI/CD avec Turborepo#

Voici le workflow GitHub Actions que j'utilise. Il a été affiné sur des dizaines d'itérations.

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   # Besoin du commit parent pour la détection de changements
 
      - 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

Le flag --filter#

Le flag --filter permet d'exécuter des tâches pour des packages spécifiques ou en fonction de ce qui a changé. C'est essentiel pour une CI efficace :

bash
# Builder uniquement l'application web et ses dépendances
pnpm turbo build --filter=apps/web...
 
# Builder uniquement les packages qui ont changé depuis main
pnpm turbo build --filter=...[main]
 
# Builder uniquement @acme/ui et tout ce qui en dépend
pnpm turbo build --filter=...@acme/ui
 
# Builder uniquement les packages dans le répertoire packages/
pnpm turbo build --filter="./packages/*"

La syntaxe avec les trois points est importante :

  • --filter=@acme/ui... — Le package et toutes ses dépendances (ce dont il a besoin)
  • --filter=...@acme/ui — Le package et tous ses dépendants (ce qui a besoin de lui)
  • --filter=@acme/ui — Seulement le package lui-même

Détection des packages affectés#

Pour les pull requests, vous ne voulez probablement pas tout builder. Vous voulez builder uniquement ce qui est affecté par les changements :

yaml
# Builder/tester uniquement ce qui a changé par rapport à main
- name: Build affected
  run: pnpm turbo build --filter="...[origin/main]"
 
- name: Test affected
  run: pnpm turbo test --filter="...[origin/main]"

La syntaxe [origin/main] indique à Turborepo de comparer le commit actuel avec origin/main et de n'exécuter les tâches que pour les packages qui ont des changements. Combiné avec le cache distant, la plupart des pipelines de PR se terminent en moins de 2 minutes même pour de gros monorepos.

Stratégie matricielle pour les déploiements#

Si chaque application se déploie indépendamment, utilisez une stratégie matricielle :

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 }}

Chaque job de la matrice builde une application et ses dépendances. Grâce au cache distant, les packages partagés sont buildés une fois et mis en cache — chaque job matriciel suivant les restaure depuis le cache.

Pièges courants#

Je dédie une section entière à cela parce que j'ai rencontré chacun de ces pièges durant mon premier mois. Apprendre de la documentation est efficace. Apprendre de mes erreurs est gratuit.

1. Dépendances circulaires#

Le graphe de dépendances doit être un DAG (graphe orienté acyclique). Si @acme/ui importe depuis @acme/utils et @acme/utils importe depuis @acme/ui, vous avez un cycle. Turborepo se plaindra et refusera de builder.

La solution est presque toujours d'extraire le code partagé dans un troisième package :

AVANT (cassé) :
@acme/ui → @acme/utils → @acme/ui  ← cycle !

APRÈS (corrigé) :
@acme/ui     → @acme/types
@acme/utils  → @acme/types

Déplacez les types ou constantes partagés dans @acme/types et faites que les deux packages en dépendent.

Une autre cause courante : vous mettez un hook React dans @acme/utils qui importe un composant de @acme/ui. Les hooks qui dépendent de composants UI appartiennent à @acme/ui, pas à @acme/utils. Les utilitaires devraient être agnostiques du framework autant que possible.

2. outputs manquants invalidant le cache#

C'est le bug le plus frustrant. Tout semble fonctionner localement. Les builds CI passent. Mais chaque build prend le temps complet parce que rien n'est mis en cache.

Scénario : votre package builde vers build/ au lieu de dist/, mais votre turbo.json ne liste que dist/** dans les outputs :

json
// turbo.json
"build": {
  "outputs": ["dist/**"]  // mais votre package builde vers build/
}

Turborepo met en cache la tâche (parce qu'elle s'est exécutée avec succès) mais ne stocke aucun fichier de sortie. La prochaine fois, il obtient un cache « hit » et ne restaure rien. L'application consommatrice ne trouve pas la sortie de build et échoue, ou — pire — utilise silencieusement des fichiers obsolètes.

Correction : auditez le répertoire de sortie de build de chaque package et assurez-vous que turbo.json les couvre tous :

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

Ou utilisez des surcharges par package dans turbo.json :

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

3. Coordination des serveurs de développement#

Exécuter turbo dev démarre tous les serveurs de développement simultanément. C'est bien jusqu'à ce que vos applications essayent d'utiliser le même port. L'attribution explicite de ports est obligatoire :

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"  // Utilise le port 4000 depuis la config
}

Un autre problème : le hot module replacement pour les packages partagés. Quand vous modifiez un fichier dans packages/ui, le changement doit se propager au serveur de développement Next.js en cours d'exécution. Si vous utilisez l'approche « packages internes » (pas d'étape de build, imports TypeScript directs), cela fonctionne automatiquement — Next.js surveille les fichiers source via les node_modules liés par symlink.

Si votre package a une étape de build, vous avez besoin que le script dev du package s'exécute en mode watch :

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

Turborepo exécute toutes les tâches dev en parallèle, donc le package se rebuilde au changement et l'application consommatrice récupère la nouvelle sortie.

4. Dérive de versions entre packages#

C'est subtil et dangereux. Si apps/web utilise React 19 et apps/admin utilise accidentellement React 18, vous pourriez ne pas le remarquer jusqu'à ce qu'un composant de @acme/ui se comporte différemment dans chaque application.

Solution : gérez les dépendances partagées au niveau racine. Le champ overrides de pnpm dans le package.json racine force une seule version partout :

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

Exécutez pnpm ls react --recursive régulièrement pour vérifier que tout utilise la même version.

5. Variables d'environnement dans les packages partagés#

Les packages partagés ne devraient pas lire process.env directement. Si @acme/utils lit process.env.API_URL, cela crée un couplage invisible avec l'environnement de l'application consommatrice. Passez plutôt la configuration explicitement :

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

L'application consommatrice fournit la configuration :

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

Cela garde les packages purs et testables.

6. Dépendances fantômes#

pnpm est strict sur les dépendances par défaut — il ne remonte pas les packages comme le fait npm. C'est une fonctionnalité, pas un bug. Cela signifie que si @acme/ui importe clsx mais ne le liste pas dans son package.json, pnpm lancera une erreur. npm le résoudrait silencieusement depuis un node_modules parent.

Déclarez toujours chaque import dans le package.json du package consommateur. Plus de dépendance au hoisting.

7. Dégradation des performances de l'IDE#

Avec 15+ packages, votre serveur de langage TypeScript pourrait commencer à peiner. Quelques conseils :

  • Ajoutez "exclude": ["node_modules", "**/dist/**"] à votre tsconfig.json racine
  • Utilisez les « Files: Exclude » de VS Code pour masquer les dossiers dist/, .next/ et .turbo/
  • Envisagez "disableSourceOfProjectReferenceRedirect": true dans le tsconfig si « Go to Definition » vous envoie vers des fichiers .d.ts profonds dans node_modules

La structure de départ : Tout ensemble#

Mettons tout ensemble. Voici chaque fichier dont vous avez besoin pour démarrer un monorepo Turborepo avec deux applications Next.js et trois packages partagés.

Racine#

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 (racine — pour l'IDE uniquement, pas utilisé par les builds)
{
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "exclude": ["node_modules", "**/dist/**", "**/.next/**"]
}

Démarrer de zéro#

Si vous commencez de rien, voici la séquence exacte de commandes :

bash
# Créer le dépôt
mkdir acme-monorepo && cd acme-monorepo
git init
 
# Initialiser le package racine
pnpm init
 
# Créer le fichier de workspace
echo 'packages:\n  - "apps/*"\n  - "packages/*"' > pnpm-workspace.yaml
 
# Installer Turborepo
pnpm add -D turbo -w
 
# Créer les répertoires
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
 
# Initialiser les applications (depuis leurs répertoires)
cd apps/web && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../admin && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../..
 
# Ajouter les dépendances de 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 ../..
 
# Installer tout
pnpm install
 
# Vérifier que ça fonctionne
pnpm turbo build
pnpm turbo dev

Le premier pnpm turbo build sera lent — tout se builde à partir de zéro. Le second devrait être quasi instantané si rien n'a changé. C'est le cache qui fonctionne.

Aller au-delà des bases#

Une fois passée la configuration initiale, quelques patterns émergent à mesure que le monorepo grandit.

Générateurs de packages#

Après votre dixième package, en créer un nouveau en copiant et modifiant devient fastidieux. Créez 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
# Crée packages/email avec tout le boilerplate

Visualisation du graphe de dépendances du workspace#

Turborepo a une commande de graphe intégrée :

bash
pnpm turbo build --graph=graph.svg

Cela génère un SVG de votre graphe de dépendances. Je l'exécute avant les refactorisations majeures pour comprendre le rayon d'impact d'un changement. Si toucher @acme/types rebuilde 12 packages, c'est peut-être le moment de le scinder en @acme/types-user et @acme/types-billing.

Élagage pour Docker#

Quand vous déployez une seule application depuis le monorepo, vous ne voulez pas copier tout le dépôt dans votre image Docker. Turborepo a une commande prune :

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

Cela génère un répertoire out/ contenant uniquement les fichiers nécessaires pour apps/web et ses dépendances. Votre Dockerfile utilise cette sortie élaguée :

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"]

L'image Docker ne contient que l'application web et ses dépendances. Pas d'application admin, pas de serveur API, pas de packages non utilisés. Les tailles d'images restent raisonnables même quand le monorepo grandit.

Un mois plus tard : Ce que je ferais différemment#

Si je recommençais, sachant ce que je sais maintenant :

  1. Commencer avec des packages internes (pas d'étape de build). J'ai gaspillé deux semaines à configurer des builds tsup pour des packages que seules mes applications Next.js consommaient. transpilePackages dans la config Next.js est plus simple et plus rapide. N'ajoutez une étape de build que quand vous en avez besoin.

  2. Garder le nombre de packages/ bas au départ. J'ai scindé trop agressivement au début. packages/utils, packages/types et packages/ui suffisent pour commencer. Vous pouvez toujours scinder plus tard quand un package devient ingérable. Scinder prématurément signifie plus de fichiers package.json à maintenir et plus d'arêtes dans votre graphe de dépendances.

  3. Configurer le cache distant dès le premier jour. npx turbo login && npx turbo link prend 30 secondes. Le temps gagné sur les builds durant la première semaine seule justifie l'effort.

  4. Documenter les commandes du workspace. Les nouveaux développeurs ne savent pas que pnpm --filter @acme/ui add lodash installe dans un package spécifique, ou que pnpm turbo build --filter=apps/web... ne builde que ce qui est nécessaire. Une simple section « Aide-mémoire Monorepo » dans votre guide de contribution fait gagner du temps à tout le monde.

  5. Appliquer la direction de dépendance dès le premier jour. Si vous permettez ne serait-ce qu'un seul import d'une application vers un package, la frontière s'érode vite. Ajoutez une règle de lint ou un check CI. La direction est apps → packages → packages. Jamais l'inverse.

Le monorepo n'est pas le but. Le but est de livrer des fonctionnalités sans lutter contre sa propre base de code. Turborepo est l'outil le plus léger que j'ai trouvé pour que cela fonctionne. Il gère le graphe de tâches, il gère le cache, et il ne vous gêne pas pour tout le reste.

Commencez simple. Ajoutez de la complexité quand le dépôt l'exige. Et attribuez vos ports.

Articles similaires