Monorepo & Turborepo – Modern Project Organization for Web Teams in 2026
Monorepo & Turborepo – Rethinking How Teams Organize Code
As web projects grow from a single application into ecosystems of frontends, backends, shared libraries, and internal tools, the question of repository structure becomes surprisingly impactful. A monorepo – keeping all related code in a single repository – has become the preferred approach for many of the world's most productive engineering organizations.
Combined with modern build orchestration tools like Turborepo, the monorepo pattern eliminates the friction that slows teams down: duplicated configurations, version mismatches between packages, and slow CI pipelines that rebuild everything from scratch.
This guide covers everything you need to know about monorepos and Turborepo in 2026 – from the fundamental concepts to a hands-on setup walkthrough.
What Is a Monorepo?
A monorepo (short for "monolithic repository") is a version control strategy where multiple projects, packages, or services coexist within a single repository. This does not mean a single massive application – each project within the monorepo remains independent, with its own package.json, build configuration, and deployment pipeline.
The key distinction is that these projects share:
- A single Git history and branching model.
- Common development tooling (linting, formatting, testing frameworks).
- The ability to import shared code without publishing packages to a registry.
- Atomic commits that can modify multiple projects simultaneously.
What a Monorepo Looks Like
A typical monorepo structure looks like this:
my-monorepo/
├── apps/
│ ├── web/ # Next.js frontend
│ ├── admin/ # Admin dashboard
│ └── api/ # Express/Fastify backend
├── packages/
│ ├── ui/ # Shared React component library
│ ├── config/ # Shared ESLint, TypeScript configs
│ ├── utils/ # Shared utility functions
│ └── types/ # Shared TypeScript types
├── turbo.json
├── package.json
└── pnpm-workspace.yaml
Each directory under apps/ is a deployable application. Each directory under packages/ is a shared library consumed by apps (and potentially other packages).
Monorepo vs Polyrepo – A Detailed Comparison
The alternative to a monorepo is a polyrepo (also called multi-repo) – each project lives in its own separate repository. Both approaches have legitimate strengths.
Advantages of Monorepos
- Code sharing without publishing. Shared components, utilities, and types are imported directly. No need to version, publish, and install packages from a registry. Changes to shared code are immediately available across all consumers.
- Atomic changes across projects. A single pull request can modify the API endpoint, the frontend that calls it, and the shared types they both use. No more coordinating releases across repositories.
- Consistent tooling. One ESLint configuration, one Prettier configuration, one TypeScript version. Every project in the monorepo follows the same standards, enforced from a single source of truth.
- Simplified dependency management. Shared dependencies are deduplicated at the root. When you upgrade React, every app in the monorepo gets the same version simultaneously.
- Better discoverability. New team members find all related code in one place. Cross-project search, IDE navigation, and refactoring work across the entire codebase.
Advantages of Polyrepos
- Simpler access control. Each repo has its own permissions. Contractors or external teams can access only the repos they need.
- Independent deployment. Each repo has a self-contained CI/CD pipeline with no risk of unrelated changes triggering unnecessary builds.
- Clearer ownership. Each repo has a defined owner or team, reducing ambiguity about who maintains what.
- Lower initial complexity. No need for workspace managers, build orchestrators, or specialized tooling. Standard Git workflows work out of the box.
- Smaller clone size. Developers only clone the repos they work on, not the entire codebase.
When to Choose a Monorepo
Choose a monorepo when:
- Multiple projects share significant code (UI components, types, utilities).
- Teams frequently need to make cross-project changes (API + frontend + types).
- You want to enforce consistent standards across all projects.
- The projects share a deployment cadence or are tightly coupled.
- You have (or plan to invest in) build tooling to manage the complexity.
When to Choose Polyrepos
Choose polyrepos when:
- Projects are truly independent with minimal shared code.
- Different teams need strict access control boundaries.
- Projects use completely different tech stacks (e.g., a Python ML service and a React frontend).
- You lack the resources to invest in monorepo tooling.
- Projects have very different release cycles and versioning requirements.
Turborepo Overview
Turborepo is a build system and task runner designed specifically for JavaScript and TypeScript monorepos. Acquired by Vercel in 2021, it has become the most popular monorepo tool in the JavaScript ecosystem alongside Nx.
Core Concepts
Turborepo operates on three key principles:
1. Task-based architecture. You define tasks (build, test, lint) and their dependencies. Turborepo figures out the optimal execution order and parallelization.
2. Content-addressable caching. Every task execution is cached based on a hash of its inputs. If nothing changed, the cached result is replayed instantly.
3. Incremental computation. Only the packages affected by a change are rebuilt. If you modify a utility function, only the packages that depend on it are re-tested and re-built.
How Turborepo Caching Works
Caching is the single biggest performance win. Here is what happens under the hood:
- Before running a task, Turborepo computes a hash from: source files, dependencies, environment variables, and the task configuration.
- It checks the local cache (stored in
node_modules/.cache/turbo) for a matching hash. - If no local match, it checks the remote cache (if configured) – a shared cache accessible to all team members and CI.
- On a cache hit, Turborepo replays the stdout/stderr output and restores the output files from cache, skipping actual execution entirely.
- On a cache miss, the task runs normally and the results are stored in cache for future runs.
In practice, this means a full CI build that takes 10 minutes on the first run might complete in 30 seconds on subsequent runs when only a small portion of code changed.
Turborepo vs Nx – Choosing the Right Tool
Turborepo and Nx are the two dominant monorepo build tools in the JavaScript ecosystem. They solve similar problems with different philosophies.
| Aspect | Turborepo | Nx |
|---|---|---|
| Philosophy | Minimal, fast, convention-based | Full-featured, plugin-rich, opinionated |
| Setup complexity | Low – a single turbo.json | Moderate – project.json files, plugin configs |
| Caching | Content-addressed, local + remote | Computation caching, Nx Cloud |
| Code generation | None built-in | Extensive generators and schematics |
| Plugin ecosystem | Minimal | Rich (React, Angular, Node, Nest, etc.) |
| Language support | JS/TS focused | JS/TS + Go, Rust, Java via plugins |
| Learning curve | Very low | Moderate to high |
| Owned by | Vercel | Nrwl |
| Best for | JS/TS teams wanting minimal setup | Large teams wanting full-featured tooling |
Choose Turborepo if you want a lightweight, fast tool that gets out of your way. It is ideal for teams already comfortable with their own build configurations who just need caching and task orchestration.
Choose Nx if you want an opinionated framework with code generators, architectural enforcement, and a rich plugin ecosystem. It is ideal for large organizations that benefit from standardized project scaffolding.
Step-by-Step Setup: pnpm Workspaces + Turborepo
Here is a complete walkthrough for setting up a monorepo with pnpm workspaces and Turborepo.
1. Initialize the Repository
mkdir my-monorepo && cd my-monorepo
git init
pnpm init
2. Configure pnpm Workspaces
Create pnpm-workspace.yaml at the root:
packages:
- "apps/*"
- "packages/*"
3. Install Turborepo
pnpm add turbo -D -w
4. Configure Turborepo
Create turbo.json at the root:
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"test": {
"dependsOn": ["build"]
},
"type-check": {
"dependsOn": ["^build"]
}
}
}
The ^build syntax means "run the build task in all dependencies first." This ensures shared packages are compiled before the apps that consume them.
5. Create a Shared UI Package
mkdir -p packages/ui/src
Create packages/ui/package.json:
{
"name": "@repo/ui",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
Create packages/ui/src/index.ts:
export { Button } from "./Button";
export { Card } from "./Card";
Create packages/ui/src/Button.tsx:
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost";
}
export function Button({ variant = "primary", children, ...props }: ButtonProps) {
return (
<button data-variant={variant} {...props}>
{children}
</button>
);
}
6. Create a Next.js App That Uses the Shared Package
mkdir -p apps/web
cd apps/web
pnpm create next-app . --typescript --tailwind --app
Add the shared package as a dependency in apps/web/package.json:
{
"dependencies": {
"@repo/ui": "workspace:*"
}
}
Now you can import shared components directly:
import { Button } from "@repo/ui";
export default function HomePage() {
return (
<main>
<Button variant="primary">Get Started</Button>
</main>
);
}
7. Add Root Scripts
In the root package.json:
{
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"test": "turbo test",
"type-check": "turbo type-check"
}
}
Running pnpm dev at the root now starts all apps in parallel. Running pnpm build builds all packages in the correct dependency order, with caching.
Caching and Pipeline Configuration
Understanding Pipeline Dependencies
The dependsOn field in turbo.json controls task execution order:
"dependsOn": ["^build"]– Runbuildin all dependency packages first (topological)."dependsOn": ["build"]– Runbuildin the same package first (sequential)."dependsOn": ["lint", "test"]– Runlintandtestin the same package first.
Environment Variable Handling
Turborepo needs to know about environment variables that affect build output:
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"env": ["DATABASE_URL", "API_KEY"],
"outputs": ["dist/**"]
}
}
}
Variables listed in env are included in the cache hash. Different environment values produce different cache entries.
Remote Caching
Remote caching is where Turborepo delivers its biggest team-wide impact. Enable it with Vercel:
npx turbo login
npx turbo link
Now when one developer builds a package, the cache is uploaded to the cloud. The next developer (or CI runner) who builds the same code gets an instant cache hit – even on a clean machine.
Sharing Packages Across Applications
Internal Package Patterns
Shared packages in a monorepo typically follow one of two patterns:
1. Source-level sharing (recommended for most cases): The consuming app transpiles the package directly. No build step needed in the shared package.
{
"name": "@repo/utils",
"main": "./src/index.ts",
"types": "./src/index.ts"
}
Configure your app's bundler (Next.js, Vite) to handle the transpilation via transpilePackages or similar settings.
2. Pre-built packages: The shared package has its own build step that produces compiled output.
{
"name": "@repo/utils",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts"
}
}
This approach is necessary when the package needs to be consumed by tools that cannot handle raw TypeScript.
Shared Configuration Packages
One of the most practical monorepo patterns is sharing tool configurations:
packages/config/
├── eslint/
│ └── index.js # Shared ESLint config
├── typescript/
│ ├── base.json # Base tsconfig
│ ├── nextjs.json # Next.js-specific tsconfig
│ └── library.json # Library-specific tsconfig
└── prettier/
└── index.js # Shared Prettier config
Apps and packages reference these configs:
{
"extends": "@repo/config/typescript/nextjs.json"
}
This pattern ensures every project follows identical standards and any configuration update propagates automatically.
CI/CD with Monorepos
Optimizing CI for Monorepos
The biggest CI challenge with monorepos is avoiding full rebuilds on every commit. Turborepo solves this through caching, but additional optimizations help:
1. Filter by affected packages:
turbo build --filter=...[origin/main]
This builds only packages that changed since the main branch, plus their dependents.
2. Use remote caching in CI:
# GitHub Actions example
- name: Build
run: pnpm turbo build --filter=...[origin/main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
3. Parallelize independent jobs:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: pnpm turbo lint --filter=...[origin/main]
test:
runs-on: ubuntu-latest
steps:
- run: pnpm turbo test --filter=...[origin/main]
build:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- run: pnpm turbo build --filter=...[origin/main]
Versioning Strategies
When packages in a monorepo need to be published to npm, versioning becomes a consideration. Two tools dominate:
Changesets
Changesets is the most popular versioning tool for monorepos. Developers create "changeset" files describing their changes, and a CI bot aggregates them into version bumps and changelogs.
pnpm add @changesets/cli -D -w
pnpm changeset init
When making a change:
pnpm changeset
# Select affected packages
# Choose version bump type (patch/minor/major)
# Write a description
Conventional Commits + Lerna
For teams using conventional commits (feat:, fix:, etc.), Lerna can automatically determine version bumps from commit messages and publish packages.
Real-World Monorepo Examples
Understanding who uses monorepos (and how) provides useful context:
- Google – Runs one of the largest monorepos in the world. All of Google's code (excluding some open-source projects) lives in a single repository with custom tooling (Blaze/Bazel).
- Meta – Uses a massive monorepo with a custom source control system (Sapling). The scale requires specialized tooling, but the benefits of atomic changes and code sharing are considered essential.
- Vercel – Dogfoods Turborepo for their own products. The Next.js repository itself is a monorepo managed with Turborepo.
- Shopify – Uses a monorepo for their admin and storefront codebases, sharing components and business logic across products.
- Stripe – Runs a monorepo for their dashboard and internal tools, sharing API clients and UI components.
When NOT to Use a Monorepo
Monorepos are not universally the right choice. Avoid them when:
- Your projects are truly independent. If a marketing site, a machine learning pipeline, and a mobile app share zero code and are maintained by different teams, separate repos are simpler.
- You cannot invest in tooling. A monorepo without proper build orchestration (Turborepo, Nx, Bazel) quickly becomes a source of frustration as CI times balloon.
- Access control is critical. If you need strict isolation between teams or contractors, polyrepos offer simpler permission models.
- Your team is not ready. Monorepos require discipline – broken builds in shared packages affect everyone. Teams need to commit to maintaining shared infrastructure.
Getting Started Checklist
Use this checklist to evaluate and set up a monorepo for your team:
- [ ] Evaluate code sharing needs. List the code that is (or should be) shared across projects. If the overlap is minimal, a monorepo may not be justified.
- [ ] Choose a workspace manager. pnpm workspaces (recommended), npm workspaces, or Yarn workspaces.
- [ ] Choose a build orchestrator. Turborepo for simplicity, Nx for a full-featured framework.
- [ ] Define your directory structure. Separate
apps/(deployable) frompackages/(shared libraries). - [ ] Set up shared configurations. Create config packages for ESLint, TypeScript, and Prettier.
- [ ] Configure the pipeline. Define task dependencies and caching rules in
turbo.json. - [ ] Enable remote caching. Connect to Vercel Remote Cache or set up a self-hosted cache.
- [ ] Optimize CI/CD. Use
--filterflags to build only affected packages. - [ ] Establish versioning. Set up Changesets or conventional commits for publishable packages.
- [ ] Document conventions. Write a CONTRIBUTING.md explaining how to add apps, packages, and dependencies.
- [ ] Train the team. Ensure everyone understands the workspace structure, dependency graph, and build pipeline.
Conclusion
The monorepo pattern is not a silver bullet, but for teams building multiple related JavaScript and TypeScript projects, it dramatically reduces the friction of code sharing, cross-project changes, and tooling consistency. When paired with Turborepo's intelligent caching and task orchestration, teams see build times drop by 60-80% and spend far less time managing infrastructure between repositories.
Start small – move two related projects into a monorepo with a shared package, configure Turborepo caching, and experience the workflow improvement firsthand. The transition does not have to be all-or-nothing, and the benefits compound as you add more projects to the workspace.
Need help? Contact us.

