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.