Monorepos with Turborepo: Scaling a Multi-Package Codebase

Vercel acquired Turborepo in December 2021, and I decided to migrate one of my multi-package projects to it shortly after. I had been using plain npm workspaces with manual build scripts, and the experience was painful. Build ordering was manual, caching was nonexistent, and CI runs took forever because every package rebuilt from scratch on every commit. Turborepo fixed all of that.
Why Monorepos
Before diving into Turborepo specifically, let me explain why monorepos are worth considering in the first place. A monorepo is a single repository that contains multiple packages or applications. Instead of separate repositories for your web app, your API server, your shared UI library, and your utility packages, everything lives together.
The advantages are compelling:
- Shared code without publishing: Your UI library is imported directly by your web app. No npm publishing, no version management, no "which version of the shared library am I running?" questions.
- Atomic changes: When you change a shared type definition, you update every consumer in the same commit. No multi-repo coordination, no "update library, publish, update consumers" dance.
- Unified CI: One CI pipeline tests everything. A change to the shared library automatically triggers tests in every package that depends on it.
- Consistent tooling: One ESLint config, one TypeScript config, one Prettier config. Every package follows the same standards.
The challenge with monorepos is build performance. When you have 10 packages and only change one, you do not want to rebuild all 10. You need a build system that understands the dependency graph and only rebuilds what changed. That is exactly what Turborepo does.
Workspace Setup
Turborepo works on top of your package manager's workspace feature. I use pnpm workspaces, but npm and Yarn workspaces work too. Here is the typical structure:
my-monorepo/
package.json
pnpm-workspace.yaml
turbo.json
apps/
web/
package.json // Next.js app
src/
api/
package.json // Express API server
src/
packages/
ui/
package.json // Shared React component library
src/
config/
package.json // Shared ESLint, TypeScript configs
utils/
package.json // Shared utility functions
src/
The pnpm-workspace.yaml tells pnpm which directories contain packages:
packages:
- 'apps/*'
- 'packages/*'
Each package has its own package.json with its own dependencies and scripts. Packages reference each other using the workspace protocol:
// apps/web/package.json
{
"name": "web",
"dependencies": {
"ui": "workspace:*",
"utils": "workspace:*",
"next": "13.1.0",
"react": "18.2.0"
}
}
The workspace:* protocol tells pnpm to resolve these dependencies from the local workspace instead of the npm registry. When you import from ui in your web app, it resolves to the local packages/ui directory. No publishing needed.
turbo.json: Pipeline Configuration
The turbo.json file is where you define your build pipeline. It tells Turborepo how tasks relate to each other, what their inputs and outputs are, and how they should be cached.
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
The dependsOn field is where the magic happens. "^build" means "run the build task in all dependencies before running build in this package." The caret (^) prefix indicates a topological dependency. If web depends on ui, and both have a build script, Turborepo builds ui first, then web. Without the caret, "dependsOn": ["build"] means "run build in this same package before this task."
The outputs array tells Turborepo which files are produced by the task. This is critical for caching. When Turborepo caches a build, it stores these output files. When it restores from cache, it recreates these files. If your build outputs go to dist/, list "dist/**". If you are using Next.js, list ".next/**".
Caching: Never Rebuild Unchanged Packages
Caching is Turborepo's killer feature. For every task, Turborepo computes a hash based on the source files, the task configuration, the environment variables, and the hashes of all dependency tasks. If the hash matches a previous run, Turborepo skips the task entirely and restores the cached outputs.
$ turbo run build
Tasks: 4 successful, 4 total
Cached: 3 cached, 4 total
Time: 1.2s
In this example, only one package actually rebuilt. The other three were restored from cache in milliseconds. On a monorepo with 10 or 20 packages, this turns a 5-minute build into a 30-second build when only one package changed.
Local caching stores results in node_modules/.cache/turbo. This works great for individual developers. But the real advantage is remote caching.
Remote Caching
Remote caching stores task outputs in a shared cache that all developers and CI runners can access. When your teammate builds the ui package and pushes to CI, the CI server reuses their cached build instead of rebuilding from scratch. When you pull their changes and run turbo run build, you get the cached result too.
Vercel provides built-in remote caching for Turborepo (free for personal use, paid for teams). You authenticate with npx turbo login and link your repo with npx turbo link. After that, cache artifacts are automatically uploaded and downloaded from Vercel's servers.
$ npx turbo login
$ npx turbo link
# Now caches are shared across your team
$ turbo run build
Remote caching enabled
Tasks: 4 successful, 4 total
Cached: 4 cached, 4 total
Time: 0.4s
Four out of four cached. The entire build completed in under half a second because every package was already cached by a teammate or a previous CI run. This completely transforms CI performance. Pull request builds that used to take 8 minutes now take under a minute because most packages have not changed.
Task Dependencies and Topological Ordering
Turborepo builds a directed acyclic graph (DAG) of your tasks based on the dependency relationships between packages and the dependsOn configuration. It then executes tasks in topological order, running independent tasks in parallel.
Consider this dependency graph: web depends on ui and utils. ui depends on utils. When you run turbo run build:
utilsbuilds first (no dependencies)uibuilds next (depends onutils)webbuilds last (depends on bothuiandutils)
If api also depends on utils but not on ui, then api and ui build in parallel after utils completes. Turborepo maximizes parallelism while respecting dependency ordering. You never have to think about build order manually.
Practical: Shared UI Library
The most common pattern in a monorepo is a shared component library. Here is a minimal setup for a packages/ui library consumed by a Next.js app:
// packages/ui/package.json
{
"name": "ui",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"lint": "eslint src/"
},
"devDependencies": {
"tsup": "6.5.0",
"typescript": "4.9.0"
},
"peerDependencies": {
"react": "^18.0.0"
}
}
// packages/ui/src/index.ts
export { Button } from './Button';
export { Card } from './Card';
export { Input } from './Input';
// packages/ui/src/Button.tsx
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost';
}
export function Button({ variant = 'primary', className, ...props }: ButtonProps) {
const baseStyles = 'px-4 py-2 rounded font-medium transition-colors';
const variantStyles = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
ghost: 'bg-transparent text-gray-600 hover:bg-gray-100',
};
return (
<button
className={baseStyles + ' ' + variantStyles[variant] + ' ' + (className || '')}
{...props}
/>
);
}
In your Next.js app, you import directly:
// apps/web/src/pages/index.tsx
import { Button, Card } from 'ui';
export default function Home() {
return (
<Card>
<h1>Welcome</h1>
<Button variant="primary">Get Started</Button>
</Card>
);
}
Full type safety, autocompletion, and click-through-to-source all work because the packages are local workspace dependencies. No build step needed during development if you configure Next.js to transpile the workspace package with transpilePackages: ['ui'] in next.config.js.
Turborepo vs. Nx
Nx is the other major player in the JavaScript monorepo space. It has been around longer and offers more features out of the box: code generators, dependency graph visualization, affected command to run tasks only on changed packages, and plugins for specific frameworks.
Turborepo is simpler. It does one thing (task orchestration with caching) and does it well. The configuration is a single turbo.json file. Nx requires more setup and has a steeper learning curve, but it also gives you more tools for large-scale monorepos with dozens or hundreds of packages.
My rule of thumb: if you have fewer than 15 packages and want minimal configuration overhead, Turborepo. If you have 50 or more packages, multiple teams, and need advanced features like code ownership and affected analysis, consider Nx.
When Monorepos Make Sense
Monorepos are not always the right call. They make sense when you have shared code between multiple applications, when you want atomic changes across packages, and when your team is small enough that everyone can work in one repository without stepping on each other.
They make less sense when packages are truly independent (no shared code), when teams are large and need strict ownership boundaries, or when your CI system cannot handle the scale. The polyrepo approach (separate repos for each package) is simpler to set up and has clearer ownership boundaries.
For my projects, monorepos with Turborepo have been a clear win. The caching alone saves enough time to justify the setup. Combined with pnpm workspaces for dependency management and Turborepo for task orchestration, the developer experience of working across multiple packages is smooth and fast.