Skip to main content
Ganesh Joshi
Back to Blogs

Monorepos with Turborepo: basics and workflows

February 15, 20266 min read
Tutorials
Project structure or terminal on screen

A monorepo holds multiple packages or applications in a single repository. Instead of maintaining separate repos for your web app, component library, and utility functions, you keep them together with shared configuration and tooling. Turborepo makes monorepos practical by caching task outputs and running only what changed.

Why use a monorepo?

When you have related packages, a monorepo offers several advantages:

Benefit Description
Code sharing Import shared utilities and components directly without publishing to npm
Atomic commits Change multiple packages in one commit with guaranteed consistency
Unified tooling One ESLint config, one TypeScript config, one test runner
Simpler refactoring Rename exports across packages in a single PR
Coordinated releases Version and release packages together when needed

The downside is complexity. Without proper tooling, a monorepo can become slow as npm install and npm run build run against every package. Turborepo solves this with intelligent caching and task orchestration.

Project structure

A typical Turborepo monorepo looks like this:

my-monorepo/
├── apps/
│   ├── web/           # Next.js web app
│   │   ├── package.json
│   │   └── src/
│   └── docs/          # Documentation site
│       ├── package.json
│       └── src/
├── packages/
│   ├── ui/            # Shared component library
│   │   ├── package.json
│   │   └── src/
│   ├── utils/         # Shared utilities
│   │   ├── package.json
│   │   └── src/
│   └── config/        # Shared configs (ESLint, TypeScript)
│       └── package.json
├── package.json       # Root package.json with workspaces
├── turbo.json         # Turborepo pipeline config
└── pnpm-workspace.yaml # If using pnpm

Each package has its own package.json with dependencies and scripts. The root package.json defines workspaces so the package manager can link local packages.

Setting up workspaces

For pnpm, create pnpm-workspace.yaml:

packages:
  - 'apps/*'
  - 'packages/*'

For npm or yarn, add to root package.json:

{
  "workspaces": ["apps/*", "packages/*"]
}

Install dependencies from the root:

pnpm install

The package manager hoists shared dependencies and links local packages so @my-org/ui resolves to packages/ui.

Configuring Turborepo

Create turbo.json at the root:

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

Key concepts:

Concept Meaning
dependsOn: ["^build"] Run this package's build after all its dependencies' builds complete
outputs Files to cache; if unchanged, skip the task
cache: false Never cache this task (useful for dev servers)
persistent: true Task runs indefinitely (dev servers)

Running tasks

From the root, run tasks across all packages:

# Build all packages
turbo run build

# Lint all packages
turbo run lint

# Run tests
turbo run test

Turborepo figures out the dependency graph and runs tasks in parallel where possible. If packages/ui depends on packages/utils, Turborepo builds utils first.

Filtering by package

Run tasks for specific packages:

# Build only the web app and its dependencies
turbo run build --filter=web

# Build only packages that changed since main
turbo run build --filter=[main]

# Build packages matching a pattern
turbo run build --filter="./packages/*"

Filtering is useful in CI to run only affected tests after a change.

Caching in action

When you run turbo run build for the first time, Turborepo:

  1. Hashes all inputs (source files, dependencies, env vars)
  2. Runs the build script
  3. Stores outputs in .turbo/cache

On subsequent runs, if the hash matches, Turborepo skips the build and restores cached outputs. This turns a 30-second build into instant replay.

# First run: 30s
turbo run build

# Second run (no changes): ~0.1s
turbo run build
# Output: ui:build: cache hit, replaying logs

Remote caching

Local caching only helps on one machine. Remote caching shares the cache across CI and team members.

For Vercel Remote Cache:

turbo login
turbo link

This connects your repo to Vercel's cache. When CI runs a build, it checks the remote cache first. If another CI run or team member already built that hash, CI downloads the cached output instead of rebuilding.

For self-hosted caching, run a Turborepo Remote Cache server or use S3/GCS with a custom adapter.

Environment variables

Turborepo can include environment variables in the hash:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "env": ["NODE_ENV", "API_URL"]
    }
  }
}

If API_URL changes, the cache invalidates and the build reruns. This ensures different environments don't share incompatible caches.

Shared configurations

Keep ESLint, TypeScript, and Prettier configs in a shared package:

packages/config/
├── package.json
├── eslint.js
├── tsconfig.base.json
└── prettier.json

Reference them from other packages:

// apps/web/package.json
{
  "devDependencies": {
    "@my-org/config": "workspace:*"
  }
}
// apps/web/.eslintrc.js
module.exports = require('@my-org/config/eslint');

Development workflow

Run all dev servers in parallel:

turbo run dev

Each app starts its dev server. Since cache: false and persistent: true, Turborepo keeps them running without caching.

For a single app:

turbo run dev --filter=web

Adding a new package

  1. Create the folder: packages/new-lib/
  2. Add package.json with name @my-org/new-lib
  3. Run pnpm install to link it
  4. Import from other packages: import { foo } from '@my-org/new-lib'

No publishing needed. The monorepo links packages automatically.

CI configuration

In GitHub Actions:

name: CI
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install
      - run: pnpm turbo run build lint test --cache-dir=.turbo
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

With remote caching enabled, CI reuses builds from previous runs.

When not to use a monorepo

Monorepos add complexity. Avoid them when:

  • You have one small project with no shared code
  • Teams need strict access control between repos
  • Projects have completely different tech stacks
  • You are not ready to invest in tooling

For a single Next.js app with no shared packages, a simple single-package setup is easier.

Turborepo vs Nx

Both are monorepo tools. Turborepo focuses on simplicity and speed with minimal configuration. Nx offers more features: generators, plugins, and a computation cache for any task. Choose Turborepo for simpler setups; consider Nx for larger organizations with complex needs.

Summary

Turborepo makes monorepos practical by caching outputs and running tasks intelligently. Define your pipeline in turbo.json, run tasks from the root, and let Turborepo handle dependencies and caching. Add remote caching to share builds across CI and team members. Start simple with a few packages and scale as needed.

Frequently Asked Questions

A monorepo is a single repository that contains multiple projects or packages. It allows shared code, consistent tooling, and atomic commits across related projects like a web app, mobile app, and shared utilities.

Turborepo is a build system for JavaScript and TypeScript monorepos. It caches task outputs, runs tasks in parallel, and only rebuilds what changed. This speeds up builds, tests, and linting across multiple packages.

Turborepo hashes inputs (source files, dependencies, environment variables) and stores outputs in a cache. If inputs haven't changed, it skips the task and restores cached outputs. Remote caching shares this cache across CI and team members.

Turborepo works with all three. pnpm is often preferred for monorepos because it uses hard links and saves disk space. npm and yarn workspaces also work well. Choose based on your team's familiarity.

Use a monorepo when packages share code, need coordinated releases, or benefit from consistent tooling. Use separate repos when projects are truly independent, have different teams, or need different access controls.

Related Posts