सामग्री पर जाएं
·31 मिनट पढ़ने का समय

Turborepo Monorepos: वो Setup जो वाकई Scale करता है

Multiple apps sharing packages के लिए मैं Turborepo monorepos कैसे structure करता हूं। Workspace configuration, pipeline caching, shared TypeScript configs, और पहले महीने में की गई गलतियां।

साझा करें:X / TwitterLinkedIn

मैंने अपने career के पहले तीन साल utility functions को repositories के बीच copy करने में बिताए। बढ़ा-चढ़ाकर नहीं कह रहा। मेरे पास एक formatDate.ts था जो सात अलग-अलग projects में रहता था, हर एक में थोड़ी-सी अलग bugs के साथ। जब मैंने एक में timezone issue fix किया, तो बाकी छह के बारे में भूल गया। Eventually Australia में कोई user ticket file करता और मुझे पता चलता कि fix उस particular app में production तक कभी पहुंचा ही नहीं।

Monorepo ने यह solve किया। Trendy होने के लिए नहीं, Google करता है इसलिए नहीं, बल्कि मैं human package registry होने से थक गया था। एक repository, shared code, हर app में atomic changes जो इसे consume करती है।

लेकिन monorepos की अपनी failure modes हैं। मैंने तीन अलग tools try किए, broken caching पर हफ्ते बर्बाद किए, आधी रात को circular dependency errors से लड़ा, और सीखा कि "बस सब कुछ एक repo में डाल दो" details के बिना उतना ही उपयोगी advice है।

यह वो Turborepo setup है जो मैं actually use करता हूं। यह चार production apps को बारह shared packages के साथ run करता है। Remote caching की वजह से builds 90 seconds से कम में होते हैं। नए developers clone करके pnpm dev run कर सकते हैं और दो minutes से कम में सब कुछ काम करता है। यहां तक पहुंचने में मुझे करीब एक महीने की गलतियां लगीं।

पहले Monorepo क्यों#

Tradeoffs के बारे में honest रहते हैं। Monorepo free नहीं है। आप एक set of problems को दूसरे set से trade कर रहे हैं, और आपको जानना होगा कि आप क्या खरीद रहे हैं।

आपको क्या मिलता है#

बिना publish किए code sharing। यह सबसे बड़ी बात है। आप packages/ui में एक React component library लिखते हैं। आपकी web app, admin dashboard, और docs site सभी इससे import करती हैं। जब आप एक button component बदलते हैं, हर app इसे immediately pick कर लेती है। कोई version bumping नहीं, npm publish नहीं, "production कौन सा version use कर रहा है?" नहीं।

typescript
// In apps/web/src/components/Header.tsx
import { Button, Avatar, Dropdown } from "@acme/ui";
import { formatDate } from "@acme/utils";
import { SITE_CONFIG } from "@acme/config";

ये imports external packages जैसे दिखते हैं। ये workspace dependencies के through resolve होते हैं। लेकिन ये same repository में source code की तरफ point करते हैं।

Atomic cross-package changes। सोचिए आपको अपने shared Button component में isLoading prop add करना है। Polyrepo world में, आप component library बदलते, नया version publish करते, हर consuming app में version update करते, फिर हर repo में PRs खोलते। एक prop के लिए तीन से पांच PRs।

Monorepo में, एक commit:

feat: add isLoading prop to Button component

- packages/ui/src/Button.tsx (add prop, render spinner)
- apps/web/src/pages/checkout.tsx (use isLoading)
- apps/admin/src/pages/users.tsx (use isLoading)

एक PR। एक review। एक merge। सब कुछ sync रहता है क्योंकि physically drift हो ही नहीं सकता।

Unified tooling। एक ESLint config। एक Prettier config। एक TypeScript base config। एक CI pipeline। जब आप ESLint upgrade करते हैं, तो एक दोपहर में हर जगह upgrade हो जाता है, आठ repos में तीन sprints तक नहीं।

आपको क्या चुकाना पड़ता है#

Root पर complexity। आपका root package.json infrastructure बन जाता है। आपकी CI pipeline को समझना होगा कौन से packages बदले। आपका IDE शायद files की sheer number से struggle करे। Git operations repo बढ़ने पर slow हो जाते हैं (हालांकि ज़्यादातर teams के लिए यह years लगता है)।

CI time balloon हो सकता है। अगर आप हर commit पर सब कुछ build करते हैं, तो बहुत compute बर्बाद करेंगे। आपको ऐसा tool चाहिए जो dependency graph समझे और सिर्फ़ वो build करे जो बदला। Turborepo exist करने की पूरी वजह यही है।

