コンテンツへスキップ
·30分で読めます

Turborepoモノレポ:本当にスケールするセットアップ

パッケージ共有する複数アプリのTurborepoモノレポ構成方法。ワークスペース設定、パイプラインキャッシュ、共有TypeScript設定、そして最初の1ヶ月で犯した失敗。

シェア:X / TwitterLinkedIn

I spent the first three years of my career copying utility functions between repositories. Not exaggerating. I had a formatDate.ts that lived in seven different projects, each with slightly different bugs. When I fixed the timezone issue in one, I'd forget about the other six. Eventually a user in Australia would file a ticket and I'd discover the fix never made it to production in that particular app.

The monorepo solved this. Not because it's trendy, not because Google does it, but because I got tired of being a human package registry. One repository, shared code, atomic changes across every app that consumes it.

But monorepos have their own failure modes. I've tried three different tools, wasted weeks on broken caching, fought circular dependency errors at midnight, and learned that "just put everything in one repo" is about as useful as advice gets without the details.

This is the Turborepo setup I actually use. It runs four production apps with twelve shared packages. Builds take under 90 seconds because of remote caching. New developers can clone and run pnpm dev and have everything working in under two minutes. It took me about a month of mistakes to get here.

Why Monorepo in the First Place#

Let's be honest about the tradeoffs. A monorepo isn't free. You're trading one set of problems for another, and you need to know what you're buying.

What You Get#

Code sharing without publishing. This is the big one. You write a React component library in packages/ui. Your web app, your admin dashboard, and your docs site all import from it. When you change a button component, every app picks it up immediately. No version bumping, no npm publish, no "which version is production using again?"

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

These imports look like external packages. They resolve through workspace dependencies. But they point to source code in the same repository.

Atomic cross-package changes. Imagine you need to add an isLoading prop to your shared Button component. In a polyrepo world, you'd change the component library, publish a new version, update the version in every consuming app, then open PRs in each repo. That's three to five PRs for one prop.

In a monorepo, it's one 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)

One PR. One review. One merge. Everything stays in sync because it physically can't drift.

Unified tooling. One ESLint config. One Prettier config. One TypeScript base config. One CI pipeline. When you upgrade ESLint, you upgrade it everywhere in one afternoon, not over three sprints across eight repos.

What You Pay#

Complexity at the root. Your package.json at the root becomes infrastructure. Your CI pipeline needs to understand which packages changed. Your IDE might struggle with the sheer number of files. Git operations slow down as the repo grows (though this takes years for most teams).

CI time can balloon. If you build everything on every commit, you'll waste enormous amounts of compute. You need a tool that understands the dependency graph and only builds what changed. That's the entire reason Turborepo exists.

Onboarding friction. New developers need to understand workspaces, hoisting, internal packages, the build pipeline. It's not just "clone and run." Well, it should be eventually, but getting there takes deliberate effort.

The honest assessment: if you have one app with no shared code, a monorepo is overhead with no benefit. If you have two or more apps that share anything — components, utilities, types, configs — the monorepo pays for itself within the first month.

Turborepo vs Nx vs Lerna#

I've used all three. Here's the short version.

Lerna was the original monorepo tool for JavaScript. It manages versioning and publishing. But it doesn't understand build pipelines or caching. It was abandoned, revived by Nx, and now feels like a compatibility layer more than a standalone tool. If you need to publish packages to npm, Lerna + Nx can handle that. But for internal monorepos where you're just sharing code between your own apps, it's more ceremony than you need.

Nx is powerful. Really powerful. It has generators, plugins for every framework, a visual dependency graph, distributed task execution. It also has a learning curve that looks like a cliff face. I've seen teams spend two weeks just configuring Nx before writing any product code. If you're at a company with 50+ developers and hundreds of packages, Nx is probably the right choice. For my use cases, it's a bulldozer when I need a shovel.

Turborepo does three things well: it understands your dependency graph, it caches build outputs, and it runs tasks in parallel. That's it. The entire configuration is one turbo.json file. You can read the whole thing in two minutes. It doesn't generate code, it doesn't have plugins, it doesn't try to replace your build tool. It's a task runner that's very, very good at knowing what needs to run and what can be skipped.

