Zum Inhalt springen
·29 Min. Lesezeit

Turborepo-Monorepos: Das Setup, das tatsächlich skaliert

Wie ich Turborepo-Monorepos für mehrere Apps mit geteilten Paketen strukturiere. Workspace-Konfiguration, Pipeline-Caching, geteilte TypeScript-Configs und die Fehler, die ich im ersten Monat gemacht habe.

Teilen:X / TwitterLinkedIn

Ich habe die ersten drei Jahre meiner Karriere damit verbracht, Utility-Funktionen zwischen Repositories zu kopieren. Keine Übertreibung. Ich hatte eine formatDate.ts, die in sieben verschiedenen Projekten lebte, jede mit leicht unterschiedlichen Bugs. Wenn ich das Zeitzonen-Problem in einem fixte, vergaß ich die anderen sechs. Irgendwann meldete ein Nutzer in Australien ein Ticket und ich entdeckte, dass der Fix es in dieser bestimmten App nie in die Produktion geschafft hatte.

Das Monorepo hat das gelöst. Nicht weil es trendy ist, nicht weil Google es macht, sondern weil ich es leid war, eine menschliche Package-Registry zu sein. Ein Repository, geteilter Code, atomare Änderungen über jede App, die ihn konsumiert.

Aber Monorepos haben ihre eigenen Fehlermodi. Ich habe drei verschiedene Tools ausprobiert, Wochen mit gebrochenem Caching verschwendet, Circular-Dependency-Fehler um Mitternacht bekämpft und gelernt, dass „pack einfach alles in ein Repo" ohne die Details ungefähr so nützlich ist wie Ratschläge eben werden.

Das ist das Turborepo-Setup, das ich tatsächlich verwende. Es betreibt vier Produktions-Apps mit zwölf geteilten Paketen. Builds dauern unter 90 Sekunden dank Remote Caching. Neue Entwickler können klonen und pnpm dev ausführen und haben alles in unter zwei Minuten am Laufen. Es hat mich etwa einen Monat an Fehlern gekostet, hier anzukommen.

Warum überhaupt ein Monorepo#

Seien wir ehrlich über die Kompromisse. Ein Monorepo ist nicht umsonst. Du tauschst eine Menge Probleme gegen eine andere, und du musst wissen, was du dir einhandelst.

Was du bekommst#

Code-Sharing ohne Veröffentlichung. Das ist der große Vorteil. Du schreibst eine React-Komponentenbibliothek in packages/ui. Deine Web-App, dein Admin-Dashboard und deine Docs-Seite importieren alle daraus. Wenn du eine Button-Komponente änderst, übernehmen alle Apps die Änderung sofort. Kein Versionsnummern-Hochzählen, kein npm-Publish, kein „welche Version läuft nochmal in Produktion?"

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

Diese Imports sehen aus wie externe Pakete. Sie werden über Workspace-Abhängigkeiten aufgelöst. Aber sie zeigen auf Quellcode im selben Repository.

Atomare paketübergreifende Änderungen. Stell dir vor, du musst eine isLoading-Prop zu deiner geteilten Button-Komponente hinzufügen. In einer Polyrepo-Welt würdest du die Komponentenbibliothek ändern, eine neue Version veröffentlichen, die Version in jeder konsumierenden App aktualisieren und dann PRs in jedem Repo öffnen. Das sind drei bis fünf PRs für eine einzige Prop.

In einem Monorepo ist es ein einziger Commit:

feat: isLoading-Prop zur Button-Komponente hinzufügen

- packages/ui/src/Button.tsx (Prop hinzufügen, Spinner rendern)
- apps/web/src/pages/checkout.tsx (isLoading verwenden)
- apps/admin/src/pages/users.tsx (isLoading verwenden)

Ein PR. Ein Review. Ein Merge. Alles bleibt synchron, weil es physisch nicht auseinanderdriften kann.

Einheitliches Tooling. Eine ESLint-Konfiguration. Eine Prettier-Konfiguration. Eine TypeScript-Basiskonfiguration. Eine CI-Pipeline. Wenn du ESLint aktualisierst, aktualisierst du es überall an einem Nachmittag, nicht über drei Sprints verteilt auf acht Repos.

Was du bezahlst#

Komplexität an der Wurzel. Deine package.json im Root wird zur Infrastruktur. Deine CI-Pipeline muss verstehen, welche Pakete sich geändert haben. Deine IDE könnte mit der schieren Anzahl an Dateien kämpfen. Git-Operationen werden langsamer, je größer das Repo wird (obwohl das bei den meisten Teams Jahre dauert).

CI-Zeit kann explodieren. Wenn du bei jedem Commit alles baust, verschwendest du enorme Mengen an Rechenleistung. Du brauchst ein Tool, das den Abhängigkeitsgraphen versteht und nur das baut, was sich geändert hat. Das ist der gesamte Grund, warum Turborepo existiert.