Onboarding friction। नए developers को workspaces, hoisting, internal packages, build pipeline समझना होगा। सिर्फ "clone and run" नहीं है। Well, eventually होना चाहिए, लेकिन वहां पहुंचने में deliberate effort लगता है।

Honest assessment: अगर आपके पास एक app है जिसमें कोई shared code नहीं, तो monorepo बिना फायदे के overhead है। अगर दो या ज़्यादा apps कुछ भी share करती हैं — components, utilities, types, configs — monorepo पहले महीने में ही worth हो जाता है।

Turborepo vs Nx vs Lerna#

मैंने तीनों use किए हैं। Short version यह है।

Lerna JavaScript का original monorepo tool था। यह versioning और publishing manage करता है। लेकिन build pipelines या caching नहीं समझता। Abandon हुआ, Nx ने revive किया, और अब standalone tool से ज़्यादा compatibility layer feel होता है। अगर npm पर packages publish करना है, Lerna + Nx handle कर सकता है। लेकिन internal monorepos के लिए जहां सिर्फ अपनी apps के बीच code share करना है, ज़रूरत से ज़्यादा ceremony है।

Nx powerful है। बहुत powerful। Generators हैं, हर framework के लिए plugins, visual dependency graph, distributed task execution। Learning curve भी cliff face जैसा है। मैंने teams को सिर्फ Nx configure करने में दो हफ्ते बिताते देखा है कोई product code लिखने से पहले। अगर 50+ developers और सैकड़ों packages वाली company हैं, तो Nx शायद सही choice है। मेरे use cases के लिए, यह bulldozer है जब मुझे shovel चाहिए।

Turborepo तीन चीज़ें अच्छे से करता है: dependency graph समझता है, build outputs cache करता है, और tasks parallel run करता है। बस इतना। पूरी configuration एक turbo.json file है। दो minutes में पूरी पढ़ सकते हैं। Code generate नहीं करता, plugins नहीं हैं, build tool replace करने की कोशिश नहीं करता। Task runner है जो बहुत अच्छे से जानता है क्या run करना है और क्या skip किया जा सकता है।

मैंने Turborepo choose किया क्योंकि पूरा setup एक नए team member को 15 minutes में explain कर सकता था। Nx के साथ, वही conversation एक घंटा लेता था और फिर भी सवाल बचते थे।

Feature           | Turborepo     | Nx              | Lerna
------------------+---------------+-----------------+----------------
Config complexity | Low (1 file)  | High (multiple) | Medium
Learning curve    | ~1 day        | ~1 week         | ~2 days
Build caching     | Yes (remote)  | Yes (remote)    | No (native)
Task orchestration| Yes           | Yes             | Basic
Code generation   | No            | Yes (extensive) | No
Framework plugins | No            | Yes             | No
Best for          | Small-medium  | Large orgs      | Publishing

Repository Structure#

यह actual directory tree है। "Getting started" example नहीं — छह महीने बाद production monorepo ऐसा दिखता है।

acme-monorepo/
├── apps/
│   ├── web/                    # Main customer-facing app
│   │   ├── src/
│   │   ├── public/
│   │   ├── next.config.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── admin/                  # Internal admin dashboard
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── docs/                   # Documentation site
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── api/                    # Express/Fastify API server
│       ├── src/
│       ├── package.json
│       └── tsconfig.json
├── packages/
│   ├── ui/                     # Shared React component library
│   │   ├── src/
│   │   │   ├── components/
│   │   │   │   ├── Button.tsx
│   │   │   │   ├── Input.tsx
│   │   │   │   ├── Modal.tsx
│   │   │   │   └── index.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── utils/                  # Shared utility functions
│   │   ├── src/
│   │   │   ├── date.ts
│   │   │   ├── string.ts
│   │   │   ├── validation.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── types/                  # Shared TypeScript types
│   │   ├── src/
│   │   │   ├── user.ts
│   │   │   ├── api.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── config-typescript/      # Shared tsconfig presets
│   │   ├── base.json
│   │   ├── nextjs.json
│   │   ├── node.json
│   │   └── package.json
│   ├── config-eslint/          # Shared ESLint configurations
│   │   ├── base.js
│   │   ├── next.js
│   │   ├── node.js
│   │   └── package.json
│   └── config-tailwind/        # Shared Tailwind presets
│       ├── tailwind.config.ts
│       └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json

apps/ vs packages/ Split#

यह fundamental organizing principle है। apps/ में deployable चीज़ें हैं — web apps, APIs, कुछ भी जिसमें dev या start command हो। packages/ में libraries हैं — code जो apps या दूसरे packages द्वारा consume होने के लिए exist करता है।