I chose Turborepo because I could explain the entire setup to a new team member in 15 minutes. With Nx, that same conversation took an hour and they still had questions.

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#

Here's the actual directory tree. Not a "getting started" example — this is what a production monorepo looks like after six months.

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

The apps/ vs packages/ Split#

This is the fundamental organizing principle. apps/ contains deployable things — web apps, APIs, anything with a dev or start command. packages/ contains libraries — code that exists to be consumed by apps or other packages.

The rule is simple: apps consume packages. Packages never import from apps. Packages can import from other packages.

If a package starts importing from an app, you've got the dependency graph backwards and Turborepo will catch it as a cycle.

Workspace Configuration#

The root pnpm-workspace.yaml tells pnpm where to find packages:

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

That's the entire file. pnpm scans those directories, reads each package.json, and creates a workspace map. When apps/web declares "@acme/ui": "workspace:*" as a dependency, pnpm links it to the local packages/ui instead of looking in the npm registry.

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

Notice: the root package.json has no runtime dependencies. It's purely orchestration. turbo is the task runner, prettier handles formatting (because it's the one tool that doesn't need per-package configuration), and typescript is hoisted so every package uses the same version.

Naming Conventions#

Every package gets a scoped name: @acme/ui, @acme/utils, @acme/config-typescript. The scope prevents collisions with npm packages and makes it immediately obvious in any import statement whether you're using internal or external code.

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

I prefix config packages with config- to group them visually: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Some teams use @acme/tsconfig, @acme/eslint-config. Either works. Just be consistent.

turbo.json Pipeline Configuration#

This is where Turborepo earns its keep. The turbo.json file defines your task pipeline — what tasks exist, what they depend on, and what they 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
    }
  }
}

Let me break down every field because this is where most people get confused.

dependsOn and the ^ Notation#

"dependsOn": ["^build"] means: "Before running this task in package X, first run build in every package that X depends on."

The ^ symbol means "upstream dependencies." Without it, "dependsOn": ["build"] would mean "run build in the same package first" — useful if your test task needs a build to happen first within the same package.

Here's a concrete example. Your dependency graph looks like this:

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

When you run turbo build, Turborepo resolves the graph:

  1. Build @acme/types (no dependencies)
  2. Build @acme/utils (no dependencies)
  3. Build @acme/ui (depends on types and utils — waits for them)
  4. Build apps/web (depends on ui — waits for it)

Steps 1 and 2 run in parallel. Step 3 waits for both. Step 4 waits for 3. Turborepo figures this out from your package.json dependency declarations. You don't have to specify the order manually.

outputs#

This is critical for caching. When Turborepo caches a build, it stores the files listed in outputs. When it gets a cache hit, it restores those files. If you forget to list an output directory, the cache will "succeed" but your files won't appear. This was my biggest mistake in the first week — I missed .next/** and couldn't figure out why my Next.js app was always rebuilding.

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

