Trzy lata temu każdy mój projekt klienta miał trzy oddzielne repozytoria: frontend, backend, paczka wspólnych typów. Każda zmiana w typach wymagała push w jednym repo, npm publish, bumpu wersji w pozostałych dwóch, instalacji, deploya. 20 minut na zmianę, której kod sam zajął 2 minuty.

Dziś każdy projekt agencji to jedno monorepo z TurboRepo. Next.js, Strapi, shared packages w jednej strukturze. Zmiana w typach widać natychmiast wszędzie. Deploy jednego repa, jedna historia commitów, jeden flow.

Ten post pokaże Ci, kiedy monorepo ma sens, jak postawić TurboRepo dla stacku TypeScript + Next.js + Strapi i czego unikać.

Co to jest monorepo

Monorepo to jedno repozytorium zawierające wiele projektów lub paczek. Przeciwieństwem jest polyrepo (każdy projekt w swoim repo).

Słynne przykłady monorepo: Google (cała firma w jednym repo), Facebook, Vercel, ostatnio większość JS-owych frameworków (Next.js, React, Vue) trzyma swoje paczki w jednym repo.

W kontekście stacku TypeScript jedna monorepo zwykle zawiera:

  • apps/ - aplikacje (Next.js, Strapi, mobile)
  • packages/ - wspólne paczki (typy, UI komponenty, utils, config)
  • Konfigurację build narzędzi w korzeniu

Dlaczego TurboRepo

W ekosystemie JS są 3 główne narzędzia do monorepo:

Nx. Najbogatszy, najbardziej “all-in-one”. Plugin system, generators, dependency graph. Krzywa nauki wyższa, ale jak ogarniesz, działa wszystko. Dla zespołów 10+ developerów lepszy wybór.

TurboRepo. Lekki, szybki, mniej opinionated. Buildy cache’owane lokalnie i w chmurze, równoległe zadania, dobra integracja z pnpm/npm/yarn workspaces. Wybór dla większości średnich projektów.

Lerna. Stary, kiedyś standard, dziś przyspieszony przez Nx (wykupiony). Wciąż używany, ale nowe projekty raczej nie wybierają Lerna.

Wybrałem TurboRepo, bo:

  • Zero magii. To w zasadzie nakładka na workspaces + build cache
  • Vercel za nim stoi (deployment integration out of the box)
  • Setup w 30 minut, działa z mojego stacka

Jeśli zaczynasz dziś, TurboRepo jest sensownym defaultem. Nx nadrabiasz, jak projekt urośnie.

Moja struktura monorepo

Klient typowy ma u mnie taki layout:

my-project/
├── apps/
│   ├── web/                 # Next.js frontend
│   ├── cms/                 # Strapi backend
│   └── docs/                # opcjonalnie: dokumentacja w Astro
├── packages/
│   ├── ui/                  # shared React komponenty
│   ├── types/               # współdzielone TypeScript typy
│   ├── eslint-config/       # shared ESLint config
│   ├── tsconfig/            # shared tsconfig
│   └── utils/               # shared funkcje
├── package.json             # root z workspaces
├── pnpm-workspace.yaml      # definicja workspaces
├── turbo.json               # konfiguracja TurboRepo
└── tsconfig.base.json       # base TypeScript config

apps/ to rzeczy z własnym lifecyclem (deploy oddzielnie). packages/ to to, co inne apps konsumują.

Setup od zera

Krok po kroku, na czym buduję każdy projekt klienta.

Krok 1: zainstaluj pnpm globalnie. npm install -g pnpm. Pnpm jest szybszy niż npm i bardziej oszczędny dyskowo (linki symboliczne zamiast kopii).

Krok 2: stwórz repo i zainicjalizuj:

mkdir my-project && cd my-project
git init
pnpm init

Krok 3: dodaj pnpm-workspace.yaml:

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

Krok 4: zainstaluj TurboRepo:

