How to Set Up a Monorepo with Turborepo in 2026
TL;DR
Turborepo + pnpm = the 2026 monorepo standard. create-turbo scaffolds the whole thing in 30 seconds. This guide walks through a real setup: Next.js web app + Hono API + shared packages (UI, database, utils, config). Remote caching makes CI runs 80%+ faster after the first run.
Key Takeaways
npx create-turbo@latest— scaffold in 30 seconds- pnpm workspaces — package manager layer (packages declare each other as dependencies)
- turbo.json — defines task dependencies and what to cache
- Remote cache — CI runs hit cache after the first build, 80%+ time savings
workspace:*protocol — pnpm's way to reference internal packages
Why Turborepo in 2026
Turborepo has become the default monorepo build tool for TypeScript applications for three reasons: minimal configuration, excellent Vercel integration (the Vercel acquisition brought first-party Next.js support), and a caching model that works correctly for the common case without requiring significant tuning.
A typical team encounters monorepo pain points before they encounter monorepo tooling. They start with multiple repositories, experience friction when shared code needs updating across all of them, and eventually consolidate into a monorepo. By the time they add Turborepo, the directory structure already exists — Turborepo adds the caching layer on top without requiring restructuring.
The concrete benefit of build caching is meaningful. In a monorepo with 10 packages, CI without caching rebuilds all 10 packages on every PR. With Turborepo's remote cache enabled, a PR that touches one package rebuilds only that package and its dependents — the rest serve cached artifacts from previous runs. Teams report 60-80% CI time reductions after enabling remote caching.
Scaffold
npx create-turbo@latest my-monorepo
# Prompts: package manager (choose pnpm), app type
cd my-monorepo
pnpm install
The scaffold creates a working monorepo immediately. Start there rather than building from scratch.
Project Structure
my-monorepo/
├── apps/
│ ├── web/ # Next.js 15 frontend
│ └── api/ # Hono API server
├── packages/
│ ├── ui/ # Shared React components
│ ├── database/ # Drizzle schema + db client
│ ├── utils/ # Shared TypeScript utilities
│ └── config/ # Shared tsconfig, biome config
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
The apps/ vs packages/ split is a convention, not a requirement. The distinction: apps/ contains deployable applications that consumers use directly, packages/ contains reusable libraries that apps/ consume. Nothing prevents an apps/ package from depending on another apps/ package, but keeping apps/ deployable and packages/ reusable is a useful mental model.
The packages/config package is worth special mention. Sharing TypeScript configuration, ESLint rules, and Biome config through a workspace package ensures consistent tooling behavior across all packages. Without it, configuration drift between packages is a common maintenance burden.
pnpm-workspace.yaml
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**", ".svelte-kit/**"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": [],
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
"cache": true
},
"type-check": {
"dependsOn": ["^build"],
"outputs": [],
"cache": true
},
"clean": {
"cache": false
}
},
"remoteCache": {
"enabled": true
}
}
Understanding dependsOn is essential to getting Turborepo right. "dependsOn": ["^build"] means "before running build for this package, run build for all of its workspace dependencies first." The ^ prefix is workspace-scoped. "dependsOn": ["build"] (without ^) means "run this package's own build task first" — useful for running type-check after build for the same package.
The outputs array tells Turborepo what to cache. Getting this right is the most common configuration mistake. If you list .next/** but your Next.js app also outputs to .next/cache/**, only the declared patterns are cached. Missing an output directory means that cached artifacts won't restore correctly, and you'll see mysterious "rebuilt from cache" builds that seem to work but actually re-executed.
persistent: true for the dev task tells Turborepo that this task runs indefinitely (it's a long-running dev server). Without this flag, Turborepo might wait for dev to complete before starting dependent tasks, which never happens.
packages/config — Shared Configs
// packages/config/package.json
{
"name": "@myapp/config",
"version": "0.0.0",
"private": true,
"files": ["biome.json", "tsconfig.base.json", "tsconfig.nextjs.json"]
}
// packages/config/tsconfig.base.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"skipLibCheck": true,
"resolveJsonModule": true
}
}
// packages/config/tsconfig.nextjs.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"jsx": "preserve",
"lib": ["dom", "dom.iterable", "esnext"]
}
}
packages/ui — Shared Components
// packages/ui/package.json
{
"name": "@myapp/ui",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"devDependencies": {
"react": "^18.2.0",
"typescript": "^5.3.0",
"@myapp/config": "workspace:*"
},
"peerDependencies": {
"react": "^18.2.0"
}
}
// packages/ui/src/index.ts
export { Button } from './button';
export { Card } from './card';
export type { ButtonProps } from './button';
// packages/ui/src/button.tsx
interface ButtonProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'ghost';
onClick?: () => void;
}
export function Button({ children, variant = 'primary', onClick }: ButtonProps) {
return (
<button
onClick={onClick}
className={`btn btn-${variant}`}
>
{children}
</button>
);
}
The "main": "./src/index.ts" approach (pointing directly to TypeScript source) works within the monorepo because consuming apps use TypeScript and compile everything at build time. For publishable packages, you'd compile to JavaScript first and point main to the compiled output. For internal packages that aren't published, the TypeScript source approach is simpler and provides better IDE experience.
packages/database — Shared DB Client
// packages/database/package.json
{
"name": "@myapp/database",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"dependencies": {
"drizzle-orm": "^0.30.0",
"@neondatabase/serverless": "^0.9.0"
}
}
// packages/database/src/index.ts
export { db } from './client';
export * from './schema';
// packages/database/src/client.ts
import { drizzle } from 'drizzle-orm/neon-serverless';
import { neon } from '@neondatabase/serverless';
import * as schema from './schema';
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
apps/web — Next.js App
// apps/web/package.json
{
"name": "@myapp/web",
"private": true,
"dependencies": {
"@myapp/ui": "workspace:*",
"@myapp/database": "workspace:*",
"@myapp/utils": "workspace:*",
"next": "15.0.0",
"react": "18.3.0",
"react-dom": "18.3.0"
}
}
// apps/web/tsconfig.json
{
"extends": "@myapp/config/tsconfig.nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}
Root package.json Scripts
// package.json (root)
{
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"test": "turbo test",
"lint": "turbo lint",
"type-check": "turbo type-check",
"clean": "turbo clean && rm -rf node_modules"
}
}
Remote Cache Setup
# Connect to Vercel Remote Cache (free)
npx turbo login
npx turbo link # Links to your Vercel team
# CI: set TURBO_TOKEN env var
# GitHub Actions example:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
# CI result after first run:
# PR #1: full build → 4 minutes (populates cache)
# PR #2: cache hit on unchanged packages → 45 seconds
Remote caching requires a Vercel account but is free on the hobby plan. The TURBO_TOKEN is a personal access token from your Vercel account settings. The TURBO_TEAM is your Vercel team slug. With both set in GitHub Actions secrets and vars, every CI run automatically hits the remote cache.
For teams that can't use Vercel remote cache (self-hosted, enterprise requirements), @turborepo/remote-cache provides an open-source alternative that runs on S3, R2, or Minio.
Common Pitfalls
Phantom dependencies in strict mode: pnpm's strict mode prevents importing packages not in your direct dependencies. After adding a package, you may discover other packages depended on it transitively. Fix by adding it explicitly to each package that needs it.
TypeScript project references: Large monorepos benefit from TypeScript project references, which enable incremental compilation across packages. Setting them up adds configuration but significantly improves IDE performance and type-check speed.
Missing outputs in turbo.json: If you add a new build artifact directory, add it to the outputs array. Otherwise, Turborepo won't cache it, and the cached "build" won't include the necessary files.
Environment variables in CI: Turborepo caches are keyed partly by environment variables declared in globalEnv or env. If your build behaves differently based on NODE_ENV but you haven't declared it in globalEnv, you may get stale cache hits with incorrect builds.
Shared Package Architecture
The most valuable part of a monorepo setup isn't build caching — it's sharing code across packages without publishing to npm. Getting the shared package structure right from the start saves significant refactoring later.
Internal Package Conventions
Shared packages in a Turborepo monorepo live in packages/ by convention. Each package has its own package.json with a name (typically scoped: @acme/ui, @acme/utils, @acme/config), TypeScript config, and build configuration. Apps in apps/ reference internal packages using the workspace:* protocol in their package.json dependencies:
{
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*"
}
}
pnpm resolves workspace:* to the local package path at install time, creating a symlink. TypeScript resolves the package through the paths configuration in tsconfig.json, pointing to the source files directly for optimal IDE performance.
Two Approaches to Internal Package Builds
The first approach: build shared packages to JavaScript (dist/) before consuming apps use them. This is explicit and reliable — TypeScript type declarations are pre-generated, and apps import compiled output. The downside is the added build step: any change to a shared package requires rebuilding it before the consuming app reflects the change, even during development.
The second approach: configure consuming apps to compile shared package TypeScript directly. TypeScript's project references enable this — apps list their shared package dependencies in tsconfig.json references, and the compiler handles the cross-package compilation. This gives instant feedback during development (no rebuild step) but requires more TypeScript configuration and can slow down type-checking in very large repos.
Turborepo supports both approaches. For small-to-medium monorepos (under 20 packages), the direct compilation approach gives a better development experience. For larger repos, pre-built packages with Turborepo caching produce faster CI runs.
Config Packages and Shared Tooling
A pattern that prevents drift between packages is extracting shared configuration into dedicated packages: @acme/eslint-config, @acme/tsconfig, @acme/prettier-config. These packages contain only configuration files — no source code — and are referenced by other packages:
// packages/web/tsconfig.json
{
"extends": "@acme/tsconfig/nextjs.json",
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
When your ESLint config needs updating, you update it in one place and all packages inherit the change after running turbo lint. Without this pattern, you'd update N separate config files and risk inconsistency.
Remote Caching Setup for Teams
Remote caching is what makes Turborepo worth adopting for teams rather than individual developers. The setup is straightforward for Vercel-hosted projects and workable for other infrastructure.
Vercel Remote Cache
For projects deploying to Vercel, remote caching enables in three steps:
# 1. Install Turborepo globally (or use npx)
npm install -g turbo
# 2. Log in with Vercel
npx turbo login
# 3. Link your repo to a Vercel team
npx turbo link
After linking, CI runs that set TURBO_TEAM and TURBO_TOKEN environment variables share a cache with local development. A build that ran on a colleague's machine doesn't re-run in CI if the inputs are identical.
Self-Hosted Remote Cache
For teams not using Vercel, turborepo-remote-cache is an open-source Turborepo-compatible cache server. Deploy it to any Node.js-capable platform (Railway, Fly, a small EC2 instance) and configure the cache endpoint:
# turbo.json
{
"remoteCache": {
"enabled": true
}
}
# Environment variables
TURBO_API="https://your-cache-server.example.com"
TURBO_TOKEN="your-secret-token"
TURBO_TEAM="your-team-slug"
The server stores artifacts in S3 or local disk. For a team of 5-10 engineers, a small Railway deployment with S3 artifact storage costs under $10/month and provides substantial CI time savings.
Measuring Cache Effectiveness
Turborepo outputs cache statistics at the end of each run. In CI, watch for the cache hit rate over time. A well-configured monorepo should see 60-80% cache hits after the first few weeks — full rebuilds happen only when dependencies change or environment variables are updated. If your hit rate is below 40%, the most common culprits are undeclared environment variables, broad inputs patterns matching too many files, or missing outputs declarations.
Incremental Adoption: Adding Turborepo to an Existing Repo
Not all monorepo adoption starts from scratch. Adding Turborepo to an existing multi-package repository is often smoother than switching from a different monorepo tool.
The prerequisite is having pnpm workspaces (or npm/yarn workspaces) already configured. If you have a repository with multiple packages and a root-level workspace configuration, adding Turborepo is primarily a configuration exercise: create turbo.json, define your task pipeline, and run turbo build instead of your existing build script.
The first time you run Turborepo on an existing repository, it builds a cache from scratch by executing all tasks. Subsequent runs hit the cache for packages that haven't changed. The immediate visible benefit is on CI: the second CI run after adding Turborepo typically shows significant speedup as unchanged packages hit the cache.
Teams moving from Nx to Turborepo (or vice versa) face more complexity. Nx generators and plugins have no equivalents in Turborepo's leaner model, and task runner configuration in project.json (Nx) differs from turbo.json. The most practical migration approach is parallel operation: keep the existing tool for generated configuration and scaffolding, add Turborepo for task running and caching, then phase out the old tool's task running once the team is comfortable with the new setup.
The most important thing to get right early is the outputs configuration in turbo.json. Turborepo caches whatever you declare in outputs — if you miss a build artifact directory, the cache restores everything except that directory, leading to subtle failures where a "cached" build appears to succeed but is missing files. Run your full build pipeline on a clean checkout after configuring Turborepo to verify all outputs are captured correctly.
Package managers matter for Turborepo performance. pnpm's isolated node_modules and content-addressed store complement Turborepo's input hashing. Bun's faster install time pairs well with Turborepo's caching — Bun installs in seconds, Turborepo avoids running tasks for unchanged packages. npm workspaces with hoisted node_modules work correctly with Turborepo but provide less isolation and slower install times at scale.
Quick Reference: Common Configuration Pitfalls
Teams new to Turborepo consistently encounter the same configuration issues. Knowing them in advance prevents hours of debugging.
Cache misses after changing non-code files: If you modify package.json (not just the scripts section, but adding or removing dependencies), Turborepo invalidates the cache for that package and all packages that depend on it. This is correct behavior — dependency changes can affect build output. If your cache hit rate drops unexpectedly, check whether a recent package.json change is the cause before investigating more complex issues.
outputs not including all generated files: A common mistake is declaring outputs: ["dist/**"] when the actual build output includes type definitions at dist/** and source maps at .tsbuildinfo. Without the source maps in outputs, they aren't cached and won't be restored on cache hits. Use outputs: ["dist/**", "*.tsbuildinfo"] to capture all artifacts.
Missing dependsOn for cross-package builds: If package A depends on package B's types, and you run build in parallel across all packages, A may start building before B's type definitions are available. The "dependsOn": ["^build"] pattern (where ^ means "in dependency packages first") ensures topological build ordering. For most JavaScript monorepos, this configuration should be the default for the build task.
Global turbo.json vs package-level overrides: Turborepo supports per-package task configurations in each package.json's turbo field, which override the root turbo.json. This is useful when one package has unique build requirements, but inconsistent use of this feature creates surprising cache behavior. Prefer keeping task configuration centralized in the root turbo.json when possible.
Compare monorepo tooling on PkgPulse. Related: Best Monorepo Tools 2026, Best JavaScript Package Managers 2026, and Best JavaScript Testing Frameworks 2026.
See the live comparison
View turborepo vs. nx on PkgPulse →