TypeScript-Monorepo mit Turborepo: Das Setup, das dein Team nicht ausbremst

#Turborepo Next.js Monorepo
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Ein Turborepo-Next.js-Monorepo ist die pragmatischste Antwort, die wir kennen, für ein Team von 3 bis 10 Entwicklern, das ein Next.js-Frontend, eine geteilte Komponentenbibliothek und ein Node.js-Backend aus einem Repository ausliefert. Das Versprechen ist echt: ein git clone, geteilte Typen über die API-Grenze hinweg, ein PR für eine Änderung, die Frontend und Backend berührt, und Builds, die nur für die Pakete laufen, die sich tatsächlich geändert haben. Der Fehlermodus ist genauso echt: Dependency-Konflikte, Modulauflösungsfehler, die niemand erklären kann, und eine CI-Pipeline, die bei jedem Commit alles neu baut, weil der Cache vor drei Wochen still den Dienst quittiert hat.

Der Unterschied zwischen beiden Ausgängen ist nicht das Werkzeug. Es ist eine Handvoll Setup-Entscheidungen, die bewusst und einmalig am Anfang getroffen werden müssen. Dieser Beitrag geht das Setup durch, das wir in Kundenprojekten verwenden: die Workspace-Struktur, die tsconfig-Konfiguration, die Import-Zyklen verhindert, die Pipeline-Definition und die CI-Cache-Strategie, die eine reale Codebasis von 8-Minuten-CI-Läufen auf 90 Sekunden gebracht hat.

Warum Monorepos Teams überhaupt ausbremsen

Vor der Lösung die Diagnose. Wenn ein Monorepo bremst, ist es fast immer eines von vier Problemen:

  1. Alles baut bei jedem Commit. Kein Task-Graph oder ein Task-Graph, dem das Werkzeug nicht trauen kann, bedeutet: Die CI fährt Build, Lint und Testsuite für alle Pakete, selbst wenn nur eine README geändert wurde.
  2. Phantom-Abhängigkeiten. Paket A importiert aus Paket B, ohne es in der package.json zu deklarieren. Lokal funktioniert es wegen Hoisting, dann bricht es in der CI oder nach einer Lockfile-Änderung, mit Modulauflösungsfehlern, die zufällig aussehen.
  3. Import-Zyklen zwischen Paketen. Die Komponentenbibliothek importiert einen Helper aus der App, die App importiert Komponenten aus der Bibliothek, und TypeScript Project References oder der Bundler verschlucken sich irgendwann an der Schleife.
  4. Ein still kaputter Cache. Das Team hat Caching einmal konfiguriert, ein Ausgabepfad hat sich geändert, und jeder "Cache Hit" ist entweder ein Miss oder, schlimmer, stellt veraltete Dateien wieder her.

Turborepo adressiert das erste Problem direkt über seinen Task-Graph und den inhaltsadressierten Cache. Die anderen drei sind Konfigurationsdisziplin, und darum geht es im Rest dieses Setups.

Die Workspace-Struktur: Apps sind deploybar, Packages sind geteilt

Halte die oberste Ebene langweilig und strikt:

.
├── apps/
│   ├── web/          # Next.js-Frontend
│   └── api/          # Node.js-Backend (Fastify, NestJS oder schlichtes HTTP)
├── packages/
│   ├── ui/           # geteilte React-Komponentenbibliothek
│   ├── config/       # geteilte eslint-, tsconfig-, prettier-Presets
│   └── contracts/    # geteilte Typen: API-Request/Response, zod-Schemas
├── package.json
├── pnpm-workspace.yaml
└── turbo.json

Zwei Regeln machen diese Struktur haltbar:

  • Alles in apps/ ist deploybar und wird von nichts anderem importiert. Apps konsumieren Packages. Packages importieren nie aus Apps. Diese eine Regel beseitigt die häufigste Quelle von Import-Zyklen.
  • Alles in packages/ hat eine echte package.json mit expliziten dependencies. Wenn ui clsx nutzt, deklariert es clsx. Sich auf Hoisting zu verlassen, ist die Geburtsstunde von Phantom-Abhängigkeiten. Wir nutzen pnpm genau deshalb: Sein striktes node_modules-Layout verwandelt nicht deklarierte Importe in sofortige lokale Fehler statt in CI-Überraschungen.

Das contracts-Paket verdient eine besondere Erwähnung. Deine API-Request- und Response-Typen (und idealerweise die zod-Schemas, die sie validieren) in ein geteiltes Paket zu legen, ist der größte Hebel des gesamten Monorepos: Frontend und Backend können nicht mehr auseinanderdriften, ohne dass ein Typfehler es dir sagt.

tsconfig-Pfade ohne Import-Zyklen