pnpm add -D turbo -w

-w znaczy “do root workspace”, nie do konkretnego paczki.

Krok 5: stwórz turbo.json w roocie:

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

To definiuje 4 zadania: build, lint, test, dev. Build każdej paczki zależy od buildów jej zależności (^build). Dev nie ma cache’a (live reload).

Krok 6: dodaj scripts w root package.json:

{
  "scripts": {
    "build": "turbo build",
    "dev": "turbo dev",
    "lint": "turbo lint",
    "test": "turbo test"
  }
}

Teraz pnpm dev w roocie odpala dev mode wszędzie naraz.

Dodawanie pierwszej app (Next.js)

cd apps
pnpm create next-app web --typescript --tailwind --app
cd web

Edytuj package.json tej app:

{
  "name": "@my-project/web",
  "version": "0.0.0",
  "private": true,
  ...
}

Konwencja: wszystko z @my-project/ przedrostkiem. Łatwo rozpoznać, co jest z monorepo.

Dodawanie shared package (typy)

cd packages
mkdir types && cd types
pnpm init

W package.json:

{
  "name": "@my-project/types",
  "version": "0.0.0",
  "private": true,
  "main": "./src/index.ts",
  "types": "./src/index.ts"
}

W packages/types/src/index.ts:

export interface User {
  id: string
  email: string
  role: 'admin' | 'user'
}

export interface Product {
  id: string
  name: string
  price: number
}

W app/web dodaj zależność:

cd apps/web
pnpm add @my-project/types

W kodzie:

import { User, Product } from "@my-project/types"

Działa natychmiast. Zmiana w packages/types/ jest widoczna w apps/web/ bez rebuildu.

Strapi w monorepo

Strapi v5 dobrze działa w monorepo, ale wymaga uwagi.

cd apps
pnpm create strapi cms --quickstart --no-run

Po instalacji edytuj apps/cms/package.json:

{
  "name": "@my-project/cms",
  ...
}

Strapi instaluje masę własnych dependencji. To OK, ale pnpm hoistuje je do root node_modules. Czasem powoduje to konflikty wersji. Mój workaround: w pnpm-workspace.yaml dodać:

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

publicHoistPattern:
  - "*strapi*"
  - "*koa*"

To zmusza pnpm do hoistowania paczek Strapi w taki sposób, że plugin system Strapi je znajduje.

Współdzielenie typów Strapi z Next.js

Killer feature monorepo. Strapi generuje typy ze swoich modeli (od v5). Te typy konsumuje frontend.

W apps/cms/ dodaj script:

{
  "scripts": {
    "generate-types": "strapi ts:generate-types --debug"
  }
}

Wygenerowane typy lądują w apps/cms/types/generated/. Zainstaluj je jako zależność w shared package:

cd packages/types
ln -s ../../apps/cms/types/generated/contentTypes.d.ts ./generated.d.ts

Albo (lepiej) napisz skrypt w turbo.json, który kopiuje typy do packages/types po każdym buildzie Strapi.

Frontend importuje typy z @my-project/types. Zmiana w modelu Strapi propaguje się do typów na froncie automatycznie. To eliminuje całą klasę bugów typu “field się nazywa inaczej w API niż w komponencie”.

Build cache: dlaczego TurboRepo jest szybkie

TurboRepo cache’uje wyniki zadań. Drugi turbo build, bez zmian, kończy się w 0.5s zamiast 60s.

Cache jest:

  • Lokalny (w .turbo/)
  • Zdalny (w Vercel Cloud Cache albo własnym S3)

Z chmurowym cache cały zespół dzieli się buildami. CI builduje, lokalnie odpalasz, masz wynik z cache. Oszczędność godzin w skali tygodnia.

Włączanie zdalnego cache w Vercel:

pnpm turbo login
pnpm turbo link

Trzy minuty, działa.

Kiedy monorepo NIE ma sensu

Monorepo to nie panaceum.

