Monorepo z TurboRepo. Jak ogarniam Next.js + Strapi + paczki współdzielone w jednym repo
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ć:
- Stwórz nowe monorepo wg struktury wyżej.
- Skopiuj kod app do
apps/, każdy w osobnym folderze. - Wyciągnij shared kod do
packages/. Zacznij od typów, potem utils, potem UI. - Postaw TurboRepo z basic
turbo.json. - Włącz remote cache w Vercel.
- 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.
Powiązane wpisy
-
Tailwind CSS. Dlaczego po 5 latach z CSS in JS wróciłem do utility classes
Pięć lat temu napisałbym ten post inaczej. "Tailwind to klasy w HTML jak w 2005, regres do dark agesów CSS". Tak myśl...