The !.next/cache/** exclusion is important. Next.js has its own cache inside .next/cache/. You don't want to store a cache of a cache — it bloats your remote cache storage and doesn't help.

inputs#

By default, Turborepo hashes every file in a package to determine if it's changed. The inputs field narrows that. If only files in src/ affect the build output, then changing README.md shouldn't invalidate the cache.

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

Be careful with this. If you add a config file that affects your build (say, postcss.config.js) and don't include it in inputs, you'll get stale cached output. When in doubt, leave inputs unset and let Turborepo hash everything.

globalDependencies#

Files listed here invalidate the cache for every package when they change. Environment files are the classic example — if your .env.local changes, everything that might read from process.env needs to rebuild.

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

I also add tsconfig.json at the root level here because my base TypeScript config affects all packages:

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

persistent and dev#

The dev task has "cache": false and "persistent": true. Caching a dev server makes no sense — it's a long-running process. The persistent flag tells Turborepo that this task doesn't exit, so it shouldn't wait for it before running other tasks.

When you run turbo dev, Turborepo starts all dev servers in parallel — your Next.js app, your API server, your docs site — all in one terminal with interleaved output. Each line is prefixed with the package name so you can tell them apart.

Shared Packages in Detail#

packages/ui — The Component Library#

This is the package every team builds first. Shared React components used across all frontend apps.

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

A few things to notice:

"version": "0.0.0" — Internal packages don't need real versions. The workspace:* protocol tells pnpm to always use the local copy. Version numbers are irrelevant.

"private": true — This package will never be published to npm. If you ever want to publish it, remove this flag and set up a proper versioning strategy.

The exports field — This is the modern way to define package entry points. It replaces main, module, and types fields. The "." export is the default import path. You can add sub-path exports for granular imports:

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 or not? Some teams skip the build step entirely for internal packages. Instead of compiling with tsup, they point the exports directly at the TypeScript source and let the consuming app's bundler (Next.js, Vite) handle compilation. This is called "internal packages" in Turborepo's terminology and it's simpler:

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

No build step. No dist folder. The consuming app's next.config.ts needs transpilePackages:

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

I use the no-build approach for most internal packages. It's faster during development and one less thing to break. The exception is packages that need to support non-TypeScript consumers or have 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"
  }
}

The sub-path exports let consuming apps import only what they need:

typescript
// Only imports date utilities, not the entire package
import { formatRelativeDate, parseISO } from "@acme/utils/date";
 
// Imports everything (barrel export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";

The barrel export in src/index.ts re-exports everything:

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

A word of caution on barrel exports: they're convenient but they can kill tree-shaking in some bundlers. If apps/web imports one function from @acme/utils, a naive bundler might include everything. Next.js handles this well with its built-in tree-shaking, but if you notice bundle size issues, the sub-path exports pattern is 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"
  }
}

This package has zero runtime dependencies. It only contains TypeScript types and interfaces. It never needs to be built because types are erased at compile time.

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#

This one is deceptively important. TypeScript configuration in a monorepo is where things get hairy fast.

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

The base configuration that all 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"]
}

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

The Node.js config for backend packages:

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

Now each app or package extends the appropriate preset:

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

The @/* Path Alias#

Every app gets the same @/* path alias pointing to its own src/ directory. This means @/components/Header in apps/web resolves to apps/web/src/components/Header, and @/components/Header in apps/admin resolves to apps/admin/src/components/Header.

These are app-local paths. They don't cross package boundaries. For cross-package imports, you always use the package name: @acme/ui, not @/../../packages/ui. If you ever see a relative import that goes up past the package root (../../packages/something), that's a code smell. Use the workspace dependency instead.

composite and Project References#

TypeScript project references (composite: true) allow tsc to build packages incrementally and understand cross-project dependencies. This is optional with Turborepo — Turborepo handles the build orchestration itself — but it can speed up type checking in your IDE.

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

I'll be honest: project references add complexity and I've dropped them from most of my setups. Turborepo's --filter and dependsOn handle the build order already. I only add composite: true when IDE performance becomes a problem (usually at 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",
    },
  },
];

Each app references its config:

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

Remote Caching#

Local caching is nice — it prevents you from rebuilding unchanged packages during development. But remote caching is transformative. It means your CI server benefits from your local builds and vice versa.

How It Works#

When Turborepo runs a task, it computes a hash based on:

  • The source files (content hash)
  • The task's inputs configuration
  • Environment variables
  • The hashes of upstream dependencies

If the hash matches a previously cached result, Turborepo restores the outputs from cache instead of running the task. With remote caching, those cached outputs are stored in a shared location that any machine — your laptop, your colleague's laptop, CI — can access.

Vercel Remote Cache#

The simplest setup. Vercel maintains the cache infrastructure for free (with limits):

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

That's it. Turborepo will now push and pull cache artifacts from Vercel's servers. On CI, you set environment variables:

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

Generate the token from the Vercel dashboard under your team settings. The TURBO_TEAM is your team slug.

Self-Hosted Remote Cache#

If you can't use Vercel (air-gapped environment, data residency requirements), you can self-host. The Turborepo remote cache API is well-documented and there are several open-source implementations:

  • ducktors/turborepo-remote-cache — A Node.js server that stores artifacts in S3, GCS, or local filesystem
  • fox1t/turborepo-remote-cache — Similar, with 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"

Then configure your monorepo to use it:

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

Cache Hit Rates#

In my experience, a well-configured Turborepo achieves 80-95% cache hit rates in CI. That means only 5-20% of tasks actually run on any given CI pipeline. The rest are restored from cache in seconds.

The keys to high cache hit rates:

  1. Narrow your inputs. If only src/ affects the build, don't let a README change invalidate cache.
  2. Don't put volatile data in globalDependencies. A .env file that changes on every deploy kills your cache.
  3. Pin your dependencies. Lockfile changes invalidate everything downstream.
  4. Keep packages small and focused. A giant packages/utils with 200 files will have its cache invalidated frequently because any change to any utility busts the whole package.

CI/CD with Turborepo#

Here's the GitHub Actions workflow I use. It's been refined over dozens of iterations.

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

The --filter Flag#

The --filter flag is how you run tasks for specific packages or based on what changed. This is essential for efficient CI:

bash
# Build only the web app and its dependencies
pnpm turbo build --filter=apps/web...
 
# Build only packages that changed since main
pnpm turbo build --filter=...[main]
 
# Build only @acme/ui and everything that depends on it
pnpm turbo build --filter=...@acme/ui
 
# Build only packages in the packages/ directory
pnpm turbo build --filter="./packages/*"

The three-dot syntax is important:

  • --filter=@acme/ui... — The package and all its dependencies (things it needs)
  • --filter=...@acme/ui — The package and all its dependents (things that need it)
  • --filter=@acme/ui — Only the package itself

Affected Packages Detection#

For pull requests, you probably don't want to build everything. You want to build only what's affected by the changes:

yaml
# Only build/test what changed compared to main
- name: Build affected
  run: pnpm turbo build --filter="...[origin/main]"
 
- name: Test affected
  run: pnpm turbo test --filter="...[origin/main]"

The [origin/main] syntax tells Turborepo to compare the current commit against origin/main and only run tasks for packages that have changes. Combined with remote caching, most PR pipelines finish in under 2 minutes even for large monorepos.

Matrix Strategy for Deployments#

If each app deploys independently, use a matrix strategy:

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

Each matrix job builds one app and its dependencies. Thanks to remote caching, the shared packages are built once and cached — each subsequent matrix job restores them from cache.

よくある落とし穴#

I'm dedicating a whole section to this because I hit every single one of these in my first month. Learning from documentation is efficient. Learning from my mistakes is free.

1. Circular Dependencies#

The dependency graph must be a DAG (directed acyclic graph). If @acme/ui imports from @acme/utils and @acme/utils imports from @acme/ui, you have a cycle. Turborepo will complain and refuse to build.

The fix is almost always to extract the shared code into a third package:

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

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

Move the shared types or constants into @acme/types and have both packages depend on it.

Another common cause: you put a React hook in @acme/utils that imports a component from @acme/ui. Hooks that depend on UI components belong in @acme/ui, not @acme/utils. Utilities should be framework-agnostic whenever possible.

2. Missing outputs Invalidating Cache#

This is the most frustrating bug. Everything seems to work locally. CI builds pass. But every build takes the full time because nothing is cached.

Scenario: your package builds to build/ instead of dist/, but your turbo.json only lists dist/** in outputs:

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

Turborepo caches the task (because it ran successfully) but doesn't store any output files. Next time, it gets a cache "hit" and restores nothing. The consuming app can't find the build output and fails, or — worse — silently uses stale files.

Fix: audit every package's build output directory and make sure turbo.json covers them all:

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

Or use per-package overrides in turbo.json:

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#

Running turbo dev starts all dev servers simultaneously. This is fine until your apps try to use the same port. Explicit port assignment is 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
}

Another issue: hot module replacement for shared packages. When you edit a file in packages/ui, the change needs to propagate to the running Next.js dev server. If you're using the "internal packages" approach (no build step, direct TypeScript imports), this works automatically — Next.js watches the source files through the symlinked node_modules.

If your package has a build step, you need the package's dev script to run in watch mode:

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

Turborepo runs all dev tasks in parallel, so the package rebuilds on change and the consuming app picks up the new output.

4. Version Drift Between Packages#

This is subtle and dangerous. If apps/web uses React 19 and apps/admin accidentally uses React 18, you might not notice until a component from @acme/ui behaves differently in each app.

Solution: manage shared dependencies at the root level. pnpm's overrides field in the root package.json forces a single version everywhere:

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

Run pnpm ls react --recursive regularly to verify everything uses the same version.

5. Environment Variables in Shared Packages#

Shared packages should not read process.env directly. If @acme/utils reads process.env.API_URL, it creates an invisible coupling to the consuming app's environment. Instead, pass configuration explicitly:

typescript
// BAD: packages/utils/src/api.ts
const API_URL = process.env.API_URL;  // What env? Which 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}`),
  };
}

The consuming app provides the configuration:

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

This keeps packages pure and testable.

6. Ghost Dependencies#

pnpm is strict about dependencies by default — it doesn't hoist packages the way npm does. This is a feature, not a bug. It means if @acme/ui imports clsx but doesn't list it in its package.json, pnpm will throw an error. npm would silently resolve it from a parent node_modules.

Always declare every import in the consuming package's package.json. No more relying on hoisting.

7. IDE Performance Degradation#

With 15+ packages, your TypeScript language server might start struggling. Some tips:

  • Add "exclude": ["node_modules", "**/dist/**"] to your root tsconfig.json
  • Use VS Code's "Files: Exclude" to hide dist/, .next/, and .turbo/ folders
  • Consider "disableSourceOfProjectReferenceRedirect": true in tsconfig if Go to Definition sends you to .d.ts files deep in node_modules

The Starter Structure: Everything Together#

Let me put it all together. Here's every file you need to bootstrap a Turborepo monorepo with two Next.js apps and three shared packages.

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 — for IDE only, not used by builds)
{
  "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

Getting Started From Zero#

If you're starting fresh, here's the 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

The first pnpm turbo build will be slow — everything builds from scratch. The second one should be nearly instant if nothing changed. That's the cache working.

Scaling Beyond the Basics#

Once you're past the initial setup, a few patterns emerge as the monorepo grows.

Package Generators#

After your tenth package, creating a new one by copying and editing gets tedious. Create a 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 has a built-in graph command:

bash
pnpm turbo build --graph=graph.svg

This generates an SVG of your dependency graph. I run this before major refactors to understand the blast radius of a change. If touching @acme/types rebuilds 12 packages, maybe it's time to split it into @acme/types-user and @acme/types-billing.

Pruning for Docker#

When deploying a single app from the monorepo, you don't want to copy the entire repo into your Docker image. Turborepo has a prune command:

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

This generates a out/ directory containing only the files needed for apps/web and its dependencies. Your Dockerfile uses this pruned output:

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

The Docker image only contains the web app and its dependencies. No admin app, no API server, no unused packages. Image sizes stay reasonable even as the monorepo grows.

One Month In: What I'd Do Differently#

If I were starting over, knowing what I know now:

  1. Start with internal packages (no build step). I wasted two weeks configuring tsup builds for packages that only my Next.js apps consumed. transpilePackages in Next.js config is simpler and faster. Only add a build step when you need one.

  2. Keep packages/ count low initially. I split too aggressively early on. packages/utils, packages/types, and packages/ui are enough to start. You can always split later when a package grows unwieldy. Splitting prematurely means more package.json files to maintain and more edges in your dependency graph.

  3. Set up remote caching on day one. npx turbo login && npx turbo link takes 30 seconds. The time saved on builds in the first week alone justifies it.

  4. Document the workspace commands. New developers don't know that pnpm --filter @acme/ui add lodash installs to a specific package, or that pnpm turbo build --filter=apps/web... builds only what's needed. A simple "Monorepo Cheatsheet" section in your contributing guide saves everyone time.

  5. Enforce the dependency direction from day one. If you allow even one import from an app into a package, the boundary erodes fast. Add a lint rule or a CI check. The direction is apps → packages → packages. Never the reverse.

The monorepo isn't the goal. The goal is shipping features without fighting your own codebase. Turborepo is the lightest-weight tool I've found for making that work. It does the task graph, it does the caching, and it stays out of your way for everything else.

Start simple. Add complexity when the repo demands it. And pin your ports.

関連記事