Onboarding-Reibung. Neue Entwickler müssen Workspaces, Hoisting, interne Pakete und die Build-Pipeline verstehen. Es ist nicht einfach „klonen und ausführen." Nun, das sollte es irgendwann sein, aber dahin zu kommen erfordert bewusste Anstrengung.

Die ehrliche Einschätzung: Wenn du eine App ohne geteilten Code hast, ist ein Monorepo Overhead ohne Nutzen. Wenn du zwei oder mehr Apps hast, die irgendetwas teilen — Komponenten, Utilities, Typen, Konfigurationen — zahlt sich das Monorepo im ersten Monat aus.

Turborepo vs. Nx vs. Lerna#

Ich habe alle drei benutzt. Hier ist die Kurzfassung.

Lerna war das ursprüngliche Monorepo-Tool für JavaScript. Es verwaltet Versionierung und Veröffentlichung. Aber es versteht keine Build-Pipelines oder Caching. Es wurde aufgegeben, von Nx wiederbelebt, und fühlt sich jetzt eher wie eine Kompatibilitätsschicht an als ein eigenständiges Tool. Wenn du Pakete auf npm veröffentlichen musst, kann Lerna + Nx das übernehmen. Aber für interne Monorepos, in denen du nur Code zwischen deinen eigenen Apps teilst, ist es mehr Zeremonie als nötig.

Nx ist mächtig. Wirklich mächtig. Es hat Generatoren, Plugins für jedes Framework, einen visuellen Abhängigkeitsgraphen, verteilte Task-Ausführung. Es hat auch eine Lernkurve, die wie eine Felswand aussieht. Ich habe Teams gesehen, die zwei Wochen nur mit der Konfiguration von Nx verbracht haben, bevor sie irgendeinen Produktcode geschrieben haben. Wenn du in einem Unternehmen mit 50+ Entwicklern und Hunderten von Paketen bist, ist Nx wahrscheinlich die richtige Wahl. Für meine Anwendungsfälle ist es ein Bulldozer, wenn ich eine Schaufel brauche.

Turborepo macht drei Dinge gut: Es versteht deinen Abhängigkeitsgraphen, es cached Build-Ausgaben, und es führt Tasks parallel aus. Das ist alles. Die gesamte Konfiguration ist eine einzige turbo.json-Datei. Du kannst das Ganze in zwei Minuten lesen. Es generiert keinen Code, es hat keine Plugins, es versucht nicht, dein Build-Tool zu ersetzen. Es ist ein Task-Runner, der sehr, sehr gut darin ist zu wissen, was ausgeführt werden muss und was übersprungen werden kann.

Ich habe Turborepo gewählt, weil ich das gesamte Setup einem neuen Teammitglied in 15 Minuten erklären konnte. Mit Nx dauerte dasselbe Gespräch eine Stunde und sie hatten immer noch Fragen.

Feature           | Turborepo     | Nx              | Lerna
------------------+---------------+-----------------+----------------
Konfig-Komplexität| Niedrig (1 D.)| Hoch (mehrere)  | Mittel
Lernkurve         | ~1 Tag        | ~1 Woche        | ~2 Tage
Build-Caching     | Ja (remote)   | Ja (remote)     | Nein (nativ)
Task-Orchestrierung| Ja           | Ja              | Einfach
Code-Generierung  | Nein          | Ja (umfangreich)| Nein
Framework-Plugins | Nein          | Ja              | Nein
Am besten für     | Klein-Mittel  | Große Orgs      | Veröffentlichung

Repository-Struktur#

Hier ist der tatsächliche Verzeichnisbaum. Kein „Erste Schritte"-Beispiel — so sieht ein Produktions-Monorepo nach sechs Monaten aus.

acme-monorepo/
├── apps/
│   ├── web/                    # Kunden-Web-App
│   │   ├── src/
│   │   ├── public/
│   │   ├── next.config.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── admin/                  # Internes Admin-Dashboard
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── docs/                   # Dokumentationsseite
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── api/                    # Express/Fastify-API-Server
│       ├── src/
│       ├── package.json
│       └── tsconfig.json
├── packages/
│   ├── ui/                     # Geteilte React-Komponentenbibliothek
│   │   ├── src/
│   │   │   ├── components/
│   │   │   │   ├── Button.tsx
│   │   │   │   ├── Input.tsx
│   │   │   │   ├── Modal.tsx
│   │   │   │   └── index.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── utils/                  # Geteilte Utility-Funktionen
│   │   ├── src/
│   │   │   ├── date.ts
│   │   │   ├── string.ts
│   │   │   ├── validation.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── types/                  # Geteilte TypeScript-Typen
│   │   ├── src/
│   │   │   ├── user.ts
│   │   │   ├── api.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── config-typescript/      # Geteilte tsconfig-Presets
│   │   ├── base.json
│   │   ├── nextjs.json
│   │   ├── node.json
│   │   └── package.json
│   ├── config-eslint/          # Geteilte ESLint-Konfigurationen
│   │   ├── base.js
│   │   ├── next.js
│   │   ├── node.js
│   │   └── package.json
│   └── config-tailwind/        # Geteilte Tailwind-Presets
│       ├── tailwind.config.ts
│       └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json