Der häufigste Fehler in einem TypeScript-Monorepo ist, paths-Aliase in der Root-tsconfig so zu verdrahten, dass jedes Paket tief in das src jedes anderen Pakets importieren kann. Zuerst funktioniert das, dann erzeugt es zwei Probleme: Bundler und Typechecker lösen Module unterschiedlich auf, und nichts verhindert, dass ein Zyklus entsteht.

Mach stattdessen das hier:

  • Jedes Paket bekommt seine eigene tsconfig.json, die eine geteilte Basis aus packages/config erweitert.
  • Pakete werden über ihren Paketnamen importiert (@acme/ui, @acme/contracts), nie über relative Pfade über Paketgrenzen hinweg und nie über einen src-Tiefimport.
  • Jedes Paket definiert in der package.json über exports einen expliziten Einstiegspunkt. Konsumenten importieren ausschließlich über diesen Einstiegspunkt.
// packages/ui/package.json
{
  "name": "@acme/ui",
  "exports": {
    ".": "./src/index.ts",
    "./styles.css": "./src/styles.css"
  }
}

exports auf TypeScript-Quellcode zeigen zu lassen (das "Internal Packages"-Muster) bedeutet, dass die konsumierende App das Paket mit ihrem eigenen Bundler kompiliert, es gibt im Dev-Betrieb also gar keinen separaten Build-Schritt für ui oder contracts. Next.js muss davon über transpilePackages: ['@acme/ui', '@acme/contracts'] in der next.config.js erfahren. Weniger Build-Schritte heißt weniger Cache-Einträge, die veralten können.

Die turbo.json-Pipeline, die die Build-Reihenfolge korrekt ableitet

Turborepo baut seinen Task-Graph aus den Workspace-Abhängigkeiten plus der Pipeline-Definition. Das entscheidende Symbol ist ^, es bedeutet "diesen Task zuerst in meinen Abhängigkeiten":

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