Rule simple है: apps packages consume करती हैं। Packages कभी apps से import नहीं करते। Packages दूसरे packages से import कर सकते हैं।

अगर कोई package किसी app से import करने लगे, तो dependency graph उल्टा हो गया और Turborepo इसे cycle के रूप में पकड़ लेगा।

Workspace Configuration#

Root pnpm-workspace.yaml pnpm को बताता है packages कहां find करने हैं:

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

बस इतनी file है। pnpm उन directories को scan करता है, हर package.json पढ़ता है, और workspace map बनाता है। जब apps/web declare करता है "@acme/ui": "workspace:*" dependency के रूप में, pnpm इसे npm registry में ढूंढने के बजाय local packages/ui से link करता है।

Root package.json#

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"
  }
}

ध्यान दें: root package.json में कोई runtime dependencies नहीं हैं। यह purely orchestration है। turbo task runner है, prettier formatting handle करता है (क्योंकि यह एक tool है जिसे per-package configuration नहीं चाहिए), और typescript hoisted है ताकि हर package same version use करे।

Naming Conventions#

हर package को scoped name मिलता है: @acme/ui, @acme/utils, @acme/config-typescript। Scope npm packages से collisions prevent करता है और किसी भी import statement में immediately obvious बनाता है कि internal code use कर रहे हैं या external।

typescript
// External package - from npm
import { clsx } from "clsx";
 
// Internal package - from our monorepo
import { Button } from "@acme/ui";

मैं config packages को config- prefix करता हूं visually group करने के लिए: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind। कुछ teams @acme/tsconfig, @acme/eslint-config use करती हैं। दोनों काम करता है। बस consistent रहें।

turbo.json Pipeline Configuration#

यहीं Turborepo अपना काम करता है। turbo.json file आपकी task pipeline define करती है — कौन से tasks exist करते हैं, वो किस पर depend करते हैं, और क्या produce करते हैं।

json
{
  "$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
    }
  }
}

हर field break down करता हूं क्योंकि यहीं ज़्यादातर लोग confuse होते हैं।

dependsOn और ^ Notation#

"dependsOn": ["^build"] का मतलब: "Package X में यह task run करने से पहले, पहले build run करो हर उस package में जिस पर X depend करता है।"

^ symbol का मतलब "upstream dependencies" है। इसके बिना, "dependsOn": ["build"] का मतलब होगा "same package में पहले build run करो" — useful अगर आपके test task को same package के अंदर build पहले होना चाहिए।

एक concrete example। आपका dependency graph ऐसा दिखता है:

apps/web → @acme/ui → @acme/utils
                    → @acme/types

जब turbo build run करते हैं, Turborepo graph resolve करता है:

  1. @acme/types build करो (कोई dependencies नहीं)
  2. @acme/utils build करो (कोई dependencies नहीं)
  3. @acme/ui build करो (types और utils पर depend करता है — उनका wait करता है)
  4. apps/web build करो (ui पर depend करता है — उसका wait करता है)

Steps 1 और 2 parallel run होते हैं। Step 3 दोनों का wait करता है। Step 4 step 3 का wait करता है। Turborepo यह आपके package.json dependency declarations से figure out करता है। Order manually specify करने की ज़रूरत नहीं।

outputs#

यह caching के लिए critical है। जब Turborepo build cache करता है, तो outputs में listed files store करता है। Cache hit मिलने पर, वो files restore करता है। अगर output directory list करना भूल गए, cache "succeed" करेगा लेकिन files appear नहीं होंगी। पहले हफ्ते में मेरी सबसे बड़ी गलती यही थी — .next/** miss कर दिया और समझ नहीं आ रहा था Next.js app हमेशा rebuild क्यों हो रही है।

json
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]

!.next/cache/** exclusion important है। Next.js का अपना cache .next/cache/ के अंदर होता है। Cache का cache store नहीं करना चाहते — remote cache storage bloat करता है और help नहीं करता।

inputs#

Default में, Turborepo package में हर file hash करता है यह determine करने के लिए कि बदला है या नहीं। inputs field इसे narrow करता है। अगर सिर्फ src/ की files build output affect करती हैं, तो README.md बदलने से cache invalidate नहीं होना चाहिए।

json
"inputs": ["src/**", "package.json", "tsconfig.json"]

इसके साथ careful रहें। अगर कोई config file add करते हैं जो build affect करती है (जैसे postcss.config.js) और inputs में include नहीं करते, तो stale cached output मिलेगा। जब doubt हो, inputs unset छोड़ दो और Turborepo को सब कुछ hash करने दो।

globalDependencies#

यहां listed files बदलने पर हर package का cache invalidate हो जाता है। Environment files classic example हैं — अगर .env.local बदला, तो process.env से पढ़ने वाली हर चीज़ को rebuild होना चाहिए।

json
"globalDependencies": ["**/.env.*local"]

