Die meisten Artikel über Monorepos starten in einem leeren Verzeichnis und enden mit „So einfach ist das.“ Dort liegt aber nicht die Schwierigkeit: Sie liegt in der Frage, ob du geteilte Packages baust oder nicht, woran du wirklich erkennst, dass das Caching funktioniert, und wie du eine einzelne App mit Docker containerisierst.
Dieser Beitrag basiert auf einem Produktiv-Repository – genau dem Repo, das die Seite hostet, die du gerade liest. Es enthält vier Apps und vier geteilte Packages, und eine davon ist nicht einmal Next.js.
Zuerst: Brauchst du überhaupt ein Monorepo?
Tipp: Wann es nötig ist
Wenn mehrere Apps denselben Code teilen und dieser Code sich in einem einzigen Commit in beiden ändern muss, ist ein Monorepo die richtige Wahl. Design Tokens, geteilte Komponenten, gemeinsame Typdefinitionen, gemeinsame Lint-Regeln.
Warnung: Wann es nicht nötig ist
Wenn die Apps unabhängig voneinander sind, bringt ein Monorepo nur Komplexität: längere CI-Builds, aufwendigere Deployments und eine verflochtene Git-Historie. Ein Monorepo unter der Annahme aufzusetzen, „wir teilen später vielleicht Code“, heißt, die Kosten zu zahlen, bevor du den Bedarf hast.
Bei mir war das Teilen real: czay.dev und fit.czay.dev nutzen exakt dieselben Design Tokens. Eine Farbe in zwei getrennten Repos getrennt zu aktualisieren, machte Versionsdrift unvermeidlich.
Wie sieht das Repository aus?
czay.dev/
├── apps/
│ ├── web/ → czay.dev (Next.js)
│ ├── student/ → student.czay.dev (Next.js + Drizzle + Postgres)
│ ├── fit/ → fit.czay.dev (Next.js)
│ └── links/ → links.czay.dev (Astro)
├── packages/
│ ├── ui/ → @czay-dev/ui (tokens + shared components)
│ ├── mdx/ → @czay-dev/mdx (MDX options, slug generation)
│ ├── tsconfig/ → @czay-dev/tsconfig (shared TS config)
│ └── eslint-config/→ @czay-dev/eslint-config
├── pnpm-workspace.yaml
├── turbo.json
└── package.jsonDer Abhängigkeitsgraph ist übersichtlich: web → ui + mdx, fit → ui, während student und links keine geteilten Packages nutzen. Das ist entscheidend, denn die gesamte Geschwindigkeit von Turborepo kommt daher, dass es diesen Graphen versteht – wenn sich ui nicht geändert hat, muss fit nicht neu gebaut werden.
Dass links Astro nutzt, ist eine bewusste Entscheidung: Turborepo ist egal, welches Framework du verwendest. Es verwaltet nur den Task-Graphen und den Cache; jede App baut mit ihrem eigenen Tooling.
pnpm Workspace einrichten
Du brauchst nur zwei Dateien.
Beispiel: pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"Beispiel: package.json der App
{
"name": "web",
"dependencies": {
"@czay-dev/ui": "workspace:*",
"@czay-dev/mdx": "workspace:*"
}
}Das workspace:*-Protokoll ist der Schlüssel: Statt diese Abhängigkeit von npm herunterzuladen, legt pnpm einen Symlink auf das Verzeichnis im Repository an. Wenn du auf ein veröffentlichtes Package umsteigst, wird das Protokoll automatisch in eine echte Versionsnummer umgewandelt; wenn du nichts veröffentlichst, musst du gar nicht darüber nachdenken.
Baue Packages nicht (meistens)
Das ist die Entscheidung, die in Monorepo-Setups die meiste Zeit verschwendet. Es gibt zwei Ansätze:
Kompiliertes Package: Das Package führt einen eigenen build-Schritt aus, der dist/ erzeugt, und die Anwendungen nutzen die gebaute Ausgabe. Vorteil: Das Package lässt sich auf npm veröffentlichen. Preis: Du musst das Package bei jeder Änderung neu bauen, brauchst während dev eingerichtete Watch-Prozesse, und Source Maps werden unübersichtlich.
JIT-Package (Just-in-Time): Das Package exportiert TypeScript-Quellcode direkt und überlässt den Build-Schritt der App, die es verwendet.
Beispiel: packages/ui/package.json
{
"name": "@czay-dev/ui",
"exports": {
"./styles/globals.css": "./src/styles/globals.css",
"./components/button": "./src/components/button.tsx",
"./lib/utils": "./src/lib/utils.ts"
}
}Beachte: kein dist/, kein main, kein build-Script. Es zeigt direkt auf die .tsx-Datei.
Dafür musst du dem Bundler deiner Anwendung sagen: „Transpiliere dieses Package auch“:
Beispiel: apps/web/next.config.ts
const nextConfig: NextConfig = {
transpilePackages: ["@czay-dev/ui", "@czay-dev/mdx"],
};Den Nutzen dieser Entscheidung spürst du in dev: Wenn du eine Datei in einem Package änderst, reagiert Fast Refresh in deiner App sofort – es gibt keinen Build-Schritt dazwischen. Solange du das Package nicht auf npm veröffentlichst, ist JIT fast immer die richtige Wahl.
turbo.json
Beispiel: turbo.json
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"env": ["DB_POSTGRES_URL", "RESEND_API_KEY", "NEXT_PUBLIC_*"]
},
"lint": { "dependsOn": ["^lint"] },
"typecheck": { "dependsOn": ["^typecheck"] },
"dev": { "cache": false, "persistent": true }
}
}Vier Details:
dependsOn: ["^build"] — Das führende ^ bedeutet „derselbe Task in meinen Abhängigkeiten“. Bevor web gebaut wird, müssen ui und mdx gebaut sein. Bei JIT-Packages ist dieser Task ein No-op (die Packages haben kein build-Script), aber wenn du den Graphen korrekt deklarierst, musst du nichts ändern, falls ein Package später doch einen Build-Schritt braucht.
dev: { cache: false, persistent: true } — Der Dev-Server ist ein langlaufender Task, der nie endet. persistent teilt das Turborepo mit, während cache: false verhindert, dass seine Ausgabe gecacht wird. Lässt du das weg, hängt Turbo und wartet darauf, dass der Task beendet wird.
env-Allowlist — Hier entstehen die fiesen Bugs. Turborepo erzeugt Cache-Keys aus den Inputs; steht eine Umgebungsvariable nicht in dieser Liste, fließt sie nicht in den Hash ein. Wenn du also NEXT_PUBLIC_API_URL änderst und neu baust, sagt Turbo „Inputs haben sich nicht geändert“, liefert die gecachte Ausgabe zurück und deployt deine App mit der alten URL. Jede Variable, die den Build beeinflusst, muss in dieser Liste stehen.
outputs — Und hier teile ich meinen eigenen Fehler.
Achtung: Der Cache, der still kaputtging
Lange Zeit sahen meine outputs so aus: [".next/**", "!.next/cache/**"]. Da drei der vier Apps Next.js waren, wirkte das richtig. Aber links ist eine Astro-App und schreibt ihre Build-Ausgabe nach dist/.
Das Ergebnis: Turbo cachte den Build-Task von links, fand aber keine Dateien zum Speichern. Beim nächsten Lauf meldete es einen „Cache Hit“, spielte die Build-Logs ab, und dist/ fehlte komplett. Keine Fehlermeldung – einfach fehlende Dateien.
Die Lösung: [".next/**", "!.next/cache/**", "dist/**"]
Die Lehre daraus: Caching kann so aussehen, als würde es funktionieren, obwohl es das nicht tut. Wenn du einen Task cachst, lösche den Output-Ordner, führe turbo run build aus und prüfe mit eigenen Augen, dass die Dateien wirklich wieder auftauchen.
Alltägliche Nutzung
pnpm dev # all apps at once
pnpm build # in order according to dependency graph
pnpm typecheck # tsc --noEmit in every package
turbo run dev --filter=web # only czay.dev
turbo run build --filter=web... # web and its dependencies
turbo run build --filter=...ui # ui and everything dependent on itDie Punkte in der --filter-Syntax legen die Richtung fest: web... bedeutet „web und seine Abhängigkeiten“, ...ui bedeutet „ui und alles, was von ui abhängt“. Letzteres ist der schnellste Weg zu sehen, was beim Bearbeiten eines Packages kaputtgehen könnte.
Die Ports sind so vergeben, dass es keine Konflikte gibt: web auf 3000, fit auf 3002, student auf 3003.
Eine einzelne App aus einem Monorepo mit Docker containerisieren
Hier tun sich die meisten mit Monorepos am schwersten. Ich deploye die student-App mit Dokploy auf meinen eigenen Server; das Docker-Image wird aus dem Repo-Root gebaut, weil dort pnpm-lock.yaml und die geteilten Packages liegen.
1. Standalone-Output und das richtige Root in Next.js
Beispiel: apps/student/next.config.ts
import path from "node:path";
const nextConfig = {
output: "standalone" as const,
outputFileTracingRoot: path.join(import.meta.dirname, "../.."),
};output: "standalone" konfiguriert Next.js so, dass nur die tatsächlich genutzten node_modules verfolgt und in ein eigenständiges, ausführbares Verzeichnis gebündelt werden.
outputFileTracingRoot ist Monorepo-spezifisch, und wenn du es weglässt, stürzt dein Image zur Laufzeit ab: pnpm hebt Abhängigkeiten in die node_modules im Repo-Root, während Next.js standardmäßig nur innerhalb des App-Verzeichnisses verfolgt und diese gehoisteten Packages verpasst. Wenn du es auf das Root setzt, sucht der File Tracer weiter oben.
2. Nur die benötigten Packages installieren
Beispiel: Dockerfile — Dependency-Layer
FROM node:22-alpine AS deps
RUN corepack enable
WORKDIR /repo
# Copy manifest files first: when source code changes, this layer
# comes from cache, so pnpm install doesn't re-run.
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/student/package.json apps/student/
COPY packages/ui/package.json packages/ui/
COPY packages/mdx/package.json packages/mdx/
COPY packages/tsconfig/package.json packages/tsconfig/
COPY packages/eslint-config/package.json packages/eslint-config/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter student...Hier gibt es zwei Tricks:
- Zuerst die Manifeste kopieren. Der Layer-Cache von Docker prüft Dateiänderungen; wenn du den Quellcode vor
pnpm installkopierst, werden die Abhängigkeiten bei jeder Codeänderung neu installiert. Diese Trennung senkt die Build-Zeiten von Minuten auf Sekunden. --filter student...— Es werden nurstudentund seine Abhängigkeiten installiert. Die Abhängigkeiten vonfitundweblanden nie im Image.
3. Nur das Nötige ins Runner-Image kopieren
FROM node:22-alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/static ./apps/student/.next/static
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/public ./apps/student/public
USER nextjsIm Runner-Image gibt es weder pnpm noch den Quellbaum noch andere Apps – nur Node und die Standalone-Build-Ausgabe.
Erklärung: Der Bug, der mich erst in Produktion erwischte
Der Ordner public/ war leer, und Git trackt keine leeren Ordner. Lokal lief alles, weil der Ordner auf meinem Rechner existierte, aber in einem frischen Clone gab es den Ordner gar nicht, sodass der COPY-Schritt zur Laufzeit mit „not found“ abbrach. Die Lösung war eine einzelne Zeile im Build-Schritt:
RUN mkdir -p apps/student/publicSolche Bugs sind in Monorepos häufig: Abweichungen zwischen dem lokalen Rechnerzustand und einem frischen Clone tauchen an weit mehr Stellen auf als in Repos mit nur einer App.
Zusammenfassung
Zusammenfassung
- Richte ein Monorepo nur ein, wenn du geteilten Code hast; nicht „für alle Fälle“
- Verlinke Packages mit
workspace:*und baue sie nicht – JIT-Packages +transpilePackagesgeben dir eine deutlich schnellere Feedback-Schleife - Wenn du Einträge in der
env-Allowlist derturbo.jsonvergisst, wird veralteter Cache ausgeliefert und deine App geht mit veralteten Umgebungsvariablen live - Fehlen in
outputsPfade, liefert das Caching still leere Ergebnisse – lösche das Output-Verzeichnis und prüfe nach - Baue Docker-Images aus dem Root, kopiere zuerst die Manifeste und installiere mit
--filter app...nur das Nötige - Setze
outputFileTracingRootbei Next.js-Standalone-Builds auf das Repo-Root, sonst stürzt das Runtime-Image ab
Der wahre Wert von Turborepo ist nicht nur Geschwindigkeit, sondern den Graphen korrekt zu verstehen. Sobald du definierst, welche App von welchem Package abhängt, entscheidet es, was gebaut werden muss und was aus dem Cache kommt. Definierst du ihn falsch – wie bei meinem outputs-Bug –, liefert es dir still die falsche Antwort, und zwar schnell.
Interaktive Roadmap & Tool
Junior Frontend Developer 2026
Was du wirklich wissen musst, um 2026 einen Junior-Frontend-Job zu bekommen, und was warten kann – abgeleitet aus echten Stellenanzeigen.
SQL oder NoSQL?
Beantworte sechs Fragen, und ich sage dir, welche Art von Datenbank zu deinem Projekt passt und warum.