Back to writing

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 configs

All of these projects live in the same Git repo. They can import from each other directly - no publishing, no versioning, no copy-pasting.

apps/web/src/components/UserCard.tsx
// 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

MonorepoPolyrepo
Sharing codeDirect importsPublish npm packages
RefactoringOne PR across everythingMultiple PRs, coordinated
ToolingConfigure once, share everywhereDuplicated per repo
CI/CDOne pipeline (with smart caching)One pipeline per repo
OnboardingClone one repo, you have everythingClone 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, executing

Changed 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.ts

Every package extends from the shared config:

apps/web/.eslintrc.js
module.exports = {
  extends: ['@workspace/config-eslint/next'],
};
apps/api/tsconfig.json
{
  "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.