What Is a Monorepo? A Simple Explanation
If you've been around the JavaScript ecosystem for a while, you've probably heard the word monorepo thrown around. Maybe you've seen it in a job description, or noticed that big projects like Next.js, Babel, and Turborepo itself all use one.
But what actually is a monorepo? When does it make sense? And why does everyone seem to reach for Turborepo when they set one up?
Let's break it down from scratch.
The Problem With Separate Repositories
Imagine you're building a product that has three parts:
- A web app (Next.js)
- An API server (Express)
- A shared UI component library
The obvious thing is to create three separate GitHub repositories - one for each. This works fine at the start. But as the project grows, you start running into friction.
Sharing code is painful. Your web app and your API both need the same TypeScript types - say, the shape of a User object. In separate repos, you have two choices: copy-paste the types (and keep them in sync manually), or publish the types as a private npm package (and deal with versioning, publishing, and npm install every time you make a change).
Refactoring across boundaries is hard. If you rename a function that's used in both the web app and the API, you're making two pull requests, coordinating two deployments, and hoping nothing breaks in between.
Tooling multiplies. Every repo needs its own ESLint config, Prettier config, TypeScript config, CI pipeline, and dependency management. Three repos means three of everything, and they slowly drift out of sync.
This is the problem monorepos solve.
What a Monorepo Actually Is
A monorepo is just one repository that contains multiple projects.
That's it. The word sounds more complicated than it is.
my-monorepo/
apps/
web/ ← Next.js frontend
api/ ← Express backend
packages/
ui/ ← Shared component library
types/ ← Shared TypeScript types
config/ ← Shared ESLint, TS, Tailwind configsAll of these projects live in the same Git repo. They can import from each other directly - no publishing, no versioning, no copy-pasting.
// Import directly from the shared package - no npm publish needed
import { Button } from '@workspace/ui';
import type { User } from '@workspace/types';When you change something in @workspace/types, both apps/web and apps/api see the change immediately. One PR, one review, one merge.
Monorepo vs Polyrepo
| Monorepo | Polyrepo | |
|---|---|---|
| Sharing code | Direct imports | Publish npm packages |
| Refactoring | One PR across everything | Multiple PRs, coordinated |
| Tooling | Configure once, share everywhere | Duplicated per repo |
| CI/CD | One pipeline (with smart caching) | One pipeline per repo |
| Onboarding | Clone one repo, you have everything | Clone N repos, set up N environments |
Neither is universally better. Polyrepos make sense when teams are large and truly independent - different release cycles, different tech stacks, different on-call rotations. Monorepos shine when a small-to-medium team is building a product where the parts are tightly related.
The Problem With Naive Monorepos
Here's the catch: if you just throw everything in one repo without tooling, you create a new problem.
Every time you make a change anywhere your CI runs tests for everything. Change a button color in the UI library? You're waiting for the API's test suite to run. Fix a typo in the docs? Full rebuild.
As the repo grows, this gets slow. Very slow. Eventually, a CI run that should take 2 minutes takes 20 minutes because you're running every test for every package on every commit.
This is the problem Turborepo solves.
Enter Turborepo
Turborepo is a build system for monorepos. Its job is simple: only run tasks for things that actually changed.
It does this through two mechanisms: a task graph and caching.
The task graph
You define tasks in turbo.json and declare which tasks depend on which:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"lint": {
"dependsOn": []
},
"test": {
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}The ^build syntax means: "before building this package, build all of its dependencies first." So if apps/web depends on packages/ui, Turborepo builds packages/ui first, automatically. You don't manage the order manually.
Caching
This is where Turborepo earns its reputation.
Before running any task, Turborepo computes a cache key — a hash of:
- The source files for that package
- The task's configuration
- The relevant environment variables
- The outputs of any dependency tasks
If the cache key matches a previous run, Turborepo skips the task entirely and restores the cached output. No rebuild, no retest.
$ turbo build
• Packages in scope: web, api, ui, types
• Running build in 4 packages
@workspace/types:build - cache hit, replaying output
@workspace/ui:build - cache hit, replaying output
@workspace/api:build - cache miss, executing
@workspace/web:build - cache miss, executingChanged only the API? Only the API rebuilds. The UI, types, and anything that didn't change gets restored from cache in milliseconds.
Remote caching
Turborepo's caching works locally by default. But with remote caching (via Vercel or a self-hosted cache), the cache is shared across your entire team and CI.
This means if your colleague already built and tested the UI package on their machine, your CI run doesn't build and test it again - it just pulls the cached result. A cold CI run on a large monorepo can go from 15 minutes to under 2 minutes.
How Packages Talk to Each Other
In a Turborepo monorepo, packages are linked through the workspaces field in your root package.json:
{
"name": "my-monorepo",
"workspaces": ["apps/*", "packages/*"]
}Each package has its own package.json with a name:
{
"name": "@workspace/ui",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}And other packages import it like any npm package:
{
"name": "@workspace/web",
"dependencies": {
"@workspace/ui": "*"
}
}The * version means "use whatever version is in this repo." No versioning, no publishing. The package manager (npm, pnpm, or yarn) resolves the local path automatically.
Shared Configs: The Underrated Win
One of the quietest benefits of a monorepo is shared tooling configs.
Instead of duplicating your ESLint config across five packages, you create one shared config package:
packages/
config-eslint/
base.js
next.js
react.js
config-typescript/
base.json
nextjs.json
config-tailwind/
base.tsEvery package extends from the shared config:
module.exports = {
extends: ['@workspace/config-eslint/next'],
};{
"extends": "@workspace/config-typescript/base"
}Now updating a lint rule once updates it everywhere. Every package stays in sync automatically.
When Should You Use a Monorepo?
A monorepo is worth the setup cost when:
- You're building multiple apps that share code - a web app and a mobile app sharing types, a frontend and API sharing validation schemas.
- Your team works across the full stack - nobody should have to coordinate PRs across three repos just to ship one feature.
- You want consistent tooling - one ESLint config, one TypeScript config, one CI pipeline for the whole project.
It's probably overkill when:
- You have one app with no shared packages - a plain Next.js app doesn't need a monorepo.
- Your services are truly independent - different teams, different languages, different release cycles.
Trying it yourself
The best way to understand monorepos is to set one up. Run npx create-turbo@latest - it scaffolds a basic Turborepo monorepo in under a minute. Poke around the turbo.json, change a file in one package, and watch what rebuilds. The caching behavior makes a lot more sense once you see it skip a task for the first time.
I write about full-stack development, tooling, and building in public as a CS student. Follow me on Twitter/X for more posts like this.