मैं root level पर tsconfig.json भी add करता हूं क्योंकि base TypeScript config सभी packages affect करता है:

json
"globalDependencies": [
  "**/.env.*local",
  "tsconfig.json"
]

persistent और dev#

dev task में "cache": false और "persistent": true है। Dev server cache करने का कोई मतलब नहीं — long-running process है। persistent flag Turborepo को बताता है कि यह task exit नहीं होता, तो दूसरे tasks run करने से पहले इसका wait न करे।

जब turbo dev run करते हैं, Turborepo सभी dev servers parallel start करता है — Next.js app, API server, docs site — सब एक terminal में interleaved output के साथ। हर line package name से prefixed होती है ताकि अलग कर सकें।

Shared Packages विस्तार से#

packages/ui — Component Library#

यह वो package है जो हर team सबसे पहले build करती है। Shared React components जो सभी frontend apps में use होते हैं।

json
{
  "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"
  }
}

कुछ चीज़ें notice करें:

"version": "0.0.0" — Internal packages को real versions की ज़रूरत नहीं। workspace:* protocol pnpm को बताता है हमेशा local copy use करो। Version numbers irrelevant हैं।

"private": true — यह package कभी npm पर publish नहीं होगा। अगर कभी publish करना हो, यह flag हटाओ और proper versioning strategy set up करो।

exports field — यह package entry points define करने का modern तरीका है। main, module, और types fields को replace करता है। "." export default import path है। Granular imports के लिए sub-path exports add कर सकते हैं:

json
{
  "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"
  }
}

Important decision: build करना है या नहीं? कुछ teams internal packages के लिए build step पूरा skip करती हैं। tsup से compile करने के बजाय, exports directly TypeScript source पर point करती हैं और consuming app का bundler (Next.js, Vite) compilation handle करता है। Turborepo की terminology में इसे "internal packages" कहते हैं और यह simpler है:

json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts"
  }
}

कोई build step नहीं। कोई dist folder नहीं। Consuming app के next.config.ts में transpilePackages चाहिए:

typescript
// apps/web/next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@acme/ui", "@acme/utils"],
};
 
export default nextConfig;

मैं ज़्यादातर internal packages के लिए no-build approach use करता हूं। Development के दौरान faster है और एक कम चीज़ break होने के लिए। Exception वो packages हैं जिन्हें non-TypeScript consumers support करना हो या complex build requirements हों।

packages/utils — Shared Utilities#

json
{
  "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 consuming apps को सिर्फ वही import करने देते हैं जो चाहिए:

typescript
// सिर्फ date utilities import करता है, पूरा package नहीं
import { formatRelativeDate, parseISO } from "@acme/utils/date";
 
// सब कुछ import करता है (barrel export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";

src/index.ts में barrel export सब कुछ re-export करता है:

typescript
// packages/utils/src/index.ts
export * from "./date";
export * from "./string";
export * from "./validation";

Barrel exports पर एक चेतावनी: convenient हैं लेकिन कुछ bundlers में tree-shaking kill कर सकते हैं। अगर apps/web @acme/utils से एक function import करती है, naive bundler सब कुछ include कर सकता है। Next.js अपनी built-in tree-shaking से इसे अच्छे से handle करता है, लेकिन अगर bundle size issues notice करें, तो sub-path exports pattern safer है।

packages/types — Shared TypeScript Types#

json
{
  "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 की zero runtime dependencies हैं। सिर्फ TypeScript types और interfaces contain करता है। इसे कभी build करने की ज़रूरत नहीं क्योंकि types compile time पर erase हो जाते हैं।

typescript
// 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;
}
typescript
// 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 — Shared TSConfigs#

यह deceptively important है। Monorepo में TypeScript configuration वो जगह है जहां चीज़ें तेज़ी से complicated हो जाती हैं।

json
{
  "name": "@acme/config-typescript",
  "version": "0.0.0",
  "private": true,
  "files": [
    "base.json",
    "nextjs.json",
    "node.json",
    "react-library.json"
  ]
}

Base configuration जो सभी packages extend करते हैं:

json
// 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"]
}

Next.js-specific config:

json
// 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" }
    ]
  }
}

Backend packages के लिए Node.js config:

json
// 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"
  }
}

अब हर app या package appropriate preset extend करता है:

json
// 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"]
}
json
// apps/api/tsconfig.json
{
  "extends": "@acme/config-typescript/node",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}