Die Aufteilung apps/ vs. packages/#

Das ist das grundlegende Organisationsprinzip. apps/ enthält deploybarer Dinge — Web-Apps, APIs, alles mit einem dev- oder start-Befehl. packages/ enthält Bibliotheken — Code, der existiert, um von Apps oder anderen Paketen konsumiert zu werden.

Die Regel ist einfach: Apps konsumieren Pakete. Pakete importieren niemals aus Apps. Pakete können aus anderen Paketen importieren.

Wenn ein Paket beginnt, aus einer App zu importieren, hast du den Abhängigkeitsgraphen verkehrt herum und Turborepo wird es als Zyklus erkennen.

Workspace-Konfiguration#

Die Root-pnpm-workspace.yaml teilt pnpm mit, wo die Pakete zu finden sind:

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

Das ist die gesamte Datei. pnpm durchsucht diese Verzeichnisse, liest jede package.json und erstellt eine Workspace-Map. Wenn apps/web "@acme/ui": "workspace:*" als Abhängigkeit deklariert, verlinkt pnpm es mit dem lokalen packages/ui, anstatt in der npm-Registry zu suchen.

Root package.json#

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

Beachte: Die Root-package.json hat keine Runtime-Abhängigkeiten. Sie ist rein für die Orchestrierung. turbo ist der Task-Runner, prettier übernimmt die Formatierung (weil es das einzige Tool ist, das keine paketspezifische Konfiguration benötigt), und typescript wird gehoisted, damit jedes Paket die gleiche Version verwendet.

Namenskonventionen#

Jedes Paket bekommt einen Scope-Namen: @acme/ui, @acme/utils, @acme/config-typescript. Der Scope verhindert Kollisionen mit npm-Paketen und macht in jedem Import-Statement sofort offensichtlich, ob du internen oder externen Code verwendest.

typescript
// Externes Paket - von npm
import { clsx } from "clsx";
 
// Internes Paket - aus unserem Monorepo
import { Button } from "@acme/ui";

Ich stelle Konfigurationspakete mit config- voran, um sie visuell zu gruppieren: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Manche Teams verwenden @acme/tsconfig, @acme/eslint-config. Beides funktioniert. Sei einfach konsistent.

turbo.json Pipeline-Konfiguration#

Hier verdient Turborepo sein Geld. Die turbo.json-Datei definiert deine Task-Pipeline — welche Tasks existieren, wovon sie abhängen und was sie produzieren.

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

Lass mich jedes Feld aufschlüsseln, denn hier werden die meisten verwirrt.

dependsOn und die ^-Notation#

"dependsOn": ["^build"] bedeutet: „Bevor du diesen Task in Paket X ausführst, führe zuerst build in jedem Paket aus, von dem X abhängt."

Das ^-Symbol bedeutet „Upstream-Abhängigkeiten." Ohne es würde "dependsOn": ["build"] bedeuten „führe build im selben Paket zuerst aus" — nützlich, wenn dein test-Task einen build im selben Paket benötigt.

Hier ist ein konkretes Beispiel. Dein Abhängigkeitsgraph sieht so aus:

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

Wenn du turbo build ausführst, löst Turborepo den Graphen auf:

  1. @acme/types bauen (keine Abhängigkeiten)
  2. @acme/utils bauen (keine Abhängigkeiten)
  3. @acme/ui bauen (hängt von types und utils ab — wartet auf sie)
  4. apps/web bauen (hängt von ui ab — wartet darauf)

Schritte 1 und 2 laufen parallel. Schritt 3 wartet auf beide. Schritt 4 wartet auf 3. Turborepo ermittelt das aus deinen package.json-Abhängigkeitsdeklarationen. Du musst die Reihenfolge nicht manuell angeben.

outputs#

Das ist entscheidend für das Caching. Wenn Turborepo einen Build cached, speichert es die in outputs aufgelisteten Dateien. Bei einem Cache-Treffer stellt es diese Dateien wieder her. Wenn du vergisst, ein Ausgabeverzeichnis aufzulisten, wird der Cache „erfolgreich" sein, aber deine Dateien erscheinen nicht. Das war mein größter Fehler in der ersten Woche — ich vergaß .next/** und konnte nicht herausfinden, warum meine Next.js-App immer neu gebaut wurde.

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

