Monorepos With Turborepo: A Practical Setup Guide


Monorepos are having a moment, and for good reason. Managing multiple related packages in a single repository simplifies dependency management, enables code sharing, and makes atomic changes across projects possible. Turborepo has emerged as the most practical tool for JavaScript and TypeScript monorepos. Here’s how to set one up properly.

Why Turborepo?

The JavaScript monorepo tool landscape includes Nx, Lerna, Rush, and Turborepo. I recommend Turborepo for most teams because it does one thing well: it makes your existing npm/yarn/pnpm workspace faster through intelligent caching and parallel execution.

Turborepo doesn’t replace your package manager or impose a specific project structure. It’s an orchestration layer that understands the dependency graph between your workspace packages and runs tasks in the optimal order, caching results to avoid redundant work.

Project Structure

A typical Turborepo monorepo looks like this:

my-monorepo/
  apps/
    web/           # Next.js frontend
    api/           # Express API
    docs/          # Documentation site
  packages/
    ui/            # Shared React components
    config/        # Shared ESLint and TypeScript configs
    utils/         # Shared utility functions
  turbo.json
  package.json

The root package.json defines the workspaces:

{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "devDependencies": {
    "turbo": "^2.0.0"
  }
}

Configuring Turborepo

The turbo.json file defines your task pipeline:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {},
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

The dependsOn: ["^build"] syntax means: before building a package, build all of its workspace dependencies first. This ensures that if your web app imports from ui, the ui package is built before web.

The outputs array tells Turborepo which directories contain build artefacts. These get cached so that unchanged packages don’t rebuild.

Shared Packages

The real value of a monorepo is code sharing. Here’s how I structure a shared UI package:

// packages/ui/package.json
{
  "name": "@myorg/ui",
  "version": "0.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsup src/index.ts --dts",
    "lint": "eslint src/"
  },
  "dependencies": {
    "react": "^18.0.0"
  }
}

Apps consume shared packages as regular dependencies:

// apps/web/package.json
{
  "dependencies": {
    "@myorg/ui": "workspace:*",
    "@myorg/utils": "workspace:*"
  }
}

The workspace:* protocol tells the package manager to resolve the dependency from the local workspace rather than the npm registry.

Caching in Action

Turborepo’s caching is its killer feature. When you run turbo build, it hashes the inputs (source files, dependencies, environment variables) for each package. If the hash matches a previous run, it replays the cached output instead of rebuilding.

On the first run:

$ turbo build
 Tasks:    5 successful, 5 total
 Cached:   0 cached, 5 total
 Time:     34.2s

On the second run without changes:

$ turbo build
 Tasks:    5 successful, 5 total
 Cached:   5 cached, 5 total
 Time:     0.3s

For CI, you can enable remote caching so that builds cached by one developer (or CI run) are available to everyone. Turborepo supports Vercel’s remote cache out of the box, and you can self-host one if Vercel isn’t in your stack.

Development Workflow

Running turbo dev starts all development servers in parallel:

turbo dev --filter=web --filter=api

The --filter flag lets you run tasks for specific packages. During development, you typically only need the app you’re working on and its dependencies.

For running commands in specific packages:

turbo test --filter=@myorg/utils
turbo lint --filter=apps/*

CI Optimisation

In CI, Turborepo’s caching dramatically reduces build times. Combined with remote caching, only packages with actual changes get rebuilt.

A typical GitHub Actions setup:

- uses: actions/checkout@v4
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'
- run: npm ci
- run: npx turbo build test lint
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

With remote caching enabled, a CI run that would take 5 minutes can complete in 30 seconds if only one package changed.

Common Pitfalls

Circular dependencies. If package A depends on package B and package B depends on package A, Turborepo can’t determine the build order. Design your packages as a directed acyclic graph. Shared utilities should be leaf nodes that don’t depend on other workspace packages.

Version drift in shared dependencies. When multiple packages depend on React, make sure they all use the same version. Use your package manager’s deduplication features and consider hoisting shared dependencies to the root package.json.

Over-splitting packages. Not everything needs to be a separate package. If a “shared” package is only used by one app, it’s not shared — it’s just indirection. Start with code in apps and extract to packages only when you have a genuine need to share.

Monorepos aren’t right for every team, but for organisations managing multiple related JavaScript projects, Turborepo provides meaningful productivity gains with minimal configuration overhead. Start small, extract shared code gradually, and let the caching pay for itself.