json
// 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#

हर app को same @/* path alias मिलता है जो अपनी src/ directory की तरफ point करता है। यानी apps/web में @/components/Header resolve होता है apps/web/src/components/Header पर, और apps/admin में @/components/Header resolve होता है apps/admin/src/components/Header पर।

ये app-local paths हैं। Package boundaries cross नहीं करते। Cross-package imports के लिए, हमेशा package name use करें: @acme/ui, @/../../packages/ui नहीं। अगर कभी relative import दिखे जो package root से ऊपर जाए (../../packages/something), वो code smell है। Workspace dependency use करें।

composite और Project References#

TypeScript project references (composite: true) tsc को packages incrementally build करने और cross-project dependencies समझने देते हैं। Turborepo के साथ यह optional है — Turborepo खुद build orchestration handle करता है — लेकिन IDE में type checking speed कर सकता है।

json
// packages/ui/tsconfig.json (with composite)
{
  "extends": "@acme/config-typescript/base",
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "jsx": "react-jsx"
  },
  "include": ["src"],
  "references": [
    { "path": "../types" },
    { "path": "../utils" }
  ]
}

Honest रहूं: project references complexity add करती हैं और मैंने ज़्यादातर setups से हटा दी हैं। Turborepo का --filter और dependsOn build order पहले से handle करता है। मैं composite: true तभी add करता हूं जब IDE performance problem बने (usually 15+ packages पर)।

packages/config-eslint — Shared Linting#

javascript
// 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" }
      ],
    },
  }
);
javascript
// 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",
    },
  },
];

हर app अपना config reference करती है:

javascript
// apps/web/eslint.config.mjs
import nextConfig from "@acme/config-eslint/next";
 
export default [
  ...nextConfig,
  {
    ignores: [".next/"],
  },
];

Remote Caching#

Local caching अच्छा है — development के दौरान unchanged packages rebuild होने से रोकता है। लेकिन remote caching transformative है। इसका मतलब आपका CI server आपकी local builds से benefit करता है और vice versa।

यह कैसे काम करता है#

जब Turborepo कोई task run करता है, hash compute करता है इन चीज़ों के basis पर:

  • Source files (content hash)
  • Task की inputs configuration
  • Environment variables
  • Upstream dependencies के hashes

अगर hash किसी previously cached result से match करता है, Turborepo task run करने के बजाय cache से outputs restore करता है। Remote caching में, वो cached outputs shared location में store होते हैं जिसे कोई भी machine — आपका laptop, colleague का laptop, CI — access कर सकती है।

Vercel Remote Cache#

सबसे simple setup। Vercel cache infrastructure free (limits के साथ) maintain करता है:

bash
# Login to Vercel (one-time setup)
npx turbo login
 
# Link your repo to a Vercel project
npx turbo link

बस इतना। Turborepo अब Vercel के servers से cache artifacts push और pull करेगा। CI पर, environment variables set करें:

yaml
# .github/workflows/ci.yml
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Token Vercel dashboard से team settings के under generate करें। TURBO_TEAM आपका team slug है।

Self-Hosted Remote Cache#

अगर Vercel use नहीं कर सकते (air-gapped environment, data residency requirements), तो self-host कर सकते हैं। Turborepo remote cache API well-documented है और कई open-source implementations हैं:

  • ducktors/turborepo-remote-cache — Node.js server जो S3, GCS, या local filesystem में artifacts store करता है
  • fox1t/turborepo-remote-cache — Similar, Azure Blob support के साथ
bash
# docker-compose.yml for 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"

फिर monorepo को configure करें इसे use करने के लिए:

json
// .turbo/config.json
{
  "teamId": "team_acme",
  "apiUrl": "https://turbo-cache.internal.acme.com"
}

Cache Hit Rates#

मेरे experience में, well-configured Turborepo CI में 80-95% cache hit rates achieve करता है। यानी सिर्फ 5-20% tasks actually किसी भी CI pipeline पर run होते हैं। बाकी seconds में cache से restore होते हैं।

High cache hit rates की keys:

  1. inputs narrow करें। अगर सिर्फ src/ build affect करता है, README बदलने से cache invalidate न होने दें।
  2. Volatile data globalDependencies में न डालें। हर deploy पर बदलने वाली .env file cache kill कर देती है।
  3. Dependencies pin करें। Lockfile changes downstream सब कुछ invalidate करती हैं।
  4. Packages छोटे और focused रखें। 200 files वाला giant packages/utils frequently cache invalidate होगा क्योंकि किसी भी utility में कोई भी change पूरे package का cache bust करता है।

CI/CD Turborepo के साथ#

यह GitHub Actions workflow है जो मैं use करता हूं। दर्जनों iterations से refined है।

yaml
# .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   # Need parent commit for change detection
 
      - 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 test

--filter Flag#

--filter flag specific packages के लिए tasks run करने या क्या बदला इसके basis पर run करने का तरीका है। Efficient CI के लिए essential:

bash
# सिर्फ web app और उसकी dependencies build करो
pnpm turbo build --filter=apps/web...
 
# सिर्फ main से बदले packages build करो
pnpm turbo build --filter=...[main]
 
# सिर्फ @acme/ui और उस पर depend करने वाला सब कुछ build करो
pnpm turbo build --filter=...@acme/ui
 
# सिर्फ packages/ directory में packages build करो
pnpm turbo build --filter="./packages/*"

Three-dot syntax important है:

  • --filter=@acme/ui... — Package और उसकी सारी dependencies (जो चीज़ें इसे चाहिए)
  • --filter=...@acme/ui — Package और उसके सारे dependents (जो चीज़ें इसे चाहती हैं)
  • --filter=@acme/ui — सिर्फ package itself

Affected Packages Detection#

Pull requests के लिए, शायद सब कुछ build नहीं करना चाहते। सिर्फ changes से affected build करना चाहते हैं:

yaml
# सिर्फ main की comparison में जो बदला वो build/test करो
- name: Build affected
  run: pnpm turbo build --filter="...[origin/main]"
 
- name: Test affected
  run: pnpm turbo test --filter="...[origin/main]"

[origin/main] syntax Turborepo को बताता है current commit को origin/main से compare करो और सिर्फ changes वाले packages के लिए tasks run करो। Remote caching के साथ combine करें, ज़्यादातर PR pipelines बड़े monorepos में भी 2 minutes से कम में finish होती हैं।

Deployments के लिए Matrix Strategy#

अगर हर app independently deploy होती है, matrix strategy use करें:

yaml
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 }}

हर matrix job एक app और उसकी dependencies build करता है। Remote caching की बदौलत, shared packages एक बार build और cache होते हैं — हर subsequent matrix job उन्हें cache से restore करता है।

Common Pitfalls#

इसके लिए पूरा section dedicated कर रहा हूं क्योंकि मैंने पहले महीने में हर एक में गिरा। Documentation से सीखना efficient है। मेरी गलतियों से सीखना free है।

1. Circular Dependencies#

Dependency graph DAG (directed acyclic graph) होना चाहिए। अगर @acme/ui @acme/utils से import करता है और @acme/utils @acme/ui से import करता है, cycle है। Turborepo complain करेगा और build refuse करेगा।

Fix almost हमेशा shared code को तीसरे package में extract करना है:

BEFORE (broken):
@acme/ui → @acme/utils → @acme/ui  ← cycle!

AFTER (fixed):
@acme/ui     → @acme/types
@acme/utils  → @acme/types

Shared types या constants को @acme/types में move करो और दोनों packages को उस पर depend कराओ।

एक और common cause: @acme/utils में React hook डालते हैं जो @acme/ui से component import करता है। UI components पर depend करने वाले hooks @acme/ui में belong करते हैं, @acme/utils में नहीं। Utilities जहां तक possible हो framework-agnostic होनी चाहिए।

2. Missing outputs Cache Invalidate करना#

सबसे frustrating bug यही है। Locally सब कुछ काम करता लगता है। CI builds pass होते हैं। लेकिन हर build full time लेता है क्योंकि कुछ भी cached नहीं है।

Scenario: package build/ में build करता है dist/ के बजाय, लेकिन turbo.json सिर्फ dist/** outputs में list करता है:

json
// turbo.json
"build": {
  "outputs": ["dist/**"]  // but your package builds to build/
}

Turborepo task cache करता है (क्योंकि successfully run हुआ) लेकिन कोई output files store नहीं करता। अगली बार cache "hit" मिलती है और कुछ restore नहीं होता। Consuming app build output find नहीं कर पाती और fail होती है, या — worse — चुपचाप stale files use करती है।

Fix: हर package की build output directory audit करो और सुनिश्चित करो turbo.json सब cover करता है:

json
"build": {
  "outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}

या turbo.json में per-package overrides use करो:

json
{
  "tasks": {
    "build": {
      "outputs": ["dist/**"]
    }
  },
  // Per-package overrides
  "packages": {
    "apps/web": {
      "build": {
        "outputs": [".next/**", "!.next/cache/**"]
      }
    },
    "apps/api": {
      "build": {
        "outputs": ["build/**"]
      }
    }
  }
}

3. Dev Server Coordination#

turbo dev run करने पर सभी dev servers simultaneously start होते हैं। तब तक fine है जब तक apps same port use करने की कोशिश न करें। Explicit port assignment mandatory है:

json
// 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"  // Uses port 4000 from config
}

दूसरा issue: shared packages के लिए hot module replacement। जब packages/ui में file edit करते हैं, change को running Next.js dev server तक propagate होना चाहिए। अगर "internal packages" approach use कर रहे हैं (कोई build step नहीं, direct TypeScript imports), तो यह automatically काम करता है — Next.js symlinked node_modules के through source files watch करता है।

अगर package में build step है, तो package के dev script को watch mode में run होना चाहिए:

json
// packages/ui/package.json
"scripts": {
  "dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch"
}

Turborepo सभी dev tasks parallel run करता है, तो package change पर rebuild होता है और consuming app नया output pick करती है।

4. Packages के बीच Version Drift#

यह subtle और dangerous है। अगर apps/web React 19 use करती है और apps/admin accidentally React 18, तो शायद notice न हो जब तक @acme/ui से कोई component हर app में differently behave न करे।

Solution: shared dependencies root level पर manage करें। pnpm के root package.json में overrides field हर जगह single version force करता है:

json
// Root package.json
{
  "pnpm": {
    "overrides": {
      "react": "^19.0.0",
      "react-dom": "^19.0.0",
      "typescript": "^5.7.3"
    }
  }
}

pnpm ls react --recursive regularly run करो verify करने के लिए सब कुछ same version use कर रहा है।

5. Shared Packages में Environment Variables#

Shared packages को process.env directly नहीं पढ़ना चाहिए। अगर @acme/utils process.env.API_URL पढ़ता है, तो consuming app के environment से invisible coupling बन जाती है। इसके बजाय, configuration explicitly pass करो:

typescript
// BAD: packages/utils/src/api.ts
const API_URL = process.env.API_URL;  // कौन सा env? कौन सी app?
 
export function fetchData(path: string) {
  return fetch(`${API_URL}${path}`);
}
 
// GOOD: packages/utils/src/api.ts
export function createApiClient(baseUrl: string) {
  return {
    fetch: (path: string) => fetch(`${baseUrl}${path}`),
  };
}

Consuming app configuration provide करती है:

typescript
// apps/web/src/lib/api.ts
import { createApiClient } from "@acme/utils";
 
export const api = createApiClient(process.env.NEXT_PUBLIC_API_URL!);

यह packages को pure और testable रखता है।

6. Ghost Dependencies#

pnpm default में dependencies के बारे में strict है — npm की तरह packages hoist नहीं करता। यह feature है, bug नहीं। मतलब अगर @acme/ui clsx import करता है लेकिन अपने package.json में list नहीं करता, pnpm error throw करेगा। npm चुपचाप parent node_modules से resolve कर लेता।

हर import consuming package के package.json में declare करो। Hoisting पर rely करना बंद करो।

7. IDE Performance Degradation#

15+ packages के साथ, TypeScript language server struggle कर सकता है। कुछ tips:

  • Root tsconfig.json में "exclude": ["node_modules", "**/dist/**"] add करो
  • VS Code की "Files: Exclude" से dist/, .next/, और .turbo/ folders hide करो
  • अगर Go to Definition node_modules में deep .d.ts files पर भेजता है तो tsconfig में "disableSourceOfProjectReferenceRedirect": true consider करो

Starter Structure: सब कुछ एक साथ#

सब एक साथ रखता हूं। दो Next.js apps और तीन shared packages के साथ Turborepo monorepo bootstrap करने के लिए हर file यहां है।

Root#

json
// 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"
    }
  }
}
yaml
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
json
// 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
    }
  }
}
json
// tsconfig.json (root — सिर्फ IDE के लिए, builds में use नहीं होता)
{
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "exclude": ["node_modules", "**/dist/**", "**/.next/**"]
}

apps/web#

json
// 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"
  }
}
typescript
// apps/web/next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@acme/ui", "@acme/utils"],
  reactStrictMode: true,
};
 
export default nextConfig;
json
// 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#

json
// 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#

json
// 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"
  }
}
typescript
// 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";
 
// Re-export types
export type { ButtonProps } from "./components/Button";
export type { InputProps } from "./components/Input";
export type { ModalProps } from "./components/Modal";
typescript
// 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#

json
// 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"
  }
}
typescript
// 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));
}
typescript
// 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 "just now";
  if (diffMinutes < 60) return `${diffMinutes}m ago`;
  if (diffHours < 24) return `${diffHours}h ago`;
  if (diffDays < 7) return `${diffDays}d ago`;
 
  return date.toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
  });
}
 
export function formatDate(date: Date, locale = "en-US"): string {
  return new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "long",
    day: "numeric",
  }).format(date);
}
typescript
// 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);
}
typescript
// packages/utils/src/index.ts
export { cn } from "./cn";
export { formatRelativeDate, formatDate } from "./date";
export { slugify, truncate, capitalize } from "./string";

packages/types#

json
// 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

Zero से शुरू करना#

अगर fresh start कर रहे हैं, exact sequence of commands यह है:

bash
# Create the repo
mkdir acme-monorepo && cd acme-monorepo
git init
 
# Initialize the root package
pnpm init
 
# Create the workspace file
echo 'packages:\n  - "apps/*"\n  - "packages/*"' > pnpm-workspace.yaml
 
# Install Turborepo
pnpm add -D turbo -w
 
# Create directories
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
 
# Initialize apps (from their directories)
cd apps/web && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../admin && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../..
 
# Add 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 everything
pnpm install
 
# Verify it works
pnpm turbo build
pnpm turbo dev

पहला pnpm turbo build slow होगा — सब कुछ scratch से build होता है। दूसरा nearly instant होना चाहिए अगर कुछ नहीं बदला। यही cache काम कर रहा है।

Basics से आगे Scaling#

Initial setup के बाद, monorepo बढ़ने पर कुछ patterns emerge होते हैं।

Package Generators#

दसवें package के बाद, copy और edit करके नया बनाना tedious हो जाता है। Script बनाओ:

typescript
// 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(`Created packages/${name}`);
console.log("Run: pnpm install");
bash
npx tsx scripts/create-package.ts email
# Creates packages/email with all the boilerplate

Workspace Dependency Graph Visualization#

Turborepo में built-in graph command है:

bash
pnpm turbo build --graph=graph.svg

यह dependency graph का SVG generate करता है। Major refactors से पहले run करता हूं change की blast radius समझने के लिए। अगर @acme/types touch करने से 12 packages rebuild होते हैं, शायद इसे @acme/types-user और @acme/types-billing में split करने का time है।

Docker के लिए Pruning#

Monorepo से single app deploy करते समय, पूरा repo Docker image में copy नहीं करना चाहते। Turborepo में prune command है:

bash
pnpm turbo prune --scope=apps/web --docker

यह out/ directory generate करता है जिसमें सिर्फ apps/web और उसकी dependencies के लिए ज़रूरी files हैं। Dockerfile इस pruned output use करता है:

dockerfile
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 में सिर्फ web app और उसकी dependencies हैं। कोई admin app नहीं, API server नहीं, unused packages नहीं। Monorepo बढ़ने पर भी image sizes reasonable रहते हैं।

एक महीने बाद: क्या अलग करता#

अगर जो अब जानता हूं वो जानते हुए शुरू करता:

  1. Internal packages (बिना build step) से शुरू करो। ऐसे packages जो सिर्फ मेरी Next.js apps consume करती थीं, उनके लिए tsup builds configure करने में दो हफ्ते बर्बाद किए। Next.js config में transpilePackages simpler और faster है। Build step तभी add करो जब ज़रूरत हो।

  2. शुरू में packages/ count कम रखो। मैंने early on बहुत aggressively split किया। packages/utils, packages/types, और packages/ui शुरू करने के लिए enough हैं। बाद में split कर सकते हो जब package unwieldy हो जाए। Prematurely split करने का मतलब ज़्यादा package.json files maintain करना और dependency graph में ज़्यादा edges।

  3. Day one पर remote caching set up करो। npx turbo login && npx turbo link 30 seconds लेता है। पहले हफ्ते में ही builds पर बचा time justify करता है।

  4. Workspace commands document करो। नए developers नहीं जानते कि pnpm --filter @acme/ui add lodash specific package में install करता है, या pnpm turbo build --filter=apps/web... सिर्फ ज़रूरी चीज़ build करता है। Contributing guide में simple "Monorepo Cheatsheet" section सबका time बचाता है।

  5. Day one से dependency direction enforce करो। अगर app से package में एक भी import allow कर दिया, boundary तेज़ी से erode होती है। Lint rule या CI check add करो। Direction है apps → packages → packages। कभी reverse नहीं।

Monorepo goal नहीं है। Goal features ship करना है बिना अपने codebase से लड़े। Turborepo सबसे lightest-weight tool है जो मैंने पाया इसे काम करने के लिए। Task graph करता है, caching करता है, और बाकी सब के लिए रास्ते से हट जाता है।

Simple शुरू करो। Complexity तब add करो जब repo demand करे। और अपने ports pin करो।

संबंधित पोस्ट