Turborepo Monorepo's: De Setup Die Echt Schaalt
Hoe ik Turborepo-monorepo's structureer voor meerdere apps die packages delen. Workspace-configuratie, pipeline-caching, gedeelde TypeScript-configs en de fouten die ik in de eerste maand maakte.
Ik heb de eerste drie jaar van mijn carrière utility-functies gekopieerd tussen repositories. Geen overdrijving. Ik had een formatDate.ts die in zeven verschillende projecten leefde, elk met net iets andere bugs. Als ik het timezone-probleem in de ene fixte, vergat ik de andere zes. Uiteindelijk maakte een gebruiker in Australië een ticket aan en ontdekte ik dat de fix nooit in productie was terechtgekomen in dat specifieke project.
De monorepo loste dit op. Niet omdat het trendy is, niet omdat Google het doet, maar omdat ik het zat was om een menselijke package registry te zijn. Eén repository, gedeelde code, atomaire wijzigingen in elke app die ervan afhankelijk is.
Maar monorepo's hebben hun eigen faalpatronen. Ik heb drie verschillende tools geprobeerd, weken verspild aan kapotte caching, tot middernacht gevochten met circulaire dependency-fouten, en geleerd dat "stop gewoon alles in één repo" ongeveer zo nuttig is als advies kan zijn zonder de details.
Dit is de Turborepo-setup die ik daadwerkelijk gebruik. Er draaien vier productie-apps op met twaalf gedeelde packages. Builds duren minder dan 90 seconden dankzij remote caching. Nieuwe developers kunnen clonen en pnpm dev draaien en alles werkend hebben binnen twee minuten. Het kostte me ongeveer een maand van fouten om hier te komen.
Waarom Überhaupt een Monorepo#
Laten we eerlijk zijn over de afwegingen. Een monorepo is niet gratis. Je ruilt de ene set problemen in voor een andere, en je moet weten wat je koopt.
Wat Je Krijgt#
Code delen zonder publiceren. Dit is de grote. Je schrijft een React component-library in packages/ui. Je webapp, je admin-dashboard en je docs-site importeren er allemaal uit. Als je een button-component wijzigt, pikt elke app het meteen op. Geen versies bumpen, geen npm publish, geen "welke versie gebruikt productie ook alweer?"
// 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";Deze imports zien eruit als externe packages. Ze worden opgelost via workspace-dependencies. Maar ze wijzen naar broncode in dezelfde repository.
Atomaire cross-package wijzigingen. Stel je voor dat je een isLoading prop moet toevoegen aan je gedeelde Button-component. In een polyrepo-wereld zou je de component-library wijzigen, een nieuwe versie publiceren, de versie bijwerken in elke app en vervolgens PR's openen in elke repo. Dat zijn drie tot vijf PR's voor één prop.
In een monorepo is het één 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)
Eén PR. Eén review. Eén merge. Alles blijft in sync omdat het fysiek niet uit de pas kan lopen.
Geünificeerde tooling. Eén ESLint-config. Eén Prettier-config. Eén TypeScript-basisconfiguratie. Eén CI-pipeline. Als je ESLint upgradet, upgrade je het overal in één middag, niet over drie sprints verspreid over acht repo's.
Wat Je Betaalt#
Complexiteit aan de root. Je package.json in de root wordt infrastructuur. Je CI-pipeline moet begrijpen welke packages zijn gewijzigd. Je IDE kan worstelen met het enorme aantal bestanden. Git-operaties vertragen naarmate de repo groeit (hoewel dit voor de meeste teams jaren duurt).
CI-tijd kan exploderen. Als je alles bouwt bij elke commit, verspil je enorme hoeveelheden rekenkracht. Je hebt een tool nodig die de dependency graph begrijpt en alleen bouwt wat gewijzigd is. Dat is de hele reden dat Turborepo bestaat.
Onboarding-frictie. Nieuwe developers moeten workspaces, hoisting, interne packages en de build-pipeline begrijpen. Het is niet gewoon "clonen en draaien." Dat zou het uiteindelijk wel moeten zijn, maar daar komen kost bewuste inspanning.
De eerlijke beoordeling: als je één app hebt zonder gedeelde code, is een monorepo overhead zonder voordeel. Als je twee of meer apps hebt die iets delen — componenten, utilities, types, configs — verdient de monorepo zichzelf terug binnen de eerste maand.
Turborepo vs Nx vs Lerna#
Ik heb alle drie gebruikt. Hier is de korte versie.
Lerna was de oorspronkelijke monorepo-tool voor JavaScript. Het beheert versioning en publicatie. Maar het begrijpt geen build-pipelines of caching. Het was verlaten, nieuw leven ingeblazen door Nx, en voelt nu meer als een compatibiliteitslaag dan een zelfstandige tool. Als je packages naar npm moet publiceren, kan Lerna + Nx dat aan. Maar voor interne monorepo's waar je alleen code deelt tussen je eigen apps, is het meer ceremonie dan nodig.
Nx is krachtig. Echt krachtig. Het heeft generators, plugins voor elk framework, een visuele dependency graph, gedistribueerde taakuitvoering. Het heeft ook een leercurve die eruitziet als een steile rotswand. Ik heb teams gezien die twee weken besteedden aan het configureren van Nx voordat ze ook maar één regel productcode schreven. Als je bij een bedrijf zit met 50+ developers en honderden packages, is Nx waarschijnlijk de juiste keuze. Voor mijn use cases is het een bulldozer terwijl ik een schop nodig heb.
Turborepo doet drie dingen goed: het begrijpt je dependency graph, het cachet build-outputs en het draait taken parallel. Dat is het. De volledige configuratie is één turbo.json-bestand. Je kunt het hele ding in twee minuten lezen. Het genereert geen code, het heeft geen plugins, het probeert niet je build tool te vervangen. Het is een task runner die heel, heel goed is in weten wat er moet draaien en wat kan worden overgeslagen.
Ik koos Turborepo omdat ik de complete setup aan een nieuw teamlid kon uitleggen in 15 minuten. Met Nx duurde datzelfde gesprek een uur en hadden ze nog steeds vragen.
Feature | Turborepo | Nx | Lerna
------------------+---------------+-----------------+----------------
Config complexity | Low (1 file) | High (multiple) | Medium
Learning curve | ~1 day | ~1 week | ~2 days
Build caching | Yes (remote) | Yes (remote) | No (native)
Task orchestration| Yes | Yes | Basic
Code generation | No | Yes (extensive) | No
Framework plugins | No | Yes | No
Best for | Small-medium | Large orgs | Publishing
Repository-structuur#
Hier is de werkelijke directorystructuur. Geen "getting started"-voorbeeld — dit is hoe een productie-monorepo eruitziet na zes maanden.
acme-monorepo/
├── apps/
│ ├── web/ # Main customer-facing app
│ │ ├── src/
│ │ ├── public/
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── admin/ # Internal admin dashboard
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── docs/ # Documentation site
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── api/ # Express/Fastify API server
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ ├── ui/ # Shared React component library
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Input.tsx
│ │ │ │ ├── Modal.tsx
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── utils/ # Shared utility functions
│ │ ├── src/
│ │ │ ├── date.ts
│ │ │ ├── string.ts
│ │ │ ├── validation.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── types/ # Shared TypeScript types
│ │ ├── src/
│ │ │ ├── user.ts
│ │ │ ├── api.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── config-typescript/ # Shared tsconfig presets
│ │ ├── base.json
│ │ ├── nextjs.json
│ │ ├── node.json
│ │ └── package.json
│ ├── config-eslint/ # Shared ESLint configurations
│ │ ├── base.js
│ │ ├── next.js
│ │ ├── node.js
│ │ └── package.json
│ └── config-tailwind/ # Shared Tailwind presets
│ ├── tailwind.config.ts
│ └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json
De apps/ vs packages/ Scheiding#
Dit is het fundamentele organisatieprincipe. apps/ bevat deploybare dingen — webapps, API's, alles met een dev- of start-commando. packages/ bevat libraries — code die bestaat om geconsumeerd te worden door apps of andere packages.
De regel is simpel: apps consumeren packages. Packages importeren nooit uit apps. Packages kunnen uit andere packages importeren.
Als een package begint te importeren uit een app, heb je de dependency graph achterstevoren en Turborepo zal het als een cyclus aanmerken.
Workspace-configuratie#
De root pnpm-workspace.yaml vertelt pnpm waar packages te vinden zijn:
packages:
- "apps/*"
- "packages/*"Dat is het hele bestand. pnpm scant die directories, leest elke package.json en maakt een workspace-map. Als apps/web "@acme/ui": "workspace:*" declareert als dependency, linkt pnpm het naar de lokale packages/ui in plaats van in het npm-register te zoeken.
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"
}
}Let op: de root package.json heeft geen runtime-dependencies. Het is puur orchestratie. turbo is de task runner, prettier handelt formatting af (omdat het de enige tool is die geen per-package-configuratie nodig heeft), en typescript wordt gehoistdat zodat elk package dezelfde versie gebruikt.
Naamconventies#
Elk package krijgt een scoped naam: @acme/ui, @acme/utils, @acme/config-typescript. De scope voorkomt botsingen met npm-packages en maakt in elke import-statement meteen duidelijk of je interne of externe code gebruikt.
// External package - from npm
import { clsx } from "clsx";
// Internal package - from our monorepo
import { Button } from "@acme/ui";Ik prefix config-packages met config- om ze visueel te groeperen: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Sommige teams gebruiken @acme/tsconfig, @acme/eslint-config. Beide werken. Wees gewoon consistent.
turbo.json Pipeline-configuratie#
Dit is waar Turborepo zijn waarde bewijst. Het turbo.json-bestand definieert je taakpipeline — welke taken er zijn, waar ze van afhangen en wat ze produceren.
{
"$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
}
}
}Laat me elk veld uitleggen want dit is waar de meeste mensen in de war raken.
dependsOn en de ^ Notatie#
"dependsOn": ["^build"] betekent: "Voordat je deze taak draait in package X, draai eerst build in elk package waar X van afhankelijk is."
Het ^-symbool betekent "upstream dependencies." Zonder het ^ zou "dependsOn": ["build"] betekenen: "draai build in hetzelfde package eerst" — handig als je test-taak eerst een build nodig heeft binnen hetzelfde package.
Hier is een concreet voorbeeld. Je dependency graph ziet er zo uit:
apps/web → @acme/ui → @acme/utils
→ @acme/types
Wanneer je turbo build draait, lost Turborepo de graph op:
- Build
@acme/types(geen dependencies) - Build
@acme/utils(geen dependencies) - Build
@acme/ui(hangt af van types en utils — wacht op hen) - Build
apps/web(hangt af van ui — wacht erop)
Stappen 1 en 2 draaien parallel. Stap 3 wacht op beide. Stap 4 wacht op 3. Turborepo leidt dit af uit je package.json dependency-declaraties. Je hoeft de volgorde niet handmatig te specificeren.
outputs#
Dit is cruciaal voor caching. Wanneer Turborepo een build cachet, slaat het de bestanden op die in outputs staan. Bij een cache hit herstelt het die bestanden. Als je vergeet een output-directory op te nemen, zal de cache "slagen" maar je bestanden verschijnen niet. Dit was mijn grootste fout in de eerste week — ik miste .next/** en kon maar niet uitvogelen waarom mijn Next.js-app steeds opnieuw bouwde.
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]De !.next/cache/**-uitsluiting is belangrijk. Next.js heeft zijn eigen cache in .next/cache/. Je wilt geen cache van een cache opslaan — het blaast je remote cache storage op en helpt niet.
inputs#
Standaard hasht Turborepo elk bestand in een package om te bepalen of het gewijzigd is. Het inputs-veld beperkt dat. Als alleen bestanden in src/ de build-output beïnvloeden, dan zou het wijzigen van README.md de cache niet moeten invalideren.
"inputs": ["src/**", "package.json", "tsconfig.json"]Wees hier voorzichtig mee. Als je een config-bestand toevoegt dat je build beïnvloedt (zeg postcss.config.js) en het niet opneemt in inputs, krijg je verouderde gecachte output. Bij twijfel, laat inputs leeg en laat Turborepo alles hashen.
globalDependencies#
Bestanden die hier staan invalideren de cache voor elk package wanneer ze wijzigen. Omgevingsbestanden zijn het klassieke voorbeeld — als je .env.local wijzigt, moet alles dat van process.env kan lezen opnieuw gebouwd worden.
"globalDependencies": ["**/.env.*local"]Ik voeg ook tsconfig.json op root-niveau hier toe omdat mijn basis TypeScript-config alle packages beïnvloedt:
"globalDependencies": [
"**/.env.*local",
"tsconfig.json"
]persistent en dev#
De dev-taak heeft "cache": false en "persistent": true. Een dev-server cachen slaat nergens op — het is een langlopend proces. De persistent-flag vertelt Turborepo dat deze taak niet stopt, dus het moet er niet op wachten voordat het andere taken draait.
Als je turbo dev draait, start Turborepo alle dev-servers parallel — je Next.js-app, je API-server, je docs-site — allemaal in één terminal met door elkaar lopende output. Elke regel is geprefixed met de packagenaam zodat je ze uit elkaar kunt houden.
Gedeelde Packages in Detail#
packages/ui — De Component-library#
Dit is het package dat elk team als eerste bouwt. Gedeelde React-componenten die in alle frontend-apps worden gebruikt.
{
"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"
}
}Een paar dingen om op te merken:
"version": "0.0.0" — Interne packages hebben geen echte versies nodig. Het workspace:* protocol vertelt pnpm om altijd de lokale kopie te gebruiken. Versienummers zijn irrelevant.
"private": true — Dit package wordt nooit naar npm gepubliceerd. Als je het ooit wilt publiceren, verwijder dan deze vlag en stel een goede versioning-strategie in.
Het exports-veld — Dit is de moderne manier om package-entrypoints te definiëren. Het vervangt main, module en types-velden. De "."-export is het standaard importpad. Je kunt sub-path exports toevoegen voor granulaire imports:
{
"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"
}
}Belangrijke beslissing: builden of niet? Sommige teams slaan de buildstap helemaal over voor interne packages. In plaats van te compileren met tsup, wijzen ze de exports direct naar de TypeScript-broncode en laten ze de bundler van de consumerende app (Next.js, Vite) de compilatie afhandelen. Dit heet "internal packages" in Turborepo's terminologie en het is simpeler:
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
}
}Geen buildstap. Geen dist-map. De next.config.ts van de consumerende app heeft transpilePackages nodig:
// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
};
export default nextConfig;Ik gebruik de no-build-aanpak voor de meeste interne packages. Het is sneller tijdens ontwikkeling en eentje minder dat kan breken. De uitzondering is packages die niet-TypeScript-consumers moeten ondersteunen of complexe build-vereisten hebben.
packages/utils — Gedeelde Utilities#
{
"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"
}
}De sub-path exports laten consumerende apps alleen importeren wat ze nodig hebben:
// Only imports date utilities, not the entire package
import { formatRelativeDate, parseISO } from "@acme/utils/date";
// Imports everything (barrel export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";De barrel export in src/index.ts exporteert alles opnieuw:
// packages/utils/src/index.ts
export * from "./date";
export * from "./string";
export * from "./validation";Een waarschuwing over barrel exports: ze zijn handig maar ze kunnen tree-shaking om zeep helpen in sommige bundlers. Als apps/web één functie importeert uit @acme/utils, kan een naïeve bundler alles meenemen. Next.js handelt dit goed af met zijn ingebouwde tree-shaking, maar als je problemen merkt met bundle-grootte, is het sub-path exports patroon veiliger.
packages/types — Gedeelde TypeScript Types#
{
"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"
}
}Dit package heeft nul runtime-dependencies. Het bevat alleen TypeScript types en interfaces. Het hoeft nooit gebuild te worden omdat types worden gewist bij compilatie.
// 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 — Gedeelde TSConfigs#
Deze is bedrieglijk belangrijk. TypeScript-configuratie in een monorepo is waar het snel ingewikkeld wordt.
{
"name": "@acme/config-typescript",
"version": "0.0.0",
"private": true,
"files": [
"base.json",
"nextjs.json",
"node.json",
"react-library.json"
]
}De basisconfiguratie die alle packages uitbreiden:
// 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"]
}De Next.js-specifieke config:
// 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" }
]
}
}De Node.js-config voor backend-packages:
// 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"
}
}Nu breidt elke app of package het juiste preset uit:
// 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"]
}De @/* Path Alias#
Elke app krijgt dezelfde @/* path alias die naar zijn eigen src/-directory wijst. Dit betekent dat @/components/Header in apps/web verwijst naar apps/web/src/components/Header, en @/components/Header in apps/admin verwijst naar apps/admin/src/components/Header.
Dit zijn app-lokale paden. Ze kruisen geen package-grenzen. Voor cross-package imports gebruik je altijd de packagenaam: @acme/ui, niet @/../../packages/ui. Als je ooit een relatieve import ziet die boven de package-root uitkomt (../../packages/something), is dat een code smell. Gebruik in plaats daarvan de workspace dependency.
composite en Project References#
TypeScript project references (composite: true) stellen tsc in staat om packages incrementeel te builden en cross-project dependencies te begrijpen. Dit is optioneel met Turborepo — Turborepo handelt de build-orchestratie zelf af — maar het kan type checking in je IDE versnellen.
// packages/ui/tsconfig.json (with composite)
{
"extends": "@acme/config-typescript/base",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src"],
"references": [
{ "path": "../types" },
{ "path": "../utils" }
]
}Ik zal eerlijk zijn: project references voegen complexiteit toe en ik heb ze uit de meeste van mijn setups verwijderd. Turborepo's --filter en dependsOn handelen de buildvolgorde al af. Ik voeg composite: true alleen toe als IDE-prestaties een probleem worden (meestal bij 15+ packages).
packages/config-eslint — Gedeelde Linting#
// 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",
},
},
];Elke app verwijst naar zijn config:
// apps/web/eslint.config.mjs
import nextConfig from "@acme/config-eslint/next";
export default [
...nextConfig,
{
ignores: [".next/"],
},
];Remote Caching#
Lokale caching is fijn — het voorkomt dat je ongewijzigde packages opnieuw bouwt tijdens ontwikkeling. Maar remote caching is transformerend. Het betekent dat je CI-server profiteert van je lokale builds en omgekeerd.
Hoe Het Werkt#
Wanneer Turborepo een taak draait, berekent het een hash op basis van:
- De bronbestanden (content hash)
- De
inputs-configuratie van de taak - Omgevingsvariabelen
- De hashes van upstream dependencies
Als de hash overeenkomt met een eerder gecacht resultaat, herstelt Turborepo de outputs uit de cache in plaats van de taak uit te voeren. Met remote caching worden die gecachte outputs opgeslagen op een gedeelde locatie die elke machine — je laptop, die van je collega, CI — kan benaderen.
Vercel Remote Cache#
De simpelste setup. Vercel onderhoudt de cache-infrastructuur gratis (met limieten):
# Login to Vercel (one-time setup)
npx turbo login
# Link your repo to a Vercel project
npx turbo linkDat is het. Turborepo pusht en pullt nu cache-artefacten van Vercel's servers. Op CI stel je omgevingsvariabelen in:
# .github/workflows/ci.yml
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Genereer het token vanuit het Vercel-dashboard onder je teaminstellingen. De TURBO_TEAM is je team-slug.
Self-Hosted Remote Cache#
Als je Vercel niet kunt gebruiken (air-gapped omgeving, data residency-vereisten), kun je zelf hosten. De Turborepo remote cache API is goed gedocumenteerd en er zijn meerdere open-source implementaties:
- ducktors/turborepo-remote-cache — Een Node.js-server die artefacten opslaat in S3, GCS of lokaal bestandssysteem
- fox1t/turborepo-remote-cache — Vergelijkbaar, met Azure Blob-ondersteuning
# docker-compose.yml for self-hosted cache
services:
turbo-cache:
image: ducktors/turborepo-remote-cache:latest
ports:
- "3000:3000"
environment:
TURBO_TOKEN: "your-secret-token"
STORAGE_PROVIDER: "s3"
S3_ACCESS_KEY: "${AWS_ACCESS_KEY_ID}"
S3_SECRET_KEY: "${AWS_SECRET_ACCESS_KEY}"
S3_REGION: "eu-central-1"
S3_ENDPOINT: "https://s3.eu-central-1.amazonaws.com"
S3_BUCKET: "turbo-cache"Configureer dan je monorepo om het te gebruiken:
// .turbo/config.json
{
"teamId": "team_acme",
"apiUrl": "https://turbo-cache.internal.acme.com"
}Cache Hit Rates#
Naar mijn ervaring haalt een goed geconfigureerde Turborepo 80-95% cache hit rates in CI. Dat betekent dat slechts 5-20% van de taken daadwerkelijk draait in een willekeurige CI-pipeline. De rest wordt in seconden uit de cache hersteld.
De sleutels tot hoge cache hit rates:
- Beperk je
inputs. Als alleensrc/de build beïnvloedt, laat een README-wijziging dan niet de cache invalideren. - Zet geen vluchtige data in
globalDependencies. Een.env-bestand dat bij elke deploy wijzigt, vermoord je cache. - Pin je dependencies. Lockfile-wijzigingen invalideren alles downstream.
- Houd packages klein en gefocust. Een gigantisch
packages/utilsmet 200 bestanden krijgt zijn cache vaak geïnvalideerd omdat elke wijziging aan een willekeurige utility het hele package ongeldig maakt.
CI/CD met Turborepo#
Hier is de GitHub Actions workflow die ik gebruik. Het is verfijnd over tientallen iteraties.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
ci:
name: Build, Lint, Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2 # Need parent commit for change detection
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo build
- name: Lint
run: pnpm turbo lint
- name: Type Check
run: pnpm turbo type-check
- name: Test
run: pnpm turbo testDe --filter Flag#
De --filter flag is hoe je taken draait voor specifieke packages of op basis van wat er gewijzigd is. Dit is essentieel voor efficiënte CI:
# Build only the web app and its dependencies
pnpm turbo build --filter=apps/web...
# Build only packages that changed since main
pnpm turbo build --filter=...[main]
# Build only @acme/ui and everything that depends on it
pnpm turbo build --filter=...@acme/ui
# Build only packages in the packages/ directory
pnpm turbo build --filter="./packages/*"De drie-punten-syntax is belangrijk:
--filter=@acme/ui...— Het package en al zijn dependencies (dingen die het nodig heeft)--filter=...@acme/ui— Het package en al zijn dependents (dingen die het nodig hebben)--filter=@acme/ui— Alleen het package zelf
Affected Packages Detectie#
Voor pull requests wil je waarschijnlijk niet alles builden. Je wilt alleen builden wat geraakt is door de wijzigingen:
# Only build/test what changed compared to main
- name: Build affected
run: pnpm turbo build --filter="...[origin/main]"
- name: Test affected
run: pnpm turbo test --filter="...[origin/main]"De [origin/main]-syntax vertelt Turborepo om de huidige commit te vergelijken met origin/main en alleen taken te draaien voor packages die wijzigingen hebben. Gecombineerd met remote caching zijn de meeste PR-pipelines klaar in minder dan 2 minuten, zelfs voor grote monorepo's.
Matrix-strategie voor Deployments#
Als elke app onafhankelijk deployt, gebruik dan een matrix-strategie:
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 }}Elke matrixjob bouwt één app en zijn dependencies. Dankzij remote caching worden de gedeelde packages eenmaal gebouwd en gecachet — elke volgende matrixjob herstelt ze vanuit de cache.
Veelvoorkomende Valkuilen#
Ik wijd een hele sectie hieraan omdat ik elke enkele van deze heb meegemaakt in mijn eerste maand. Leren van documentatie is efficiënt. Leren van mijn fouten is gratis.
1. Circulaire Dependencies#
De dependency graph moet een DAG zijn (directed acyclic graph). Als @acme/ui importeert uit @acme/utils en @acme/utils importeert uit @acme/ui, heb je een cyclus. Turborepo klaagt en weigert te builden.
De fix is bijna altijd om de gedeelde code te extraheren naar een derde package:
VOOR (kapot):
@acme/ui → @acme/utils → @acme/ui ← cyclus!
NA (gefixt):
@acme/ui → @acme/types
@acme/utils → @acme/types
Verplaats de gedeelde types of constanten naar @acme/types en laat beide packages ervan afhangen.
Een andere veelvoorkomende oorzaak: je zet een React hook in @acme/utils die een component importeert uit @acme/ui. Hooks die afhankelijk zijn van UI-componenten horen in @acme/ui, niet in @acme/utils. Utilities moeten waar mogelijk framework-agnostisch zijn.
2. Missende outputs Die Cache Invalideren#
Dit is de meest frustrerende bug. Alles lijkt lokaal te werken. CI-builds slagen. Maar elke build duurt de volle tijd omdat niets wordt gecachet.
Scenario: je package bouwt naar build/ in plaats van dist/, maar je turbo.json vermeldt alleen dist/** in outputs:
// turbo.json
"build": {
"outputs": ["dist/**"] // but your package builds to build/
}Turborepo cachet de taak (omdat hij succesvol was) maar slaat geen outputbestanden op. De volgende keer krijgt het een cache "hit" en herstelt niets. De consumerende app kan de build-output niet vinden en faalt, of — erger — gebruikt stilletjes verouderde bestanden.
Fix: audit de build-output-directory van elk package en zorg dat turbo.json ze allemaal dekt:
"build": {
"outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}Of gebruik per-package overrides in turbo.json:
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
},
// Per-package overrides
"packages": {
"apps/web": {
"build": {
"outputs": [".next/**", "!.next/cache/**"]
}
},
"apps/api": {
"build": {
"outputs": ["build/**"]
}
}
}
}3. Dev Server-coördinatie#
turbo dev draaien start alle dev-servers tegelijk. Dat is prima totdat je apps dezelfde poort proberen te gebruiken. Expliciete poorttoewijzing is verplicht:
// apps/web/package.json
"scripts": {
"dev": "next dev --port 3000"
}
// apps/admin/package.json
"scripts": {
"dev": "next dev --port 3001"
}
// apps/docs/package.json
"scripts": {
"dev": "next dev --port 3002"
}
// apps/api/package.json
"scripts": {
"dev": "tsx watch src/index.ts" // Uses port 4000 from config
}Nog een probleem: hot module replacement voor gedeelde packages. Als je een bestand in packages/ui bewerkt, moet de wijziging doorgegeven worden aan de draaiende Next.js dev-server. Als je de "internal packages"-aanpak gebruikt (geen buildstap, directe TypeScript imports), werkt dit automatisch — Next.js bewaakt de bronbestanden via de gesymlinkte node_modules.
Als je package een buildstap heeft, moet het dev-script van het package in watch-modus draaien:
// packages/ui/package.json
"scripts": {
"dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch"
}Turborepo draait alle dev-taken parallel, dus het package herbouwt bij wijzigingen en de consumerende app pikt de nieuwe output op.
4. Versiedrift Tussen Packages#
Dit is subtiel en gevaarlijk. Als apps/web React 19 gebruikt en apps/admin per ongeluk React 18, merk je het misschien pas als een component uit @acme/ui zich anders gedraagt in elke app.
Oplossing: beheer gedeelde dependencies op root-niveau. Het overrides-veld van pnpm in de root package.json forceert overal dezelfde versie:
// Root package.json
{
"pnpm": {
"overrides": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
}
}
}Draai pnpm ls react --recursive regelmatig om te verifiëren dat alles dezelfde versie gebruikt.
5. Omgevingsvariabelen in Gedeelde Packages#
Gedeelde packages moeten niet direct process.env lezen. Als @acme/utils process.env.API_URL leest, creëert het een onzichtbare koppeling met de omgeving van de consumerende app. Geef configuratie in plaats daarvan expliciet door:
// BAD: packages/utils/src/api.ts
const API_URL = process.env.API_URL; // What env? Which app?
export function fetchData(path: string) {
return fetch(`${API_URL}${path}`);
}
// GOOD: packages/utils/src/api.ts
export function createApiClient(baseUrl: string) {
return {
fetch: (path: string) => fetch(`${baseUrl}${path}`),
};
}De consumerende app levert de configuratie:
// apps/web/src/lib/api.ts
import { createApiClient } from "@acme/utils";
export const api = createApiClient(process.env.NEXT_PUBLIC_API_URL!);Dit houdt packages puur en testbaar.
6. Ghost Dependencies#
pnpm is standaard streng over dependencies — het hoist packages niet zoals npm dat doet. Dit is een feature, geen bug. Het betekent dat als @acme/ui clsx importeert maar het niet vermeldt in zijn package.json, pnpm een fout gooit. npm zou het stilletjes oplossen vanuit een bovenliggende node_modules.
Declareer altijd elke import in de package.json van het consumerende package. Niet meer leunen op hoisting.
7. IDE-prestatieverslechtering#
Met 15+ packages kan je TypeScript language server beginnen te worstelen. Enkele tips:
- Voeg
"exclude": ["node_modules", "**/dist/**"]toe aan je roottsconfig.json - Gebruik VS Code's "Files: Exclude" om
dist/,.next/en.turbo/mappen te verbergen - Overweeg
"disableSourceOfProjectReferenceRedirect": truein tsconfig als Go to Definition je naar.d.tsbestanden diep innode_modulesstuurt
De Starterstructuur: Alles Samen#
Laat me het allemaal samenvoegen. Hier is elk bestand dat je nodig hebt om een Turborepo-monorepo op te zetten met twee Next.js-apps en drie gedeelde packages.
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 — for IDE only, not used by builds)
{
"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";
// Re-export types
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#
# Dependencies
node_modules/
# Build outputs
dist/
build/
.next/
out/
# Turborepo
.turbo/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/settings.json
.idea/
# OS
.DS_Store
Thumbs.db
Vanaf Nul Beginnen#
Als je helemaal opnieuw begint, is dit de exacte reeks commando's:
# Create the repo
mkdir acme-monorepo && cd acme-monorepo
git init
# Initialize the root package
pnpm init
# Create the workspace file
echo 'packages:\n - "apps/*"\n - "packages/*"' > pnpm-workspace.yaml
# Install Turborepo
pnpm add -D turbo -w
# Create directories
mkdir -p apps/web apps/admin
mkdir -p packages/ui/src packages/utils/src packages/types/src
mkdir -p packages/config-typescript packages/config-eslint
# Initialize apps (from their directories)
cd apps/web && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../admin && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../..
# Add workspace dependencies
cd packages/ui && pnpm add @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
cd apps/web && pnpm add @acme/ui@workspace:* @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
# Install everything
pnpm install
# Verify it works
pnpm turbo build
pnpm turbo devDe eerste pnpm turbo build zal langzaam zijn — alles wordt helemaal opnieuw gebouwd. De tweede zou bijna instant moeten zijn als er niets is gewijzigd. Dat is de cache die werkt.
Opschalen Voorbij de Basis#
Als je voorbij de initiële setup bent, ontstaan er een paar patronen naarmate de monorepo groeit.
Package Generators#
Na je tiende package wordt het aanmaken van een nieuwe door kopiëren en bewerken vervelend. Maak een 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
# Creates packages/email with all the boilerplateWorkspace Dependency Graph Visualisatie#
Turborepo heeft een ingebouwd graph-commando:
pnpm turbo build --graph=graph.svgDit genereert een SVG van je dependency graph. Ik draai dit voor grote refactors om de impact van een wijziging te begrijpen. Als het aanraken van @acme/types 12 packages herbouwt, is het misschien tijd om het op te splitsen in @acme/types-user en @acme/types-billing.
Pruning voor Docker#
Bij het deployen van een enkele app vanuit de monorepo wil je niet de hele repo kopiëren naar je Docker-image. Turborepo heeft een prune-commando:
pnpm turbo prune --scope=apps/web --dockerDit genereert een out/-directory met alleen de bestanden die nodig zijn voor apps/web en zijn dependencies. Je Dockerfile gebruikt deze geprunede output:
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"]Het Docker-image bevat alleen de webapp en zijn dependencies. Geen admin-app, geen API-server, geen ongebruikte packages. Image-groottes blijven redelijk, zelfs als de monorepo groeit.
Een Maand Later: Wat Ik Anders Zou Doen#
Als ik opnieuw zou beginnen, wetende wat ik nu weet:
-
Begin met internal packages (geen buildstap). Ik verspilde twee weken aan het configureren van tsup-builds voor packages die alleen door mijn Next.js-apps werden geconsumeerd.
transpilePackagesin Next.js-config is simpeler en sneller. Voeg pas een buildstap toe als je er een nodig hebt. -
Houd het aantal
packages/in het begin laag. Ik splitste te agressief in het begin.packages/utils,packages/typesenpackages/uizijn genoeg om te starten. Je kunt altijd later splitsen als een package onhandelbaar wordt. Te vroeg splitsen betekent meerpackage.json-bestanden om te onderhouden en meer verbindingen in je dependency graph. -
Stel remote caching in op dag één.
npx turbo login && npx turbo linkkost 30 seconden. De tijdsbesparing op builds in alleen de eerste week rechtvaardigt het al. -
Documenteer de workspace-commando's. Nieuwe developers weten niet dat
pnpm --filter @acme/ui add lodashin een specifiek package installeert, of datpnpm turbo build --filter=apps/web...alleen bouwt wat nodig is. Een simpele "Monorepo Cheatsheet"-sectie in je contributing guide bespaart iedereen tijd. -
Dwing de dependency-richting af vanaf dag één. Als je ook maar één import van een app naar een package toestaat, erodeert de grens snel. Voeg een lint-regel of CI-check toe. De richting is
apps → packages → packages. Nooit andersom.
De monorepo is niet het doel. Het doel is features leveren zonder tegen je eigen codebase te vechten. Turborepo is de lichtgewichtste tool die ik heb gevonden om dat te laten werken. Het doet de taakgraph, het doet de caching, en het blijft uit de weg voor al het andere.
Begin simpel. Voeg complexiteit toe wanneer de repo erom vraagt. En pin je poorten.