saajunaid

monorepo

Monorepo management with Turborepo and pnpm workspaces. Use for Turborepo setup, turbo.json task dependencies, remote caching, pnpm workspace protocol, shared packages (ui-library, config, types), affected-only CI/CD builds, or monorepo structure (apps/ vs packages/). Covers pitfalls like circular deps, version drift, and hoisting issues.

saajunaid 0 Updated 5mo ago
GitHub

Install

npx skillscat add saajunaid/caddis-plugin/monorepo

Install via the SkillsCat registry.

SKILL.md

Monorepo Skill

Turborepo + pnpm workspaces for managing multi-app, multi-package repositories efficiently.

1. Repository Structure

monorepo/
├── apps/
│   ├── web/               # Next.js app
│   ├── api/               # FastAPI or Express backend
│   └── docs/              # Docusaurus or similar
├── packages/
│   ├── ui/                # Shared React component library
│   ├── config/            # Shared ESLint, TypeScript, Tailwind configs
│   ├── types/             # Shared TypeScript types / Zod schemas
│   └── utils/             # Shared utility functions
├── turbo.json
├── pnpm-workspace.yaml
└── package.json           # Root — scripts only, no app deps

2. pnpm Workspaces

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
// Root package.json
{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "test": "turbo test",
    "lint": "turbo lint",
    "typecheck": "turbo typecheck",
    "clean": "turbo clean && rm -rf node_modules"
  },
  "devDependencies": {
    "turbo": "^2.0.0"
  },
  "engines": { "node": ">=20", "pnpm": ">=9" }
}

workspace: Protocol — Cross-Package Dependencies

// apps/web/package.json
{
  "dependencies": {
    "@myapp/ui": "workspace:*",      // Always latest local version
    "@myapp/types": "workspace:^",   // Semver compatible local version
    "@myapp/utils": "workspace:*"
  }
}

Rules:

  • workspace:* — consume local package always (recommended for internal packages)
  • workspace:^ — enforces semver compatibility (use for packages that may be published)
  • Never use relative paths ("../packages/ui") — use workspace protocol

3. Turborepo Configuration

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],         // Build dependencies first (topological)
      "inputs": ["src/**", "package.json"],
      "outputs": [".next/**", "dist/**", "!.next/cache/**"]
    },
    "dev": {
      "persistent": true,              // Keep running (dev servers)
      "cache": false,                  // Never cache watch mode
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "test/**", "vitest.config.ts"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "inputs": ["src/**", ".eslintrc*", "eslint.config.*"]
    },
    "typecheck": {
      "dependsOn": ["^build"],         // Types may be generated by build
      "inputs": ["src/**", "tsconfig*.json"]
    },
    "clean": {
      "cache": false
    }
  }
}

Task Dependency Conventions

  • "dependsOn": ["^build"] — wait for all dependencies' build tasks to finish first
  • "dependsOn": ["build"] — wait for the same package's build task first
  • "cache": false — never cache this task (use for watch/serve tasks)
  • "persistent": true — marks tasks that run indefinitely (dev servers), prevents pipeline hanging

4. Remote Caching

# Login to Vercel Remote Cache (free)
npx turbo login
npx turbo link   # Links to your team's remote cache

# CI: set environment variable
TURBO_TOKEN=<token>
TURBO_TEAM=<team-slug>
# GitHub Actions with remote cache
- name: Build
  run: turbo build
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Cache hit = unchanged output = skip the task entirely. A 100% cache hit monorepo build in CI can drop from 10 minutes to seconds.

5. Shared Packages

packages/config — Shared TypeScript/ESLint/Tailwind Config

// packages/config/package.json
{
  "name": "@myapp/config",
  "version": "0.0.0",
  "private": true,
  "exports": {
    "./typescript": "./typescript/base.json",
    "./eslint/base": "./eslint/base.js",
    "./tailwind": "./tailwind/index.ts"
  }
}
// apps/web/tsconfig.json
{
  "extends": "@myapp/config/typescript",
  "compilerOptions": { "outDir": "dist" },
  "include": ["src", "next-env.d.ts"]
}

packages/ui — Shared Component Library

// packages/ui/package.json
{
  "name": "@myapp/ui",
  "exports": {
    ".": {
      "import": "./src/index.ts",   // For bundlers (Vite/Next.js/Turbopack)
      "types": "./src/index.ts"
    }
  },
  "peerDependencies": {
    "react": ">=18",
    "react-dom": ">=18"
  }
}

Pattern: Export from src/index.ts directly — let the consuming app's bundler handle transpilation. Avoids a double-build for internal packages.

packages/types — Shared Zod Schemas and Types

// packages/types/src/index.ts
export * from './user'
export * from './product'
export * from './common'

Both apps and API share these schemas — single source of truth for request/response contracts.

6. CI/CD — Affected-Only Builds

# .github/workflows/ci.yml
name: CI
context: fork

on: [push, pull_request]

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 2 }  # Need previous commit for --filter

      - uses: pnpm/action-setup@v4
        with: { version: 9 }

      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'pnpm' }

      - run: pnpm install --frozen-lockfile

      - name: Build and Test (affected only)
        run: turbo run build test lint --filter="...[HEAD^1]"
        # --filter="...[HEAD^1]" = only packages changed since last commit
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Useful Filter Patterns

# Only changed packages and their dependents
turbo build --filter="...[HEAD^1]"

# Only a specific app and its deps
turbo build --filter="web..."

# All packages except one
turbo build --filter="!docs"

# Specific package
turbo test --filter="@myapp/ui"

7. Pitfalls

Pitfall Symptom Fix
Circular dependencies Build never completes (cycle in task graph) madge --circular --extensions ts src/ to detect; extract shared code
Version drift @myapp/config at v1 in one app, v2 in another workspace:* for all internal packages
Hoisting issues Package works locally, fails in CI Use pnpm.overrides or .npmrc public-hoist-pattern
Missing ^build in dev Dev server crashes because shared types not compiled Add "dependsOn": ["^build"] to dev task
Cache poisoning Stale build output served Add all relevant source files to inputs
persistent: true missing on dev turbo dev exits immediately Always mark watch-mode tasks as persistent: true
Cross-package imports via ../../ Breaks when packages move Always use workspace:* — never relative paths

8. Checklist

  • pnpm-workspace.yaml covers apps/* and packages/*
  • All internal packages use workspace:* in dependencies
  • turbo.json has ^build in dependsOn for transitive tasks
  • dev task has persistent: true and cache: false
  • Remote cache configured for CI (Vercel or self-hosted)
  • --filter="...[HEAD^1]" used in CI for affected-only builds
  • Shared TypeScript config extends from @myapp/config/typescript
  • packages/types holds all cross-boundary Zod schemas
  • No circular imports between packages (run madge check)
  • No relative path cross-package imports (../../packages/)