
Monorepos Explained: What They Are and When to Use One
What a monorepo is, how it differs from a monolith and polyrepo, and how workspaces and Turborepo work, tested for real.

A monorepo is one repository that holds several separate projects. They live side by side, can share code and are built and tested together. The opposite approach, one repository per project, is often called a polyrepo or multi-repo setup.
This guide explains the monorepo definition, how it differs from a monolith and from microservices, how JavaScript monorepos work, and which tools help.
I also built a small one and ran it, so the outputs below are real. I checked the sources on October 23, 2026.
The short answer
On Nx's monorepo.tools site, a monorepo is defined as one repository that holds several distinct projects whose relationships are clearly defined. Putting unrelated code in one folder is not enough.
A monorepo makes shared code and cross-project changes easier. It also needs tooling, because builds and tests grow with the repository. Small teams often start with workspaces, then add a build system when it gets slow.
A monorepo is about where code is stored. A monolith is about how software is built and deployed. They are separate choices, and you can have either, both or neither.
Monorepo meaning and definition
The same site makes a useful point. It says a repository with several projects but no defined relationships between them is just code colocation, not a monorepo. The relationships are what matter.
In practice that means projects can depend on each other in a known way, so tools can tell which projects a change affects. That is what lets a build system skip work.
A famous example
Google stores most of its code in one repository. A 2016 article in Communications of the ACM by Rachel Potvin and Josh Levenberg describes it as a monolithic repository and discusses the advantages and trade-offs of the model.
The article reports that the repository held about two billion lines of code in January 2015. Google built its own custom source control system for it, so its setup is not a template for ordinary teams.
Monorepo vs polyrepo
Polyrepo, multi-repo and many repositories all mean one repository per project. This table is our comparison. Neither approach wins in every case.
| Question | Monorepo | Polyrepo |
|---|---|---|
| Sharing code | Projects import each other in place | Shared code is usually published as a package |
| Cross-project changes | One change and one review can span projects | Separate changes in each repository, kept in sync |
| Tooling and dependency versions | One setup for everything | Each repository has its own |
| Build and test time | Grows unless the tooling skips unaffected work | Each repository stays small |
| Access and ownership | Everyone sees one repository, so ownership rules need care | Access is easy to set per repository |
| Setup effort | A tool such as a workspace manager or build system is almost required | Little extra tooling |
Monorepo vs monolith
This is our explanation of two terms that sound alike. A monolith is one application deployed as a single unit. A monorepo is one repository. A monorepo can hold many independently deployed applications.
Google's article calls its repository monolithic, which describes how the code is stored. That is a different idea from a monolithic application.
Monorepo vs microservices
Microservices split a system into small services that deploy separately. They can live in one repository or in many. A monorepo does not force a monolith, and microservices do not force separate repositories.
Many teams keep several services in one monorepo so that a change to a shared contract and its users ship together. This is our analysis, not a rule.
Monorepo architecture
A typical JavaScript monorepo holds a root package.json, a folder for packages and some shared configuration. My demo uses this layout.
.
├── package.json
├── turbo.json
└── packages
├── app
│ └── package.json
└── utils
└── package.json
The utils package has a function, and the app package imports it. The root package.json declares which folders are packages. If the app package is a front-end app, our Vite guide shows how to start one.
How workspaces work
The npm documentation describes workspaces as features for managing multiple packages from your local file system within one top-level root package. You name them in the workspaces field of the root package.json.
{
"name": "demo-monorepo",
"private": true,
"workspaces": ["packages/*"]
}
npm's documentation says that running npm install links the workspace folders into the root node_modules folder. I confirmed this in my demo.
$ npm install
added 2 packages in 305ms
$ ls -l node_modules/@demo
lrwxrwxrwx 1 root root 18 Oct 23 02:27 app -> ../../packages/app
lrwxrwxrwx 1 root root 20 Oct 23 02:27 utils -> ../../packages/utils
The app package can now import @demo/utils, and Node finds it through the link. There is no publishing step in between.
pnpm workspaces
pnpm describes its workspace support with a pnpm-workspace.yaml file that lists package patterns. Its documentation also covers the workspace: protocol, which makes a dependency point at a package inside the repository.
pnpm's documentation adds that since version 12.7.0, a repository with a workspaces field in package.json and no pnpm-workspace.yaml gets one created on install. Our guide to npm vs pnpm vs Yarn vs Bun compares the tools.
What went wrong in my demo
The utils package builds into a dist folder, and the app imports that built output. I ran the build in every workspace with npm. It failed.
$ npm run build --workspaces
npm notice run @demo/app@1.0.0 build
Error: Cannot find module '/tmp/mono/node_modules/@demo/utils/dist/index.js'.
Please verify that the package.json has a valid "main" entry
npm built the app first, before utils had produced its dist folder, so the import failed. Workspaces link packages together, but they do not know that one package must be built before another.
The fix is a build system
This is the gap build systems fill. Turborepo's documentation describes caching and task scheduling, and says it schedules tasks to run in parallel across available cores. It reads the scripts you already wrote in package.json.
I added a small turbo.json. The dependsOn setting with a caret tells Turborepo to build a package's dependencies first, and outputs names the folder to cache.
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}
First run
Turborepo built utils, then app, and both were cache misses. It took 229 milliseconds in my test.
$ npx turbo run build
Packages in scope: @demo/app, @demo/utils
@demo/utils:build: cache miss, executing 6e264f57316bc0ba
@demo/utils:build: built utils
@demo/app:build: cache miss, executing cb381314f1ec9453
@demo/app:build: built app
Tasks: 2 successful, 2 total
Cached: 0 cached, 2 total
Second run, nothing changed
Turborepo's documentation says it restores the results of a cacheable task from a fingerprint taken the first time the task ran. Both tasks were cache hits, and the run took 5 milliseconds.
$ npx turbo run build
@demo/utils:build: cache hit, replaying logs 6e264f57316bc0ba
@demo/app:build: cache hit, replaying logs cb381314f1ec9453
Tasks: 2 successful, 2 total
Cached: 2 cached, 2 total
Time: 5ms >>> FULL TURBO
After changing the app
I added a comment to the app's source. Only the app was rebuilt, and utils came straight from the cache.
@demo/utils:build: cache hit, replaying logs 6e264f57316bc0ba
@demo/app:build: cache miss, executing 144e7731c251dc3a
Cached: 1 cached, 2 total
After changing utils
I then changed the utils package. Both tasks rebuilt, because the app depends on utils. That is the monorepo idea in miniature: change one package, and only that package and the ones that depend on it do work.
@demo/utils:build: cache miss, executing 6d3a6202712ea420
@demo/app:build: cache miss, executing 2bec5a22486df368
Cached: 0 cached, 2 total
Remote caching
Turborepo's documentation says its Remote Cache keeps task results, so your CI does not repeat work it has already done. My demo had remote caching disabled, as the run output showed.
Teams use a shared cache so a build finished on one machine can be reused on another. The documentation describes this feature, and I did not test it.
Monorepo tools
This table lists common tools and what their own documentation says. Versions are the latest on the npm registry on October 23, 2026 for the JavaScript tools.
| Tool | What its documentation says |
|---|---|
| npm, pnpm, Yarn, Bun workspaces | Link multiple packages in one repository. pnpm's page notes that npm, Yarn and Bun repositories list projects in a workspaces field |
| Turborepo 2.11.10 | A build system for scaling monorepos, with caching, task scheduling and a Remote Cache |
| Nx 24.0.0 | A build system for JavaScript and TypeScript monorepos, with plugins that extend it to other languages |
| Lerna 10.1.1 | Described as the original monorepo tool for JavaScript and TypeScript, which runs a command across projects efficiently |
| Bazel | An open-source build and test tool similar to Make, Maven and Gradle, for many languages, which caches previous work |
How to choose a tool
This is our advice. If you only need packages to link together, workspaces are enough. If builds get slow or order matters, as in my demo, add a build system.
If you are mixing many languages in one repository, look at Bazel or Nx's plugins. If you publish many packages, consider Lerna. Try one on a copy of your repository first.
Benefits of a monorepo
These benefits are our summary. They match the themes in Google's article, such as code sharing and collaboration across teams.
Easier sharing
Projects use each other's code without publishing it.
Atomic changes
One change can update a library and everything that uses it.
One toolchain
Linting, testing and dependency versions are set once.
Visibility
People can see and search other teams' code.
Costs and risks
Builds grow
Without caching and affected-only runs, CI slows down as the repository grows.
Structure matters
Without defined relationships between projects, the benefits fade.
Tooling to learn
Workspaces and a build system add concepts to your team.
Access control
Everyone works in one repository, so decide ownership rules early.
When a monorepo fits
This checklist is our advice. A monorepo tends to fit when several projects share code, change together or share tooling, and the team can adopt a build tool.
Polyrepo tends to fit when projects are unrelated, owned by separate teams with different release cycles, or need different access rules. Start small: move two related packages together and see how it feels.
pnpm workspaces in practice
I also built a three-package workspace with pnpm 12.15.0 to see how it behaves. The app depends on a ui package and a utils package, and ui depends on utils. Each dependency uses the workspace protocol.
"dependencies": { "@demo/ui": "workspace:*", "@demo/utils": "workspace:*" }
After install, pnpm lists the local packages as links to their folders. Nothing is downloaded from the registry for them, so an edit to utils shows up in app immediately.
$ pnpm -r list --depth 0
@demo/app@1.0.0 /tmp/mono2/packages/app
├── @demo/ui@link:../ui
└── @demo/utils@link:../utils
@demo/ui@1.0.0 /tmp/mono2/packages/ui
└── @demo/utils@link:../utils
The order is the useful part. When I ran the build in every package with pnpm, it built utils, then ui, then app.
That is dependency order, and it avoided the failure that npm's workspaces run hit in the demo above.
$ pnpm -r run build
Scope: 3 of 4 workspace projects
built utils
built ui
built app
That is one test on a tiny repository, so do not read it as a rule for every project.
It does show why the choice of package manager matters in a monorepo. Our guide to npm vs pnpm vs Yarn vs Bun compares them.
Run only what changed
The goal of a monorepo build is to do less work, not more. Filters let you pick a package and everything it needs. In pnpm, a package name followed by three dots means that package and its dependencies.
$ pnpm --filter @demo/ui... run build
Scope: 2 of 4 workspace projects
built utils
built ui
Only the ui package and its one dependency were built. The app, which nothing here needs, was skipped. Turborepo has the same idea. In my npm demo, filtering to the utils package replayed its cache.
$ npx turbo run build --filter=@demo/utils
@demo/utils:build: cache hit, replaying logs 6d3a6202712ea420
Tasks: 1 successful, 1 total
Cached: 1 cached, 1 total
Affected-only runs
Turborepo's run reference describes an affected option that filters to the packages affected by changes on the current branch.
By default it compares your branch with main, and it can be combined with a filter, for example to build only one app when it is affected.
That comparison needs the Git history between the two points. In a continuous integration job that clones only the latest commit, it cannot see what changed, so fetch enough history for the comparison to work.
Continuous integration for a monorepo
CI is where monorepos either shine or hurt. If every change triggers every test, build times grow with the repository. These are the usual tools, and we suggest using them together.
Path filters
GitHub's workflow syntax lets a workflow run only when files in certain paths change, with paths and paths-ignore options.
Affected-only tasks
Build and test only the packages that the change touches.
Remote caching
Share task results between your machine and CI, so work done once is not repeated.
Parallel jobs
Split independent packages across jobs.
Start with caching and affected-only runs. They deliver most of the benefit and need little configuration.
Ownership with CODEOWNERS
When many teams share one repository, someone must be responsible for each area. GitHub's code owners feature uses a CODEOWNERS file to name the people or teams responsible for code in a repository.
The file may sit in the .github folder, the repository root or the docs folder, and GitHub uses the first one it finds.
Each file applies to one branch. People with admin or owner permission can require approval from code owners before a pull request merges.
The effect is that a change in the payments folder asks the payments team for review, even though everyone shares the repository. Check GitLab's and Bitbucket's own documentation if you use those hosts, because the details differ.
Versioning and publishing
If your packages are published to a registry, you have a choice.
One policy is a single version number for everything, which is simple but releases packages that did not change. The other is independent versions, where each package moves on its own.
Changesets is an open source tool that is described as handling versioning and changelogs, with monorepos as its focus.
Contributors add a small note about each change, and the tool turns those notes into version bumps and changelog entries. Lerna and Nx also cover releases.
If your packages are internal and only deployed together, you may not need versioning at all. Workspace links already keep them in step.
One version of each dependency
A monorepo makes it possible to use one version of each third-party library across all projects. That is our recommendation, because it avoids the situation where two apps in the same repository break in different ways on different versions.
The cost is that an upgrade touches everything at once. With good tests and affected-only runs, that is usually a fair trade, and the change lands in one pull request.
Moving an existing repository in
You can bring an old repository into a monorepo and keep its history. I tried git subtree, which adds another repository's history under a folder. The old repository had three commits.
$ git subtree add --prefix=packages/lib /tmp/oldlib main
Added dir 'packages/lib'
$ git log --oneline --graph
* 7049798 Add 'packages/lib/' from commit 'c1376d8...'
|| * c1376d8 Fix lib bug
| * 5267592 Improve lib
| * 97c39ad Add lib
* 4a39bef app change
* c735e7a baseline
The three original commits are in the graph. There is a caveat, though.
Those commits recorded files at the old repository's root, so a path-limited log for packages/lib shows only the import commit. The history is kept, but its paths are not rewritten.
Nx's documentation has a section on preserving Git histories when you import projects, so check the tool you plan to use.
Moving files into a folder and rewriting history is a bigger operation, and you should try it on a copy first.
A migration plan
This is our advice for moving several repositories into one. Go in small steps.
Pick two related projects
Start with projects that already share code, so the benefit is visible quickly.
Choose workspace tooling
Set up npm, pnpm or Yarn workspaces and confirm the build works.
Import with history
Use subtree or the importer your build tool offers, and test on a copy.
Add caching and affected-only runs
Do this before the repository grows.
Set ownership
Add a CODEOWNERS file so review stays targeted.
Retire the old repositories
Archive them and point their readmes at the new location.
When a monorepo hurts
A monorepo is not free. Google's article describes building its own custom source control system for its repository, which shows how much tooling a very large one can need. Most teams are nowhere near that size.
Still, watch for these signs: clones and checkouts that take too long, builds that run everything on every change, ownership disputes in shared folders and tools that only work in one language.
If several of these appear, consider splitting the repository.
Monorepos and Git hosting
A monorepo is just a repository to GitHub, GitLab or Bitbucket. Any repository can hold many projects. Our comparison of GitHub vs GitLab vs Bitbucket covers hosting, and our Git tutorial covers the basics.
Frequently asked questions
What exactly is a monorepo?
A single repository that holds multiple distinct projects with well-defined relationships between them.
How does a monorepo differ from a monolith?
A monorepo is about repository layout. A monolith is about how an application is built and deployed.
What is monorepo vs polyrepo?
A monorepo keeps many projects in one repository. A polyrepo keeps one project in each repository.
Do monorepos work with microservices?
Yes. Several services can share one repository and still be deployed separately.
What are the best monorepo tools?
Workspaces for linking packages, then Turborepo, Nx, Lerna or Bazel for builds, depending on your needs.
What is a monorepo in GitHub?
A repository on GitHub that contains several projects. GitHub treats it like any other repository.
How do you migrate to a monorepo?
Start with two related projects, set up workspaces, import the old repository with its history, add caching and affected-only builds, and archive the old repositories.
Do monorepos slow Git down?
A very large repository can make clones and status checks slower. Google built custom tooling for its repository. For ordinary teams, shallow clones and affected-only builds keep things manageable.
Who reviews code in a monorepo?
A CODEOWNERS file can route each folder to the team responsible for it.
A quick checklist
Sources
- Turborepo: turbo run reference
- GitHub Docs: Workflow syntax for GitHub Actions
- GitHub Docs: About code owners
- Changesets
- Nx: Run affected tasks
- monorepo.tools: What is a monorepo
- Google Research: Why Google stores billions of lines of code in a single repository
- npm Docs: Workspaces
- pnpm: Workspaces
- Turborepo: Introduction
- Turborepo: Caching
- Nx: Intro
- Lerna: Introduction
- Bazel: Intro
From circuittimes.com/cloud-dev/monorepo-explained/
Was this guide helpful?
What went wrong?
Read nextComparison 13 min readGit Merge vs Rebase: When to Use Each, With Real ExamplesGit merge, rebase, fast-forward and squash compared with real command output, conflicts, force pushing and pull settings.
Comparison 11 min readJest vs Vitest: Which Test Runner to Use in 2026
Comparison 10 min readPlaywright vs Cypress: Which Testing Tool in 2026
Comparison 12 min readGitHub vs GitLab vs Bitbucket: Which to Choose in 2026
The briefing, without the noise.
The stories that matter in technology, every weekday morning.
Free. Unsubscribe in one click.