Die !.next/cache/**-Ausnahme ist wichtig. Next.js hat seinen eigenen Cache in .next/cache/. Du willst keinen Cache eines Caches speichern — das bläht deinen Remote-Cache-Speicher auf und hilft nicht.

inputs#

Standardmäßig hasht Turborepo jede Datei in einem Paket, um festzustellen, ob es sich geändert hat. Das inputs-Feld grenzt das ein. Wenn nur Dateien in src/ die Build-Ausgabe beeinflussen, sollte eine Änderung der README.md den Cache nicht invalidieren.

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

Sei vorsichtig damit. Wenn du eine Konfigurationsdatei hinzufügst, die deinen Build beeinflusst (sagen wir postcss.config.js) und sie nicht in inputs aufnimmst, bekommst du veraltete gecachte Ausgaben. Im Zweifel lass inputs nicht gesetzt und lass Turborepo alles hashen.

globalDependencies#

Hier aufgelistete Dateien invalidieren den Cache für jedes Paket, wenn sie sich ändern. Umgebungsdateien sind das klassische Beispiel — wenn sich deine .env.local ändert, muss alles, was möglicherweise aus process.env liest, neu gebaut werden.

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

Ich füge hier auch tsconfig.json auf Root-Ebene hinzu, weil meine Basis-TypeScript-Konfiguration alle Pakete betrifft:

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

persistent und dev#

Der dev-Task hat "cache": false und "persistent": true. Einen Dev-Server zu cachen ergibt keinen Sinn — es ist ein lang laufender Prozess. Das persistent-Flag teilt Turborepo mit, dass dieser Task nicht beendet wird, also sollte es nicht darauf warten, bevor andere Tasks ausgeführt werden.

Wenn du turbo dev ausführst, startet Turborepo alle Dev-Server parallel — deine Next.js-App, deinen API-Server, deine Docs-Seite — alle in einem Terminal mit verschachtelter Ausgabe. Jede Zeile wird mit dem Paketnamen vorangestellt, damit du sie unterscheiden kannst.

Geteilte Pakete im Detail#

packages/ui — Die Komponentenbibliothek#

Das ist das Paket, das jedes Team zuerst erstellt. Geteilte React-Komponenten, die in allen Frontend-Apps verwendet werden.

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

Ein paar Dinge, die auffallen:

"version": "0.0.0" — Interne Pakete brauchen keine echten Versionen. Das workspace:*-Protokoll teilt pnpm mit, immer die lokale Kopie zu verwenden. Versionsnummern sind irrelevant.

"private": true — Dieses Paket wird niemals auf npm veröffentlicht. Wenn du es jemals veröffentlichen willst, entferne dieses Flag und richte eine ordentliche Versionierungsstrategie ein.

Das exports-Feld — Das ist die moderne Art, Paket-Einstiegspunkte zu definieren. Es ersetzt die Felder main, module und types. Der "."-Export ist der Standard-Importpfad. Du kannst Unterpfad-Exports für granulare Imports hinzufügen:

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

Wichtige Entscheidung: Bauen oder nicht? Manche Teams überspringen den Build-Schritt komplett für interne Pakete. Statt mit tsup zu kompilieren, zeigen sie die exports direkt auf den TypeScript-Quellcode und lassen den Bundler der konsumierenden App (Next.js, Vite) die Kompilierung übernehmen. Das nennt sich „interne Pakete" in Turborepos Terminologie und ist einfacher:

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

Kein Build-Schritt. Kein dist-Ordner. Die next.config.ts der konsumierenden App braucht transpilePackages:

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

Ich verwende den No-Build-Ansatz für die meisten internen Pakete. Es ist schneller während der Entwicklung und eine Sache weniger, die kaputt gehen kann. Die Ausnahme sind Pakete, die Nicht-TypeScript-Konsumenten unterstützen müssen oder komplexe Build-Anforderungen haben.

packages/utils — Geteilte Utilities#

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

Die Unterpfad-Exports lassen konsumierende Apps nur das importieren, was sie brauchen:

typescript
// Importiert nur Datums-Utilities, nicht das gesamte Paket
import { formatRelativeDate, parseISO } from "@acme/utils/date";
 
// Importiert alles (Barrel-Export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";

Der Barrel-Export in src/index.ts re-exportiert alles:

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

Ein Wort der Warnung zu Barrel-Exports: Sie sind bequem, aber sie können das Tree-Shaking in manchen Bundlern zunichtemachen. Wenn apps/web eine Funktion aus @acme/utils importiert, könnte ein naiver Bundler alles einschließen. Next.js handhabt das mit seinem eingebauten Tree-Shaking gut, aber wenn du Probleme mit der Bundle-Größe bemerkst, ist das Unterpfad-Export-Muster sicherer.

packages/types — Geteilte TypeScript-Typen#

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

Dieses Paket hat null Runtime-Abhängigkeiten. Es enthält nur TypeScript-Typen und Interfaces. Es muss nie gebaut werden, weil Typen beim Kompilieren entfernt werden.

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 — Geteilte TSConfigs#

Dieses Paket ist trügerisch wichtig. TypeScript-Konfiguration in einem Monorepo ist der Punkt, an dem die Dinge schnell haarig werden.

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

Die Basiskonfiguration, die alle Pakete erweitern:

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

Die Next.js-spezifische Konfiguration:

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

Die Node.js-Konfiguration für Backend-Pakete:

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

Jetzt erweitert jede App oder jedes Paket das entsprechende Preset:

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

Der @/*-Pfad-Alias#

Jede App bekommt den gleichen @/*-Pfad-Alias, der auf ihr eigenes src/-Verzeichnis zeigt. Das bedeutet, @/components/Header in apps/web wird zu apps/web/src/components/Header aufgelöst, und @/components/Header in apps/admin wird zu apps/admin/src/components/Header aufgelöst.

Das sind app-lokale Pfade. Sie überschreiten keine Paketgrenzen. Für paketübergreifende Imports verwendest du immer den Paketnamen: @acme/ui, nicht @/../../packages/ui. Wenn du jemals einen relativen Import siehst, der über die Paket-Wurzel hinausgeht (../../packages/something), ist das ein Code-Smell. Verwende stattdessen die Workspace-Abhängigkeit.

composite und Projekt-Referenzen#

TypeScript-Projekt-Referenzen (composite: true) ermöglichen es tsc, Pakete inkrementell zu bauen und projektübergreifende Abhängigkeiten zu verstehen. Das ist optional mit Turborepo — Turborepo übernimmt die Build-Orchestrierung selbst — aber es kann die Typprüfung in deiner IDE beschleunigen.

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

Ich bin ehrlich: Projekt-Referenzen fügen Komplexität hinzu und ich habe sie aus den meisten meiner Setups entfernt. Turborepos --filter und dependsOn übernehmen die Build-Reihenfolge bereits. Ich füge composite: true nur hinzu, wenn die IDE-Performance zum Problem wird (normalerweise bei 15+ Paketen).

packages/config-eslint — Geteiltes Linting#

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

Jede App referenziert ihre Konfiguration:

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

Remote Caching#

Lokales Caching ist nett — es verhindert, dass du unveränderte Pakete während der Entwicklung neu baust. Aber Remote Caching ist transformativ. Es bedeutet, dass dein CI-Server von deinen lokalen Builds profitiert und umgekehrt.

Wie es funktioniert#

Wenn Turborepo einen Task ausführt, berechnet es einen Hash basierend auf:

  • Den Quelldateien (Inhalts-Hash)
  • Der inputs-Konfiguration des Tasks
  • Umgebungsvariablen
  • Den Hashes der Upstream-Abhängigkeiten

Wenn der Hash mit einem zuvor gecachten Ergebnis übereinstimmt, stellt Turborepo die outputs aus dem Cache wieder her, anstatt den Task auszuführen. Mit Remote Caching werden diese gecachten Ausgaben an einem gemeinsamen Ort gespeichert, auf den jeder Rechner — dein Laptop, der Laptop deines Kollegen, die CI — zugreifen kann.

Vercel Remote Cache#

Das einfachste Setup. Vercel pflegt die Cache-Infrastruktur kostenlos (mit Einschränkungen):

bash
# Bei Vercel anmelden (einmalige Einrichtung)
npx turbo login
 
# Dein Repo mit einem Vercel-Projekt verknüpfen
npx turbo link

Das war's. Turborepo wird jetzt Cache-Artefakte von Vercels Servern hoch- und runterladen. In der CI setzt du Umgebungsvariablen:

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

Generiere den Token im Vercel-Dashboard unter deinen Team-Einstellungen. TURBO_TEAM ist dein Team-Slug.

Selbstgehosteter Remote Cache#

Wenn du Vercel nicht nutzen kannst (Air-Gapped-Umgebung, Datenhaltungsanforderungen), kannst du selbst hosten. Die Turborepo Remote Cache API ist gut dokumentiert und es gibt mehrere Open-Source-Implementierungen:

  • ducktors/turborepo-remote-cache — Ein Node.js-Server, der Artefakte in S3, GCS oder im lokalen Dateisystem speichert
  • fox1t/turborepo-remote-cache — Ähnlich, mit Azure-Blob-Unterstützung
bash
# docker-compose.yml für selbstgehosteten 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"

Dann konfigurierst du dein Monorepo, um es zu nutzen:

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

Cache-Trefferquoten#

Nach meiner Erfahrung erreicht ein gut konfiguriertes Turborepo 80-95% Cache-Trefferquoten in der CI. Das bedeutet, nur 5-20% der Tasks werden tatsächlich in einer bestimmten CI-Pipeline ausgeführt. Der Rest wird in Sekunden aus dem Cache wiederhergestellt.

Die Schlüssel zu hohen Cache-Trefferquoten:

  1. Grenze deine inputs ein. Wenn nur src/ den Build beeinflusst, lass eine README-Änderung den Cache nicht invalidieren.
  2. Packe keine volatilen Daten in globalDependencies. Eine .env-Datei, die sich bei jedem Deploy ändert, zerstört deinen Cache.
  3. Pinne deine Abhängigkeiten. Lockfile-Änderungen invalidieren alles Nachgelagerte.
  4. Halte Pakete klein und fokussiert. Ein riesiges packages/utils mit 200 Dateien wird seinen Cache häufig invalidiert bekommen, weil jede Änderung an irgendeiner Utility das gesamte Paket ungültig macht.

CI/CD mit Turborepo#

Hier ist der GitHub-Actions-Workflow, den ich verwende. Er wurde über Dutzende von Iterationen verfeinert.

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   # Braucht den Eltern-Commit für Änderungserkennung
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
 
      - name: Abhängigkeiten installieren
        run: pnpm install --frozen-lockfile
 
      - name: Build
        run: pnpm turbo build
 
      - name: Lint
        run: pnpm turbo lint
 
      - name: Typprüfung
        run: pnpm turbo type-check
 
      - name: Test
        run: pnpm turbo test

Das --filter-Flag#

Das --filter-Flag ist die Art, wie du Tasks für bestimmte Pakete oder basierend auf Änderungen ausführst. Das ist essenziell für effiziente CI:

bash
# Nur die Web-App und ihre Abhängigkeiten bauen
pnpm turbo build --filter=apps/web...
 
# Nur Pakete bauen, die sich seit main geändert haben
pnpm turbo build --filter=...[main]
 
# Nur @acme/ui und alles, was davon abhängt, bauen
pnpm turbo build --filter=...@acme/ui
 
# Nur Pakete im packages/-Verzeichnis bauen
pnpm turbo build --filter="./packages/*"

Die Drei-Punkt-Syntax ist wichtig:

  • --filter=@acme/ui... — Das Paket und alle seine Abhängigkeiten (Dinge, die es braucht)
  • --filter=...@acme/ui — Das Paket und alle seine Abhängigen (Dinge, die es brauchen)
  • --filter=@acme/ui — Nur das Paket selbst

Erkennung betroffener Pakete#

Für Pull Requests willst du wahrscheinlich nicht alles bauen. Du willst nur das bauen, was von den Änderungen betroffen ist:

yaml
# Nur das bauen/testen, was sich im Vergleich zu main geändert hat
- name: Betroffene bauen
  run: pnpm turbo build --filter="...[origin/main]"
 
- name: Betroffene testen
  run: pnpm turbo test --filter="...[origin/main]"

Die [origin/main]-Syntax teilt Turborepo mit, den aktuellen Commit mit origin/main zu vergleichen und Tasks nur für Pakete auszuführen, die Änderungen aufweisen. Kombiniert mit Remote Caching sind die meisten PR-Pipelines in unter 2 Minuten fertig, selbst für große Monorepos.

Matrix-Strategie für Deployments#

Wenn jede App unabhängig deployed wird, verwende eine Matrix-Strategie:

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: Abhängigkeiten installieren
      run: pnpm install --frozen-lockfile
 
    - name: Build
      run: pnpm turbo build --filter=apps/${{ matrix.app }}...
 
    - name: ${{ matrix.app }} deployen
      run: ./scripts/deploy.sh ${{ matrix.app }}
      env:
        DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Jeder Matrix-Job baut eine App und ihre Abhängigkeiten. Dank Remote Caching werden die geteilten Pakete einmal gebaut und gecached — jeder nachfolgende Matrix-Job stellt sie aus dem Cache wieder her.

Häufige Fallstricke#

Ich widme diesem Thema einen ganzen Abschnitt, weil ich in meinem ersten Monat auf jeden einzelnen davon gestoßen bin. Aus Dokumentation zu lernen ist effizient. Aus meinen Fehlern zu lernen ist kostenlos.

1. Zirkuläre Abhängigkeiten#

Der Abhängigkeitsgraph muss ein DAG (gerichteter azyklischer Graph) sein. Wenn @acme/ui aus @acme/utils importiert und @acme/utils aus @acme/ui importiert, hast du einen Zyklus. Turborepo wird sich beschweren und den Build verweigern.

Die Lösung ist fast immer, den geteilten Code in ein drittes Paket zu extrahieren:

VORHER (kaputt):
@acme/ui → @acme/utils → @acme/ui  ← Zyklus!

NACHHER (behoben):
@acme/ui     → @acme/types
@acme/utils  → @acme/types

Verschiebe die geteilten Typen oder Konstanten nach @acme/types und lass beide Pakete davon abhängen.

Ein weiterer häufiger Grund: Du packst einen React-Hook in @acme/utils, der eine Komponente aus @acme/ui importiert. Hooks, die von UI-Komponenten abhängen, gehören in @acme/ui, nicht in @acme/utils. Utilities sollten, wann immer möglich, framework-agnostisch sein.

2. Fehlende outputs invalidieren den Cache#

Das ist der frustrierendste Bug. Lokal scheint alles zu funktionieren. CI-Builds laufen durch. Aber jeder Build dauert die volle Zeit, weil nichts gecached ist.

Szenario: Dein Paket baut nach build/ statt dist/, aber deine turbo.json listet nur dist/** in outputs:

json
// turbo.json
"build": {
  "outputs": ["dist/**"]  // aber dein Paket baut nach build/
}

Turborepo cached den Task (weil er erfolgreich lief), speichert aber keine Ausgabedateien. Beim nächsten Mal bekommt es einen Cache-„Treffer" und stellt nichts wieder her. Die konsumierende App kann die Build-Ausgabe nicht finden und schlägt fehl, oder — schlimmer — verwendet still veraltete Dateien.

Lösung: Überprüfe das Build-Ausgabeverzeichnis jedes Pakets und stelle sicher, dass turbo.json sie alle abdeckt:

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

Oder verwende paketspezifische Überschreibungen in turbo.json:

json
{
  "tasks": {
    "build": {
      "outputs": ["dist/**"]
    }
  },
  // Paketspezifische Überschreibungen
  "packages": {
    "apps/web": {
      "build": {
        "outputs": [".next/**", "!.next/cache/**"]
      }
    },
    "apps/api": {
      "build": {
        "outputs": ["build/**"]
      }
    }
  }
}

3. Dev-Server-Koordination#

turbo dev ausführen startet alle Dev-Server gleichzeitig. Das ist in Ordnung, bis deine Apps versuchen, denselben Port zu verwenden. Explizite Portzuweisung ist Pflicht:

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"  // Verwendet Port 4000 aus der Config
}

Ein weiteres Problem: Hot Module Replacement für geteilte Pakete. Wenn du eine Datei in packages/ui bearbeitest, muss die Änderung zum laufenden Next.js-Dev-Server propagiert werden. Wenn du den „interne Pakete"-Ansatz verwendest (kein Build-Schritt, direkte TypeScript-Imports), funktioniert das automatisch — Next.js überwacht die Quelldateien durch die symgelinkten node_modules.

Wenn dein Paket einen Build-Schritt hat, muss das dev-Skript des Pakets im Watch-Modus laufen:

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

Turborepo führt alle dev-Tasks parallel aus, sodass das Paket bei Änderungen neu baut und die konsumierende App die neue Ausgabe übernimmt.

4. Versionsabweichung zwischen Paketen#

Das ist subtil und gefährlich. Wenn apps/web React 19 verwendet und apps/admin versehentlich React 18 nutzt, merkst du es vielleicht erst, wenn sich eine Komponente aus @acme/ui in jeder App anders verhält.

Lösung: Verwalte geteilte Abhängigkeiten auf Root-Ebene. pnpms overrides-Feld in der Root-package.json erzwingt überall eine einzige Version:

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

Führe regelmäßig pnpm ls react --recursive aus, um zu überprüfen, dass alles die gleiche Version verwendet.

5. Umgebungsvariablen in geteilten Paketen#

Geteilte Pakete sollten process.env nicht direkt lesen. Wenn @acme/utils process.env.API_URL liest, erzeugt das eine unsichtbare Kopplung an die Umgebung der konsumierenden App. Übergib stattdessen die Konfiguration explizit:

typescript
// SCHLECHT: packages/utils/src/api.ts
const API_URL = process.env.API_URL;  // Welche Umgebung? Welche App?
 
export function fetchData(path: string) {
  return fetch(`${API_URL}${path}`);
}
 
// GUT: packages/utils/src/api.ts
export function createApiClient(baseUrl: string) {
  return {
    fetch: (path: string) => fetch(`${baseUrl}${path}`),
  };
}

Die konsumierende App liefert die Konfiguration:

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

Das hält Pakete rein und testbar.

6. Geister-Abhängigkeiten#

pnpm ist standardmäßig streng bei Abhängigkeiten — es hoistet Pakete nicht so wie npm. Das ist ein Feature, kein Bug. Es bedeutet, wenn @acme/ui clsx importiert, es aber nicht in seiner package.json auflistet, wird pnpm einen Fehler werfen. npm würde es stillschweigend aus einem übergeordneten node_modules auflösen.

Deklariere immer jeden Import in der package.json des konsumierenden Pakets. Kein Verlassen mehr auf Hoisting.

7. IDE-Performance-Verschlechterung#

Bei 15+ Paketen könnte dein TypeScript-Language-Server anfangen zu kämpfen. Einige Tipps:

  • Füge "exclude": ["node_modules", "**/dist/**"] zu deiner Root-tsconfig.json hinzu
  • Verwende VS Codes „Files: Exclude", um dist/, .next/ und .turbo/-Ordner auszublenden
  • Erwäge "disableSourceOfProjectReferenceRedirect": true in der tsconfig, wenn „Gehe zu Definition" dich zu .d.ts-Dateien tief in node_modules schickt

Die Starter-Struktur: Alles zusammen#

Lass mich alles zusammenfügen. Hier ist jede Datei, die du brauchst, um ein Turborepo-Monorepo mit zwei Next.js-Apps und drei geteilten Paketen aufzusetzen.

Root#

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

apps/web#

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

apps/admin#

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

packages/ui#

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

packages/utils#

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

packages/types#

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

.gitignore#

# Abhängigkeiten
node_modules/

# Build-Ausgaben
dist/
build/
.next/
out/

# Turborepo
.turbo/

# Umgebung
.env
.env.local
.env.*.local

# IDE
.vscode/settings.json
.idea/

# OS
.DS_Store
Thumbs.db

Von Null an starten#

Wenn du von vorne anfängst, hier ist die exakte Befehlsabfolge:

bash
# Das Repo erstellen
mkdir acme-monorepo && cd acme-monorepo
git init
 
# Das Root-Paket initialisieren
pnpm init
 
# Die Workspace-Datei erstellen
echo 'packages:\n  - "apps/*"\n  - "packages/*"' > pnpm-workspace.yaml
 
# Turborepo installieren
pnpm add -D turbo -w
 
# Verzeichnisse erstellen
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
 
# Apps initialisieren (aus ihren Verzeichnissen)
cd apps/web && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../admin && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../..
 
# Workspace-Abhängigkeiten hinzufügen
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 ../..
 
# Alles installieren
pnpm install
 
# Überprüfen, ob es funktioniert
pnpm turbo build
pnpm turbo dev

Der erste pnpm turbo build wird langsam sein — alles baut von Grund auf. Der zweite sollte nahezu sofort sein, wenn sich nichts geändert hat. Das ist der Cache in Aktion.

Über die Grundlagen hinaus skalieren#

Sobald du über die anfängliche Einrichtung hinaus bist, kristallisieren sich einige Muster heraus, während das Monorepo wächst.

Paket-Generatoren#

Nach deinem zehnten Paket wird das Erstellen eines neuen durch Kopieren und Bearbeiten mühsam. Erstelle ein Skript:

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("Verwendung: tsx scripts/create-package.ts <paket-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"), "// Einstiegspunkt\n");
 
console.log(`packages/${name} erstellt`);
console.log("Ausführen: pnpm install");
bash
npx tsx scripts/create-package.ts email
# Erstellt packages/email mit dem gesamten Boilerplate

Workspace-Abhängigkeitsgraph-Visualisierung#

Turborepo hat einen eingebauten Graph-Befehl:

bash
pnpm turbo build --graph=graph.svg

Das generiert ein SVG deines Abhängigkeitsgraphen. Ich führe das vor größeren Refactorings aus, um den Auswirkungsradius einer Änderung zu verstehen. Wenn das Anfassen von @acme/types 12 Pakete neu baut, ist es vielleicht an der Zeit, es in @acme/types-user und @acme/types-billing aufzuteilen.

Pruning für Docker#

Wenn du eine einzelne App aus dem Monorepo deployst, willst du nicht das gesamte Repo in dein Docker-Image kopieren. Turborepo hat einen prune-Befehl:

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

Das generiert ein out/-Verzeichnis, das nur die Dateien enthält, die für apps/web und seine Abhängigkeiten benötigt werden. Dein Dockerfile verwendet diese beschnittene Ausgabe:

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

Das Docker-Image enthält nur die Web-App und ihre Abhängigkeiten. Keine Admin-App, keinen API-Server, keine ungenutzten Pakete. Image-Größen bleiben vernünftig, selbst wenn das Monorepo wächst.

Ein Monat später: Was ich anders machen würde#

Wenn ich von vorne anfangen würde, mit dem Wissen von heute:

  1. Mit internen Paketen starten (kein Build-Schritt). Ich habe zwei Wochen damit verschwendet, tsup-Builds für Pakete zu konfigurieren, die nur meine Next.js-Apps konsumierten. transpilePackages in der Next.js-Konfiguration ist einfacher und schneller. Füge nur einen Build-Schritt hinzu, wenn du einen brauchst.

  2. packages/-Anzahl anfangs niedrig halten. Ich habe zu früh zu aggressiv aufgeteilt. packages/utils, packages/types und packages/ui reichen für den Anfang. Du kannst später immer noch aufteilen, wenn ein Paket unhandlich wird. Zu frühes Aufteilen bedeutet mehr package.json-Dateien zu pflegen und mehr Kanten in deinem Abhängigkeitsgraphen.

  3. Remote Caching am ersten Tag einrichten. npx turbo login && npx turbo link dauert 30 Sekunden. Die in der ersten Woche allein bei Builds gesparte Zeit rechtfertigt es.

  4. Die Workspace-Befehle dokumentieren. Neue Entwickler wissen nicht, dass pnpm --filter @acme/ui add lodash in ein bestimmtes Paket installiert, oder dass pnpm turbo build --filter=apps/web... nur das Nötige baut. Ein einfacher „Monorepo-Spickzettel"-Abschnitt in deinem Contributing Guide spart allen Zeit.

  5. Die Abhängigkeitsrichtung vom ersten Tag an durchsetzen. Wenn du auch nur einen Import von einer App in ein Paket erlaubst, erodiert die Grenze schnell. Füge eine Lint-Regel oder einen CI-Check hinzu. Die Richtung ist apps → packages → packages. Niemals umgekehrt.

Das Monorepo ist nicht das Ziel. Das Ziel ist, Features auszuliefern, ohne gegen die eigene Codebasis zu kämpfen. Turborepo ist das leichtgewichtigste Tool, das ich gefunden habe, um das zum Laufen zu bringen. Es erledigt den Task-Graphen, es erledigt das Caching, und es hält sich bei allem anderen aus dem Weg.

Fang einfach an. Füge Komplexität hinzu, wenn das Repo es verlangt. Und pinne deine Ports.

Ähnliche Beiträge