Turborepo Monorepos: Setup yang Benar-Benar Scalable
Cara saya menstrukturkan Turborepo monorepos untuk multiple apps yang berbagi packages. Konfigurasi workspace, pipeline caching, shared TypeScript configs, dan kesalahan yang saya buat di bulan pertama.
Saya menghabiskan tiga tahun pertama karier saya menyalin fungsi utility antar repository. Bukan berlebihan. Saya punya formatDate.ts yang hidup di tujuh proyek berbeda, masing-masing dengan bug yang sedikit berbeda. Ketika saya memperbaiki masalah timezone di satu proyek, saya lupa tentang enam proyek lainnya. Akhirnya seorang pengguna di Australia membuat tiket dan saya menemukan perbaikannya tidak pernah sampai ke production di aplikasi itu.
Monorepo menyelesaikan ini. Bukan karena sedang tren, bukan karena Google melakukannya, tapi karena saya lelah menjadi human package registry. Satu repository, kode bersama, perubahan atomik di setiap aplikasi yang menggunakannya.
Tapi monorepo punya mode kegagalannya sendiri. Saya sudah mencoba tiga tool yang berbeda, membuang berminggu-minggu untuk caching yang rusak, bertarung dengan error circular dependency di tengah malam, dan belajar bahwa "taruh saja semuanya di satu repo" adalah nasihat yang tidak berguna tanpa detail.
Ini adalah setup Turborepo yang benar-benar saya gunakan. Ia menjalankan empat aplikasi production dengan dua belas shared packages. Build memakan waktu di bawah 90 detik berkat remote caching. Developer baru bisa clone dan menjalankan pnpm dev dan semua berjalan dalam waktu kurang dari dua menit. Butuh sekitar sebulan kesalahan untuk sampai di sini.
Mengapa Monorepo Sejak Awal#
Mari jujur tentang trade-off-nya. Monorepo tidak gratis. Anda menukar satu set masalah dengan yang lain, dan Anda perlu tahu apa yang Anda beli.
Apa yang Anda Dapatkan#
Berbagi kode tanpa publishing. Ini yang utama. Anda menulis component library React di packages/ui. Web app, admin dashboard, dan situs docs Anda semuanya mengimpor darinya. Ketika Anda mengubah komponen button, setiap aplikasi langsung mendapatkannya. Tanpa bump versi, tanpa npm publish, tanpa "versi mana yang dipakai production?"
// Di apps/web/src/components/Header.tsx
import { Button, Avatar, Dropdown } from "@acme/ui";
import { formatDate } from "@acme/utils";
import { SITE_CONFIG } from "@acme/config";Import ini terlihat seperti package eksternal. Mereka resolve melalui workspace dependencies. Tapi mereka menunjuk ke source code di repository yang sama.
Perubahan atomik lintas-package. Bayangkan Anda perlu menambahkan prop isLoading ke komponen Button yang dibagikan. Di dunia polyrepo, Anda akan mengubah component library, mempublikasikan versi baru, memperbarui versi di setiap aplikasi yang mengonsumsi, lalu membuka PR di setiap repo. Itu tiga sampai lima PR untuk satu prop.
Di monorepo, cukup satu commit:
feat: tambah prop isLoading ke komponen Button
- packages/ui/src/Button.tsx (tambah prop, render spinner)
- apps/web/src/pages/checkout.tsx (gunakan isLoading)
- apps/admin/src/pages/users.tsx (gunakan isLoading)
Satu PR. Satu review. Satu merge. Semuanya tetap sinkron karena secara fisik tidak bisa menyimpang.
Tooling terpadu. Satu konfigurasi ESLint. Satu konfigurasi Prettier. Satu base config TypeScript. Satu pipeline CI. Ketika Anda meng-upgrade ESLint, Anda meng-upgrade di mana-mana dalam satu sore, bukan selama tiga sprint di delapan repo.
Apa yang Anda Bayar#
Kompleksitas di root. package.json di root menjadi infrastruktur. Pipeline CI Anda perlu memahami package mana yang berubah. IDE Anda mungkin kesulitan dengan banyaknya jumlah file. Operasi Git melambat seiring repo tumbuh (meskipun ini butuh bertahun-tahun untuk kebanyakan tim).
Waktu CI bisa membengkak. Jika Anda mem-build semuanya di setiap commit, Anda akan membuang compute yang sangat banyak. Anda memerlukan tool yang memahami dependency graph dan hanya mem-build apa yang berubah. Itulah seluruh alasan Turborepo ada.
Hambatan onboarding. Developer baru perlu memahami workspaces, hoisting, internal packages, build pipeline. Bukan sekadar "clone dan jalankan." Memang pada akhirnya seharusnya begitu, tapi sampai ke sana membutuhkan upaya yang disengaja.
Penilaian jujurnya: jika Anda punya satu aplikasi tanpa kode bersama, monorepo adalah overhead tanpa manfaat. Jika Anda punya dua atau lebih aplikasi yang berbagi apa pun — komponen, utility, tipe, konfigurasi — monorepo akan terbayar dalam bulan pertama.
Turborepo vs Nx vs Lerna#
Saya sudah menggunakan ketiganya. Berikut versi singkatnya.
Lerna adalah tool monorepo asli untuk JavaScript. Ia mengelola versioning dan publishing. Tapi ia tidak memahami build pipeline atau caching. Ia ditinggalkan, dihidupkan kembali oleh Nx, dan sekarang terasa seperti compatibility layer lebih dari tool mandiri. Jika Anda perlu mempublikasikan package ke npm, Lerna + Nx bisa menangani itu. Tapi untuk monorepo internal di mana Anda hanya berbagi kode antar aplikasi sendiri, itu lebih banyak seremoni dari yang diperlukan.
Nx powerful. Sangat powerful. Ia punya generators, plugin untuk setiap framework, visual dependency graph, distributed task execution. Ia juga punya learning curve yang terlihat seperti tebing. Saya pernah melihat tim menghabiskan dua minggu hanya mengkonfigurasi Nx sebelum menulis kode produk apa pun. Jika Anda di perusahaan dengan 50+ developer dan ratusan package, Nx mungkin pilihan yang tepat. Untuk kasus penggunaan saya, itu bulldozer ketika saya butuh sekop.
Turborepo melakukan tiga hal dengan baik: ia memahami dependency graph Anda, ia meng-cache output build, dan ia menjalankan task secara paralel. Itu saja. Seluruh konfigurasi adalah satu file turbo.json. Anda bisa membaca semuanya dalam dua menit. Ia tidak menghasilkan kode, ia tidak punya plugin, ia tidak mencoba mengganti build tool Anda. Ia adalah task runner yang sangat, sangat baik dalam mengetahui apa yang perlu dijalankan dan apa yang bisa dilewati.
Saya memilih Turborepo karena saya bisa menjelaskan seluruh setup ke anggota tim baru dalam 15 menit. Dengan Nx, percakapan yang sama memakan waktu satu jam dan mereka masih punya pertanyaan.
Fitur | Turborepo | Nx | Lerna
-------------------+---------------+-----------------+----------------
Kompleksitas config| Rendah (1 file)| Tinggi (banyak) | Menengah
Learning curve | ~1 hari | ~1 minggu | ~2 hari
Build caching | Ya (remote) | Ya (remote) | Tidak (native)
Task orchestration | Ya | Ya | Dasar
Code generation | Tidak | Ya (ekstensif) | Tidak
Framework plugins | Tidak | Ya | Tidak
Terbaik untuk | Kecil-menengah| Organisasi besar| Publishing
Struktur Repository#
Berikut directory tree yang sebenarnya. Bukan contoh "getting started" — ini seperti apa monorepo production setelah enam bulan.
acme-monorepo/
├── apps/
│ ├── web/ # Aplikasi utama untuk pelanggan
│ │ ├── src/
│ │ ├── public/
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── admin/ # Dashboard admin internal
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── docs/ # Situs dokumentasi
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── api/ # Server API Express/Fastify
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ ├── ui/ # Library komponen React bersama
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Input.tsx
│ │ │ │ ├── Modal.tsx
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── utils/ # Fungsi utility bersama
│ │ ├── src/
│ │ │ ├── date.ts
│ │ │ ├── string.ts
│ │ │ ├── validation.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── types/ # Tipe TypeScript bersama
│ │ ├── src/
│ │ │ ├── user.ts
│ │ │ ├── api.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── config-typescript/ # Preset tsconfig bersama
│ │ ├── base.json
│ │ ├── nextjs.json
│ │ ├── node.json
│ │ └── package.json
│ ├── config-eslint/ # Konfigurasi ESLint bersama
│ │ ├── base.js
│ │ ├── next.js
│ │ ├── node.js
│ │ └── package.json
│ └── config-tailwind/ # Preset Tailwind bersama
│ ├── tailwind.config.ts
│ └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json
Pemisahan apps/ vs packages/#
Ini adalah prinsip pengorganisasian fundamental. apps/ berisi hal-hal yang bisa di-deploy — web app, API, apa pun yang punya perintah dev atau start. packages/ berisi library — kode yang ada untuk dikonsumsi oleh apps atau package lain.
Aturannya sederhana: apps mengonsumsi packages. Packages tidak pernah mengimpor dari apps. Packages bisa mengimpor dari packages lain.
Jika sebuah package mulai mengimpor dari app, Anda punya dependency graph yang terbalik dan Turborepo akan menangkapnya sebagai siklus.
Konfigurasi Workspace#
Root pnpm-workspace.yaml memberi tahu pnpm di mana menemukan package:
packages:
- "apps/*"
- "packages/*"Itu seluruh isi file-nya. pnpm memindai direktori tersebut, membaca setiap package.json, dan membuat workspace map. Ketika apps/web mendeklarasikan "@acme/ui": "workspace:*" sebagai dependency, pnpm me-link-nya ke packages/ui lokal alih-alih mencari di npm registry.
Root package.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"
}
}Perhatikan: root package.json tidak punya runtime dependencies. Murni orkestrasi. turbo adalah task runner, prettier menangani formatting (karena ini satu-satunya tool yang tidak memerlukan konfigurasi per-package), dan typescript di-hoist sehingga setiap package menggunakan versi yang sama.
Konvensi Penamaan#
Setiap package mendapat scoped name: @acme/ui, @acme/utils, @acme/config-typescript. Scope mencegah collision dengan package npm dan langsung jelas di setiap import statement apakah Anda menggunakan kode internal atau eksternal.
// Package eksternal - dari npm
import { clsx } from "clsx";
// Package internal - dari monorepo kita
import { Button } from "@acme/ui";Saya menambahkan prefix config- ke package konfigurasi untuk mengelompokkannya secara visual: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Beberapa tim menggunakan @acme/tsconfig, @acme/eslint-config. Keduanya berfungsi. Yang penting konsisten.
Konfigurasi Pipeline turbo.json#
Di sinilah Turborepo membuktikan nilainya. File turbo.json mendefinisikan pipeline task Anda — task apa yang ada, apa dependensinya, dan apa yang mereka hasilkan.
{
"$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
}
}
}Mari saya jelaskan setiap field karena di sinilah kebanyakan orang bingung.
dependsOn dan Notasi ^#
"dependsOn": ["^build"] artinya: "Sebelum menjalankan task ini di package X, jalankan dulu build di setiap package yang menjadi dependensi X."
Simbol ^ berarti "upstream dependencies." Tanpanya, "dependsOn": ["build"] akan berarti "jalankan build di package yang sama terlebih dahulu" — berguna jika task test Anda memerlukan build terjadi lebih dulu dalam package yang sama.
Berikut contoh konkret. Dependency graph Anda terlihat seperti ini:
apps/web → @acme/ui → @acme/utils
→ @acme/types
Ketika Anda menjalankan turbo build, Turborepo me-resolve graph-nya:
- Build
@acme/types(tanpa dependensi) - Build
@acme/utils(tanpa dependensi) - Build
@acme/ui(bergantung pada types dan utils — menunggu mereka) - Build
apps/web(bergantung pada ui — menunggu)
Langkah 1 dan 2 berjalan secara paralel. Langkah 3 menunggu keduanya. Langkah 4 menunggu langkah 3. Turborepo memahami ini dari deklarasi dependency package.json Anda. Anda tidak perlu menentukan urutan secara manual.
outputs#
Ini kritis untuk caching. Ketika Turborepo meng-cache sebuah build, ia menyimpan file yang tercantum di outputs. Ketika mendapat cache hit, ia memulihkan file tersebut. Jika Anda lupa mencantumkan direktori output, cache akan "berhasil" tapi file Anda tidak muncul. Ini adalah kesalahan terbesar saya di minggu pertama — saya melewatkan .next/** dan tidak bisa mengerti mengapa aplikasi Next.js saya selalu rebuilding.
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]Pengecualian !.next/cache/** penting. Next.js punya cache sendiri di dalam .next/cache/. Anda tidak ingin menyimpan cache dari cache — itu membengkakkan penyimpanan remote cache Anda dan tidak membantu.
inputs#
Secara default, Turborepo meng-hash setiap file di sebuah package untuk menentukan apakah ia berubah. Field inputs mempersempit itu. Jika hanya file di src/ yang memengaruhi output build, maka mengubah README.md seharusnya tidak menginvalidasi cache.
"inputs": ["src/**", "package.json", "tsconfig.json"]Hati-hati dengan ini. Jika Anda menambahkan file konfigurasi yang memengaruhi build (misalnya, postcss.config.js) dan tidak menyertakannya di inputs, Anda akan mendapat cached output yang usang. Jika ragu, biarkan inputs tidak diatur dan biarkan Turborepo meng-hash semuanya.
globalDependencies#
File yang tercantum di sini menginvalidasi cache untuk setiap package ketika berubah. File environment adalah contoh klasik — jika .env.local Anda berubah, semua yang mungkin membaca dari process.env perlu di-rebuild.
"globalDependencies": ["**/.env.*local"]Saya juga menambahkan tsconfig.json di level root karena konfigurasi TypeScript dasar saya memengaruhi semua package:
"globalDependencies": [
"**/.env.*local",
"tsconfig.json"
]persistent dan dev#
Task dev memiliki "cache": false dan "persistent": true. Meng-cache dev server tidak masuk akal — ini adalah proses yang berjalan lama. Flag persistent memberi tahu Turborepo bahwa task ini tidak akan selesai, jadi ia tidak seharusnya menunggu sebelum menjalankan task lain.
Ketika Anda menjalankan turbo dev, Turborepo memulai semua dev server secara paralel — aplikasi Next.js Anda, API server, situs docs — semuanya di satu terminal dengan output yang saling berselingan. Setiap baris diberi prefix nama package sehingga Anda bisa membedakannya.
Shared Packages secara Detail#
packages/ui — Component Library#
Ini adalah package yang setiap tim bangun pertama kali. Komponen React bersama yang digunakan di semua frontend app.
{
"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"
}
}Beberapa hal yang perlu diperhatikan:
"version": "0.0.0" — Internal packages tidak memerlukan versi yang sesungguhnya. Protokol workspace:* memberi tahu pnpm untuk selalu menggunakan salinan lokal. Nomor versi tidak relevan.
"private": true — Package ini tidak akan pernah dipublikasikan ke npm. Jika Anda suatu saat ingin mempublikasikannya, hapus flag ini dan siapkan strategi versioning yang proper.
Field exports — Ini adalah cara modern untuk mendefinisikan entry point package. Ia menggantikan field main, module, dan types. Export "." adalah path import default. Anda bisa menambahkan sub-path exports untuk import yang granular:
{
"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"
}
}Keputusan penting: build atau tidak? Beberapa tim melewati langkah build sepenuhnya untuk internal packages. Alih-alih mengompilasi dengan tsup, mereka mengarahkan exports langsung ke TypeScript source dan membiarkan bundler aplikasi yang mengonsumsi (Next.js, Vite) menangani kompilasi. Ini disebut "internal packages" dalam terminologi Turborepo dan lebih sederhana:
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
}
}Tanpa langkah build. Tanpa folder dist. next.config.ts aplikasi yang mengonsumsi memerlukan transpilePackages:
// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
};
export default nextConfig;Saya menggunakan pendekatan tanpa-build untuk kebanyakan internal packages. Ini lebih cepat selama development dan satu hal lebih sedikit yang bisa rusak. Pengecualiannya adalah package yang perlu mendukung konsumen non-TypeScript atau memiliki kebutuhan build yang kompleks.
packages/utils — Utility Bersama#
{
"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"
}
}Sub-path exports memungkinkan aplikasi yang mengonsumsi hanya mengimpor apa yang mereka butuhkan:
// Hanya mengimpor utility tanggal, bukan seluruh package
import { formatRelativeDate, parseISO } from "@acme/utils/date";
// Mengimpor semuanya (barrel export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";Barrel export di src/index.ts me-re-export semuanya:
// packages/utils/src/index.ts
export * from "./date";
export * from "./string";
export * from "./validation";Peringatan tentang barrel exports: mereka nyaman tapi bisa membunuh tree-shaking di beberapa bundler. Jika apps/web mengimpor satu fungsi dari @acme/utils, bundler yang naif mungkin menyertakan semuanya. Next.js menangani ini dengan baik berkat tree-shaking bawaannya, tapi jika Anda melihat masalah ukuran bundle, pola sub-path exports lebih aman.
packages/types — Tipe TypeScript Bersama#
{
"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"
}
}Package ini punya nol runtime dependencies. Ia hanya berisi tipe dan interface TypeScript. Ia tidak perlu di-build karena tipe dihapus pada waktu kompilasi.
// 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;
}// 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 — TSConfigs Bersama#
Yang ini sangat penting meskipun terlihat sederhana. Konfigurasi TypeScript di monorepo adalah tempat di mana segalanya menjadi rumit dengan cepat.
{
"name": "@acme/config-typescript",
"version": "0.0.0",
"private": true,
"files": [
"base.json",
"nextjs.json",
"node.json",
"react-library.json"
]
}Konfigurasi dasar yang semua package extends:
// 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"]
}Konfigurasi khusus Next.js:
// 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" }
]
}
}Konfigurasi Node.js untuk backend packages:
// 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"
}
}Sekarang setiap app atau package extends preset yang sesuai:
// 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/api/tsconfig.json
{
"extends": "@acme/config-typescript/node",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}// 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"]
}Path Alias @/*#
Setiap app mendapat path alias @/* yang sama yang menunjuk ke direktori src/-nya sendiri. Ini berarti @/components/Header di apps/web me-resolve ke apps/web/src/components/Header, dan @/components/Header di apps/admin me-resolve ke apps/admin/src/components/Header.
Ini adalah path lokal-app. Mereka tidak melewati batas package. Untuk import lintas-package, Anda selalu menggunakan nama package: @acme/ui, bukan @/../../packages/ui. Jika Anda pernah melihat import relatif yang naik melewati root package (../../packages/something), itu code smell. Gunakan workspace dependency sebagai gantinya.
composite dan Project References#
Project references TypeScript (composite: true) memungkinkan tsc mem-build package secara incremental dan memahami dependensi lintas-proyek. Ini opsional dengan Turborepo — Turborepo menangani orkestrasi build sendiri — tapi bisa mempercepat type checking di IDE Anda.
// packages/ui/tsconfig.json (dengan composite)
{
"extends": "@acme/config-typescript/base",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src"],
"references": [
{ "path": "../types" },
{ "path": "../utils" }
]
}Jujur: project references menambah kompleksitas dan saya sudah melepasnya dari kebanyakan setup saya. --filter dan dependsOn Turborepo sudah menangani urutan build. Saya hanya menambahkan composite: true ketika performa IDE menjadi masalah (biasanya di 15+ package).
packages/config-eslint — Linting Bersama#
// 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" }
],
},
}
);// 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",
},
},
];Setiap app mereferensikan konfigurasinya:
// apps/web/eslint.config.mjs
import nextConfig from "@acme/config-eslint/next";
export default [
...nextConfig,
{
ignores: [".next/"],
},
];Remote Caching#
Local caching itu bagus — ia mencegah Anda dari rebuilding package yang tidak berubah selama development. Tapi remote caching itu transformatif. Ini berarti server CI Anda mendapat manfaat dari build lokal Anda dan sebaliknya.
Cara Kerjanya#
Ketika Turborepo menjalankan sebuah task, ia menghitung hash berdasarkan:
- File source (content hash)
- Konfigurasi
inputstask - Environment variables
- Hash dari upstream dependencies
Jika hash cocok dengan hasil yang sebelumnya di-cache, Turborepo memulihkan outputs dari cache alih-alih menjalankan task. Dengan remote caching, cached outputs tersebut disimpan di lokasi bersama yang bisa diakses mesin mana pun — laptop Anda, laptop kolega Anda, CI.
Vercel Remote Cache#
Setup paling sederhana. Vercel memelihara infrastruktur cache secara gratis (dengan batas):
# Login ke Vercel (setup sekali saja)
npx turbo login
# Hubungkan repo Anda ke proyek Vercel
npx turbo linkItu saja. Turborepo sekarang akan push dan pull cache artifacts dari server Vercel. Di CI, Anda mengatur environment variables:
# .github/workflows/ci.yml
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Generate token dari Vercel dashboard di pengaturan tim Anda. TURBO_TEAM adalah slug tim Anda.
Self-Hosted Remote Cache#
Jika Anda tidak bisa menggunakan Vercel (environment air-gapped, persyaratan data residency), Anda bisa self-host. API remote cache Turborepo terdokumentasi dengan baik dan ada beberapa implementasi open-source:
- ducktors/turborepo-remote-cache — Server Node.js yang menyimpan artifacts di S3, GCS, atau filesystem lokal
- fox1t/turborepo-remote-cache — Serupa, dengan dukungan Azure Blob
# docker-compose.yml untuk self-hosted 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"Lalu konfigurasikan monorepo Anda untuk menggunakannya:
// .turbo/config.json
{
"teamId": "team_acme",
"apiUrl": "https://turbo-cache.internal.acme.com"
}Tingkat Cache Hit#
Dalam pengalaman saya, Turborepo yang dikonfigurasi dengan baik mencapai tingkat cache hit 80-95% di CI. Itu berarti hanya 5-20% task yang benar-benar berjalan di setiap pipeline CI. Sisanya dipulihkan dari cache dalam hitungan detik.
Kunci untuk tingkat cache hit yang tinggi:
- Persempit
inputsAnda. Jika hanyasrc/yang memengaruhi build, jangan biarkan perubahan README menginvalidasi cache. - Jangan taruh data volatile di
globalDependencies. File.envyang berubah setiap deploy membunuh cache Anda. - Pin dependencies Anda. Perubahan lockfile menginvalidasi semua yang downstream.
- Buat package tetap kecil dan fokus.
packages/utilsraksasa dengan 200 file akan sering cache-nya diinvalidasi karena perubahan apa pun di utility mana pun merusak seluruh package.
CI/CD dengan Turborepo#
Berikut workflow GitHub Actions yang saya gunakan. Ini sudah disempurnakan melalui puluhan iterasi.
# .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 # Perlu parent commit untuk deteksi perubahan
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo build
- name: Lint
run: pnpm turbo lint
- name: Type Check
run: pnpm turbo type-check
- name: Test
run: pnpm turbo testFlag --filter#
Flag --filter adalah cara Anda menjalankan task untuk package tertentu atau berdasarkan apa yang berubah. Ini esensial untuk CI yang efisien:
# Build hanya web app dan dependensinya
pnpm turbo build --filter=apps/web...
# Build hanya package yang berubah sejak main
pnpm turbo build --filter=...[main]
# Build hanya @acme/ui dan semua yang bergantung padanya
pnpm turbo build --filter=...@acme/ui
# Build hanya package di direktori packages/
pnpm turbo build --filter="./packages/*"Sintaks tiga titik penting:
--filter=@acme/ui...— Package dan semua dependencies-nya (hal-hal yang dibutuhkan)--filter=...@acme/ui— Package dan semua dependents-nya (hal-hal yang membutuhkannya)--filter=@acme/ui— Hanya package itu sendiri
Deteksi Package yang Terpengaruh#
Untuk pull request, Anda mungkin tidak ingin mem-build semuanya. Anda ingin mem-build hanya apa yang terpengaruh oleh perubahan:
# Hanya build/test yang berubah dibandingkan main
- name: Build affected
run: pnpm turbo build --filter="...[origin/main]"
- name: Test affected
run: pnpm turbo test --filter="...[origin/main]"Sintaks [origin/main] memberi tahu Turborepo untuk membandingkan commit saat ini dengan origin/main dan hanya menjalankan task untuk package yang memiliki perubahan. Dikombinasikan dengan remote caching, kebanyakan pipeline PR selesai dalam waktu kurang dari 2 menit bahkan untuk monorepo besar.
Strategi Matrix untuk Deployment#
Jika setiap app deploy secara independen, gunakan strategi matrix:
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: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo build --filter=apps/${{ matrix.app }}...
- name: Deploy ${{ matrix.app }}
run: ./scripts/deploy.sh ${{ matrix.app }}
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Setiap matrix job mem-build satu app dan dependensinya. Berkat remote caching, shared packages di-build sekali dan di-cache — setiap matrix job berikutnya memulihkannya dari cache.
Jebakan Umum#
Saya mendedikasikan satu bagian penuh untuk ini karena saya menabrak setiap satu dari kesalahan ini di bulan pertama saya. Belajar dari dokumentasi itu efisien. Belajar dari kesalahan saya itu gratis.
1. Circular Dependencies#
Dependency graph harus berupa DAG (directed acyclic graph). Jika @acme/ui mengimpor dari @acme/utils dan @acme/utils mengimpor dari @acme/ui, Anda punya siklus. Turborepo akan mengeluh dan menolak build.
Perbaikannya hampir selalu mengekstrak kode bersama ke package ketiga:
SEBELUM (rusak):
@acme/ui → @acme/utils → @acme/ui ← siklus!
SESUDAH (diperbaiki):
@acme/ui → @acme/types
@acme/utils → @acme/types
Pindahkan tipe atau konstanta bersama ke @acme/types dan buat kedua package bergantung padanya.
Penyebab umum lainnya: Anda menaruh React hook di @acme/utils yang mengimpor komponen dari @acme/ui. Hook yang bergantung pada komponen UI miliknya di @acme/ui, bukan @acme/utils. Utility sebaiknya framework-agnostic sebisa mungkin.
2. outputs yang Hilang Menginvalidasi Cache#
Ini adalah bug paling membuat frustrasi. Semuanya tampak berfungsi secara lokal. CI build berhasil. Tapi setiap build memakan waktu penuh karena tidak ada yang di-cache.
Skenario: package Anda build ke build/ alih-alih dist/, tapi turbo.json Anda hanya mencantumkan dist/** di outputs:
// turbo.json
"build": {
"outputs": ["dist/**"] // tapi package Anda build ke build/
}Turborepo meng-cache task (karena berhasil dijalankan) tapi tidak menyimpan file output apa pun. Kali berikutnya, ia mendapat cache "hit" dan memulihkan kosong. Aplikasi yang mengonsumsi tidak bisa menemukan output build dan gagal, atau — lebih buruk — secara diam-diam menggunakan file usang.
Perbaikan: audit direktori output build setiap package dan pastikan turbo.json mencakup semuanya:
"build": {
"outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}Atau gunakan override per-package di turbo.json:
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
},
// Override per-package
"packages": {
"apps/web": {
"build": {
"outputs": [".next/**", "!.next/cache/**"]
}
},
"apps/api": {
"build": {
"outputs": ["build/**"]
}
}
}
}3. Koordinasi Dev Server#
Menjalankan turbo dev memulai semua dev server secara bersamaan. Ini baik-baik saja sampai aplikasi Anda mencoba menggunakan port yang sama. Penugasan port eksplisit itu wajib:
// 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" // Menggunakan port 4000 dari konfigurasi
}Masalah lain: hot module replacement untuk shared packages. Ketika Anda mengedit file di packages/ui, perubahan perlu dipropagasi ke dev server Next.js yang berjalan. Jika Anda menggunakan pendekatan "internal packages" (tanpa langkah build, import TypeScript langsung), ini berfungsi secara otomatis — Next.js mengawasi source files melalui symlinked node_modules.
Jika package Anda punya langkah build, Anda memerlukan script dev package untuk berjalan dalam mode watch:
// packages/ui/package.json
"scripts": {
"dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch"
}Turborepo menjalankan semua task dev secara paralel, jadi package rebuild saat ada perubahan dan aplikasi yang mengonsumsi mengambil output baru.
4. Version Drift Antar Package#
Ini halus dan berbahaya. Jika apps/web menggunakan React 19 dan apps/admin secara tidak sengaja menggunakan React 18, Anda mungkin tidak menyadarinya sampai komponen dari @acme/ui berperilaku berbeda di setiap app.
Solusi: kelola shared dependencies di level root. Field overrides pnpm di root package.json memaksa satu versi di mana-mana:
// Root package.json
{
"pnpm": {
"overrides": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
}
}
}Jalankan pnpm ls react --recursive secara teratur untuk memverifikasi semuanya menggunakan versi yang sama.
5. Environment Variables di Shared Packages#
Shared packages tidak boleh membaca process.env secara langsung. Jika @acme/utils membaca process.env.API_URL, itu membuat coupling tak terlihat ke environment aplikasi yang mengonsumsi. Sebagai gantinya, berikan konfigurasi secara eksplisit:
// BURUK: packages/utils/src/api.ts
const API_URL = process.env.API_URL; // Env apa? App mana?
export function fetchData(path: string) {
return fetch(`${API_URL}${path}`);
}
// BAIK: packages/utils/src/api.ts
export function createApiClient(baseUrl: string) {
return {
fetch: (path: string) => fetch(`${baseUrl}${path}`),
};
}Aplikasi yang mengonsumsi menyediakan konfigurasi:
// apps/web/src/lib/api.ts
import { createApiClient } from "@acme/utils";
export const api = createApiClient(process.env.NEXT_PUBLIC_API_URL!);Ini menjaga package tetap murni dan bisa ditest.
6. Ghost Dependencies#
pnpm ketat tentang dependencies secara default — ia tidak meng-hoist package seperti yang npm lakukan. Ini fitur, bukan bug. Ini berarti jika @acme/ui mengimpor clsx tapi tidak mencantumkannya di package.json-nya, pnpm akan melempar error. npm akan me-resolve-nya secara diam-diam dari parent node_modules.
Selalu deklarasikan setiap import di package.json package yang mengonsumsi. Jangan lagi bergantung pada hoisting.
7. Degradasi Performa IDE#
Dengan 15+ package, TypeScript language server Anda mungkin mulai kesulitan. Beberapa tips:
- Tambahkan
"exclude": ["node_modules", "**/dist/**"]ke roottsconfig.jsonAnda - Gunakan "Files: Exclude" VS Code untuk menyembunyikan folder
dist/,.next/, dan.turbo/ - Pertimbangkan
"disableSourceOfProjectReferenceRedirect": truedi tsconfig jika Go to Definition mengirim Anda ke file.d.tsjauh di dalamnode_modules
Struktur Starter: Semuanya Digabungkan#
Mari saya satukan semuanya. Berikut setiap file yang Anda butuhkan untuk bootstrap Turborepo monorepo dengan dua Next.js app dan tiga shared packages.
Root#
// 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"
}
}
}# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// 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
}
}
}// tsconfig.json (root — untuk IDE saja, tidak digunakan oleh build)
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"exclude": ["node_modules", "**/dist/**", "**/.next/**"]
}apps/web#
// 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"
}
}// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
reactStrictMode: true,
};
export default nextConfig;// 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#
// 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#
// 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"
}
}// 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";
// Tipe-tipe di re-export
export type { ButtonProps } from "./components/Button";
export type { InputProps } from "./components/Input";
export type { ModalProps } from "./components/Modal";// 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#
// 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"
}
}// 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));
}// 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 "baru saja";
if (diffMinutes < 60) return `${diffMinutes}m lalu`;
if (diffHours < 24) return `${diffHours}j lalu`;
if (diffDays < 7) return `${diffDays}h lalu`;
return date.toLocaleDateString("id-ID", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function formatDate(date: Date, locale = "id-ID"): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
}// 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);
}// packages/utils/src/index.ts
export { cn } from "./cn";
export { formatRelativeDate, formatDate } from "./date";
export { slugify, truncate, capitalize } from "./string";packages/types#
// 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#
# Dependencies
node_modules/
# Build outputs
dist/
build/
.next/
out/
# Turborepo
.turbo/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/settings.json
.idea/
# OS
.DS_Store
Thumbs.db
Memulai dari Nol#
Jika Anda memulai dari awal, berikut urutan perintah yang tepat:
# Buat repo
mkdir acme-monorepo && cd acme-monorepo
git init
# Inisialisasi root package
pnpm init
# Buat file workspace
echo 'packages:\n - "apps/*"\n - "packages/*"' > pnpm-workspace.yaml
# Install Turborepo
pnpm add -D turbo -w
# Buat direktori
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
# Inisialisasi apps (dari direktori masing-masing)
cd apps/web && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../admin && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../..
# Tambahkan workspace dependencies
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 ../..
# Install semuanya
pnpm install
# Verifikasi berfungsi
pnpm turbo build
pnpm turbo devpnpm turbo build pertama akan lambat — semuanya build dari nol. Yang kedua seharusnya hampir instan jika tidak ada yang berubah. Itulah cache bekerja.
Meningkatkan Skala Melampaui Dasar#
Setelah Anda melewati setup awal, beberapa pola muncul seiring monorepo tumbuh.
Package Generators#
Setelah package kesepuluh Anda, membuat yang baru dengan menyalin dan mengedit menjadi membosankan. Buat script:
// scripts/create-package.ts
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const name = process.argv[2];
if (!name) {
console.error("Usage: tsx scripts/create-package.ts <package-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"), "// Entry point\n");
console.log(`Dibuat packages/${name}`);
console.log("Jalankan: pnpm install");npx tsx scripts/create-package.ts email
# Membuat packages/email dengan semua boilerplateVisualisasi Dependency Graph Workspace#
Turborepo punya perintah graph bawaan:
pnpm turbo build --graph=graph.svgIni menghasilkan SVG dari dependency graph Anda. Saya menjalankan ini sebelum refactor besar untuk memahami blast radius dari sebuah perubahan. Jika menyentuh @acme/types me-rebuild 12 package, mungkin saatnya memecahnya menjadi @acme/types-user dan @acme/types-billing.
Pruning untuk Docker#
Saat men-deploy satu app dari monorepo, Anda tidak ingin menyalin seluruh repo ke Docker image Anda. Turborepo punya perintah prune:
pnpm turbo prune --scope=apps/web --dockerIni menghasilkan direktori out/ yang hanya berisi file yang diperlukan untuk apps/web dan dependensinya. Dockerfile Anda menggunakan output yang di-prune ini:
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"]Docker image hanya berisi web app dan dependensinya. Tanpa admin app, tanpa API server, tanpa package yang tidak digunakan. Ukuran image tetap wajar meskipun monorepo tumbuh.
Satu Bulan Kemudian: Apa yang Akan Saya Lakukan Berbeda#
Jika saya memulai ulang, dengan pengetahuan yang saya punya sekarang:
-
Mulai dengan internal packages (tanpa langkah build). Saya membuang dua minggu mengkonfigurasi build tsup untuk package yang hanya aplikasi Next.js saya konsumsi.
transpilePackagesdi konfigurasi Next.js lebih sederhana dan lebih cepat. Tambahkan langkah build hanya ketika Anda membutuhkannya. -
Jaga jumlah
packages/tetap rendah di awal. Saya memisahkan terlalu agresif sejak dini.packages/utils,packages/types, danpackages/uisudah cukup untuk memulai. Anda selalu bisa memisahkan nanti ketika sebuah package tumbuh sulit dikelola. Memisahkan terlalu dini berarti lebih banyak filepackage.jsonyang harus dipelihara dan lebih banyak edge di dependency graph. -
Siapkan remote caching di hari pertama.
npx turbo login && npx turbo linkmemakan waktu 30 detik. Waktu yang dihemat untuk build di minggu pertama saja sudah membenarkannya. -
Dokumentasikan perintah workspace. Developer baru tidak tahu bahwa
pnpm --filter @acme/ui add lodashmenginstall ke package tertentu, atau bahwapnpm turbo build --filter=apps/web...mem-build hanya apa yang diperlukan. Bagian sederhana "Monorepo Cheatsheet" di panduan kontribusi Anda menghemat waktu semua orang. -
Tegakkan arah dependency dari hari pertama. Jika Anda mengizinkan bahkan satu import dari app ke package, batasnya terkikis cepat. Tambahkan lint rule atau CI check. Arahnya adalah
apps → packages → packages. Tidak pernah sebaliknya.
Monorepo bukan tujuannya. Tujuannya adalah shipping fitur tanpa bertarung dengan codebase Anda sendiri. Turborepo adalah tool paling ringan yang saya temukan untuk membuat itu berfungsi. Ia menangani task graph, ia menangani caching, dan ia menyingkir untuk segala hal lainnya.
Mulai sederhana. Tambahkan kompleksitas ketika repo menuntutnya. Dan pin port Anda.