Drei Details sind hier tragend:

  • "dependsOn": ["^build"] sorgt dafür, dass Turborepo contracts und ui vor web und api baut, in Abhängigkeitsreihenfolge und parallel, wo möglich. Du pflegst nie wieder eine Build-Reihenfolge von Hand.
  • outputs muss alles auflisten, was der Task schreibt, und ausschließen, was nicht wiederhergestellt werden soll. Für Next.js heißt das: .next/** einschließen, aber .next/cache/** ausschließen. Ein falsches outputs ist die mit Abstand häufigste Ursache für kaputtes Caching.
  • dev ist nicht cachebar und persistent. Einen Dev-Server zu cachen ergibt keinen Sinn, und die Markierung als persistent sagt Turborepo, dass er nie beendet wird.

Damit führt turbo run build lint test typecheck in einem PR nur die Tasks aus, deren Eingaben sich geändert haben. Ein reiner Docs-Commit fährt fast nichts.

Die CI-Cache-Strategie für ein Turborepo-Next.js-Monorepo: von 8 Minuten auf 90 Sekunden

In einer realen Kunden-Codebasis (Next.js-App, Komponentenbibliothek, Node-API, rund 90.000 Zeilen TypeScript) lief die CI vor diesem Setup 8 Minuten pro PR und danach etwa 90 Sekunden, bei einem Median-PR, der ein Paket berührt. Zwei Schichten haben das ermöglicht.

Schicht 1: Remote Cache. Turborepos Remote Cache teilt Task-Ergebnisse über Maschinen hinweg, genau das, was zustandslose CI-Runner brauchen. Vercels gehosteter Remote Cache ist die Zero-Config-Option (npx turbo login && npx turbo link, danach TURBO_TOKEN und TURBO_TEAM in der CI setzen). Selbst gehostete Open-Source-Implementierungen existieren, falls Compliance-Anforderungen einen Drittanbieter-Cache ausschließen; solche Entscheidungen begleiten wir in unserer Tech-Stack-Strategie.

Schicht 2: GitHub-Actions-Cache für Abhängigkeiten. Der Remote Cache deckt Task-Ausgaben ab; den pnpm-Store willst du trotzdem cachen, damit Installationen schnell sind:

- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build lint test typecheck
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Nutze actions/cache nicht, um node_modules direkt zu cachen oder um .turbo als Ersatz für einen echten Remote Cache zu verwenden. node_modules über Lockfile-Änderungen hinweg wiederherzustellen erzeugt genau die Klasse von Modulauflösungsfehlern, für die Monorepos verantwortlich gemacht werden, und ein branch-gebundener .turbo-Cache liefert Cache Hits auf dem Branch, der die Änderung erzeugt hat, und Misses überall sonst.

Die Fehler, die den Cache still zerstören

Jeder davon hat schon ein Team getroffen, mit dem wir gearbeitet haben:

  • Ausgabeverzeichnisse vor Builds nicht bereinigen. Wenn sich in dist/ Dateien früherer Builds ansammeln, speichert der Cache sie, und ein wiederhergestellter Cache erweckt gelöschte Module zurück. Lass build-Skripte zuerst aufräumen (tsup und Next.js tun das, handgeschriebene tsc-Builds oft nicht).
  • Aus dem falschen Einstiegspunkt importieren. Ein Tiefimport wie @acme/ui/src/button umgeht die exports-Map. Er kompiliert vielleicht, aber Turborepos Hash-Eingaben und die Paketgrenze sind sich nicht mehr einig darüber, was sich geändert hat, und Konsumenten bauen neu, wenn sie es nicht sollten (oder schlimmer, sie tun es nicht, wenn sie sollten). Aktuelle TypeScript-Einstellungen mit moduleResolution: "bundler" markieren solche Importe; schalte das ein.
  • Umgebungsvariablen fehlen im Hash. Wenn ein Build NEXT_PUBLIC_API_URL liest und diese Variable in der turbo.json nicht unter env deklariert ist, teilen sich zwei Builds mit unterschiedlichen Werten einen Cache-Eintrag. Deklariere jede Variable, die ein Task liest. Turborepos Strict-Env-Modus lässt nicht deklarierte Variablen laut scheitern; aktiviere ihn.
  • Nicht erfasste Eingaben. Ein Build-Skript, das eine Datei außerhalb des Paketverzeichnisses liest (eine VERSION-Datei auf Root-Ebene, generierter Code), braucht diese Datei in den inputs des Tasks, sonst invalidieren Änderungen daran den Cache nicht.

Das Muster hinter allen vieren: Turborepos Cache ist nur so korrekt wie deine Deklaration dessen, was hineingeht und was herauskommt. Er kann Seiteneffekte nicht sehen, die du nicht deklariert hast.

Wann dieses Setup zu viel ist

Ehrlichkeit gehört dazu. Wenn du eine Next.js-App und kein zweites Deployable hast, fügt ein Monorepo Zeremonie ohne Gegenwert hinzu; eine gut strukturierte Einzelanwendung ist einfacher. Wenn du 40 Entwickler und Hunderte Pakete hast, willst du irgendwann die strengere Durchsetzung von Regeln, wie sie Nx oder Bazel-artiges Tooling bietet. Das Setup in diesem Beitrag ist auf die Mitte abgestimmt: zwei bis fünf Deployables, eine Handvoll geteilter Pakete und ein Team, das klein genug ist, dass niemand Build-Infrastruktur in Vollzeit betreuen will.

Wenn dein Monorepo bereits im schmerzhaften Zustand ist (langsame CI, zufällige Auflösungsfehler, ein Cache, dem niemand traut), ist die Lösung meist ein fokussiertes Aufräumen der vier Problembereiche oben und keine Migration auf ein neues Werkzeug. Das ist eine Übung auf Code-Ebene, und genau diese Arbeit machen wir in Code-Quality-Consulting-Projekten. Wenn du ein neues Produkt startest und Workspace, Pipeline und CI ab dem ersten Commit korrekt verdrahtet haben willst, passt das zu unserer Custom Software Development-Arbeit.

FAQ

Brauche ich Turborepo, wenn ich bereits pnpm Workspaces nutze? pnpm Workspaces lösen Verlinkung und Dependency-Isolation; sie geben dir weder einen Task-Graph noch einen Cache. Ohne Turborepo (oder ein Äquivalent) fährt pnpm -r build jedes Mal den Build jedes Pakets, in einer Reihenfolge, die du von Hand pflegst. Die beiden Werkzeuge ergänzen sich, sie sind keine Alternativen.

Sollten geteilte Pakete vorgebaut oder als Quellcode konsumiert werden? Für ein Team von 3 bis 10 Entwicklern: als Quellcode konsumieren (das Internal-Packages-Muster mit transpilePackages). Vorbauen lohnt sich erst, wenn ein Paket extern veröffentlicht oder von Tooling konsumiert wird, das TypeScript nicht transpilieren kann.

Gibt der Remote Cache Quellcode preis? Der Remote Cache speichert Task-Ausgaben (kompilierte Artefakte, Logs), nicht dein Repository. Artefakte sind inhaltsadressiert, und der Zugriff ist per Token begrenzt. Falls Richtlinien Drittanbieter-Speicher trotzdem verbieten, hoste den Cache selbst; das Protokoll ist offen.

Wie verhindere ich, dass Apps sich gegenseitig importieren? Erzwinge es maschinell: eine ESLint-Regel (import/no-restricted-paths oder Turborepos eigenes Boundaries-Feature), die jeden Import aus apps/* außerhalb der App selbst verbietet. Konventionen verfallen, Lint-Regeln nicht.

Wenn du ein zweites Augenpaar auf dein Monorepo-Setup willst oder überhaupt entscheidest, ob ein Monorepo zu deinem Team passt: Schreib an hello@wolf-tech.io oder finde uns auf wolf-tech.io. Wir schauen uns lieber ein konkretes Repo an, als abstrakt zu diskutieren.