Pojedyncza app bez współdzielonych paczek. Zwykłe repo wystarczy. Monorepo dorzuca komplikacje bez korzyści.

Zespoły niezależne pracujące na różnych technologiach. Java backend i React frontend w dwóch oddzielnych zespołach, bez współdzielonego kodu. Mniej tarcia w osobnych repach.

Open source biblioteka. Trzymanie głównego kodu w osobnym repo daje czystszą historię i łatwiejszy contribution flow.

Bardzo duża skala (Google). Tu monorepo wymaga specjalnych narzędzi (Bazel). TurboRepo nie wystarczy.

Dla agencji robiącej projekty klientów z stacka TypeScript + Next.js + headless CMS, monorepo wygrywa w 90% przypadków.

Najczęstsze błędy

Wrzucanie wszystkiego do packages/. Każda paczka ma overhead (build, tsconfig, package.json). Trzy plików utility w ‘utility’ package’ to nadmierna struktura. Trzymaj rzeczy razem, dopóki nie są szare wykorzystywane.

Brak peerDependencies. Jeśli paczka @my-project/ui używa Reacta, React powinien być w peerDependencies, nie dependencies. Inaczej dwie wersje Reacta walczą w bundle.

Wersjonowanie wewnętrznych paczek. Nie robisz tego. Wersja 0.0.0 dla wszystkich, używasz workspace:* jako zależności. Wersjonowanie potrzebne tylko dla publikowanych paczek.

Brak turbo.json. Bez tego nie ma build cache, nie ma parallelism, nie ma rozróżnienia zadań. Daj 30 minut, zrób porządne turbo.json.

Mieszanie pnpm/npm/yarn. Wybierz jeden, trzymaj się. Najczęstsze zwyrodnienia projektów monorepo to plik pnpm-lock.yaml, yarn.lock i package-lock.json obok siebie.

Brak Github Actions cache. CI bez cache buduje wszystko od zera za każdym razem. Z cache buduje tylko to, co się zmieniło. Różnica: 10 minut vs 2 minuty deploy.

Performance: kilka liczb z moich projektów

Średni projekt klienta po roku rozwoju:

  • 8 apps i packages
  • ~1500 plików TypeScript
  • ~80 000 linii kodu

Cold build (bez cache): 45 sekund.
Warm build (z lokalnym cache): 1.5 sekundy.
CI build z Vercel Remote Cache: 12 sekund.

Bez TurboRepo te same builds zajmowałyby 3-5x dłużej, bez równoległości.

Co dalej

Po opanowaniu TurboRepo, rzeczy do nauczenia:

  • Changesets do wersjonowania, jeśli publikujesz paczki na npm
  • Husky + lint-staged do pre-commit hooków
  • Renovate do automatycznego update’u zależności
  • Code owners w Github (kto reviewuje co)

To wszystko nie jest must-have. Dorzucasz, kiedy boli.

Od czego zacząć

Jeśli masz projekt w polyrepo i chcesz migrować:

  1. Stwórz nowe monorepo wg struktury wyżej.
  2. Skopiuj kod app do apps/, każdy w osobnym folderze.
  3. Wyciągnij shared kod do packages/. Zacznij od typów, potem utils, potem UI.
  4. Postaw TurboRepo z basic turbo.json.
  5. Włącz remote cache w Vercel.
  6. Migruj CI do TurboRepo (turbo build --filter=... dla affected packages).

Migracja małego projektu to dzień. Średniego: tydzień. Dużego: miesiąc. Inwestycja zwraca się w 2-3 miesiącach przez oszczędność czasu na codziennej pracy.

Monorepo nie rozwiąże złych decyzji architektonicznych. Ale dobrze zaprojektowane monorepo plus dobre decyzje to dramatyczny boost produktywności zespołu. Pisałem o tym w automatyzacji. Monorepo z dobrymi narzędziami to forma automatyzacji codziennej pracy.