What a monorepo gives a small team (and what it doesn't)

A monorepo is a single version-controlled repository that holds several packages or applications, such as an API service, a worker, and a shared library of types and clients. For a small team, the value is less about scale and more about reducing coordination overhead.

The first benefit is atomic cross-package changes. If you rename a field in a shared schema, you can update the library and every consumer in one commit and one review. Nobody has to publish an intermediate version or sequence pull requests across repositories.

The second is one place for tooling config. Linting, formatting, TypeScript base settings and CI workflows live at the root and are inherited, so they don't drift between repositories.

The third is simpler dependency alignment. With one lockfile, it is easier to see and fix cases where two apps pin different versions of the same framework.

None of this requires build orchestration. Running tasks in dependency order and caching their outputs is a separate concern, covered later.

A monorepo also leaves some problems unsolved. Deployment, release and versioning remain separate pipelines per app. If you publish packages to an external registry, you still need a versioning and changelog process.

The main cost is a larger blast radius. A broken shared package or a bad root config change can block every app at once. Treat shared code like a public API: require review, keep it covered by tests, and run those tests on every change.

Who should use this setup

This setup fits a small team that owns several JavaScript or TypeScript deployables sharing code. Typical examples are an API, a background worker and a web frontend that share types, validation schemas or API clients. The strongest signal is frequent cross-cutting changes. If a typical feature touches a shared package and two consumers, separate repositories add publish-and-bump overhead to every one of those changes.

It also suits teams that want one set of lint, format and TypeScript rules across services, and teams where everyone works on most of the code rather than owning isolated corners of it.

It is a weaker fit in these cases:

  • One application with no shared libraries. A single package.json gives you nothing to coordinate.
  • Code with genuinely independent owners and release cadences. For example, a library published for outside consumers, with its own versioning promises, may be easier to manage in its own repository.
  • Mostly non-JavaScript codebases. pnpm, Turborepo and Nx are built around package.json scripts and the npm ecosystem. Nx has plugins for other languages, but a polyglot repo raises questions this article doesn't cover.
  • Teams without anyone willing to own root-level tooling. A shared config that nobody maintains turns into a shared bottleneck.

Within the target group, the choice of tool follows the team's needs. Plain pnpm workspaces suit most small teams at the start. Turborepo suits teams whose CI time has become a real problem. Nx suits teams that also want generators and enforced boundaries and have someone to own its configuration.

How the pieces fit together

The setup works in three layers, and each one can be adopted without the next.

  1. The package manager (pnpm workspaces). pnpm reads pnpm-workspace.yaml to find which directories are packages. It installs all external dependencies into one content-addressable store and resolves them into one root lockfile. It links internal packages to each other through the workspace: protocol, so an app imports the local source of a shared library instead of a published copy. This layer alone gives you atomic changes, one lockfile and strict dependency isolation.
  2. Scripts and filters (still pnpm). Each package defines its own build, test and lint scripts in package.json. pnpm can run a script across every package in dependency order (pnpm -r run build) or across a selected subset (--filter), including packages changed since a git ref. This is enough orchestration for a small repo, but nothing is cached.
  3. The task runner (Turborepo or Nx, optional). A task runner reads the same package graph and adds a task graph on top. That graph records, for example, that test in an app requires build in its dependencies first. Before running a task, the runner hashes the declared inputs: source files, config, relevant environment variables and the hashes of upstream tasks. If a stored result exists for that hash, it restores the outputs and logs instead of running the task. Otherwise it runs the task and stores the result locally, or in a remote cache shared across machines.

The rest of this article walks through these layers in order. It explains how pnpm workspaces behave, then how far pnpm scripts get you, then what a task runner adds and when it is worth the configuration.

How pnpm workspaces work

pnpm treats the repository as a set of packages it discovers from one file at the root, pnpm-workspace.yaml. A minimal version contains a packages key listing globs such as apps/* and packages/*. Every directory matching a glob that contains a package.json becomes a workspace member, so adding a new service or library is just creating a folder.

Internal packages depend on each other through the workspace: protocol. An app that consumes a shared library declares something like "@acme/config": "workspace:*" in its dependencies. That tells pnpm to link the local copy rather than look for it on a registry. A typo or a missing package fails at install time instead of silently pulling a same-named package from the network. If you publish, pnpm rewrites the protocol into a real version range.

Installs produce a single lockfile at the root. One pnpm-lock.yaml means one place to review dependency changes and one resolution for the whole repo.

The less obvious part is the layout of node_modules. pnpm keeps package contents in a content-addressable store and links them into a hidden .pnpm directory. Each package's own node_modules exposes only symlinks to what it declared. Code that imports a package it never listed, which only worked because npm hoisted it, now fails to resolve. These are phantom dependencies, and surfacing them is the point.

The cost is that some tools assume a flat, hoisted tree and break. The usual fixes live in .npmrc. You can hoist specific packages with public-hoist-pattern, or fall back to node-linker=hoisted when a tool cannot cope. Prefer the narrow fix, because each hoisting exception reintroduces some of the looseness you adopted pnpm to remove.

cat > pnpm-workspace.yaml <<'EOF'
packages:
  - "apps/*"
  - "packages/*"
EOF

cat > apps/web/package.json <<'EOF'
{
  "name": "@acme/web",
  "private": true,
  "dependencies": {
    "@acme/config": "workspace:*"
  }
}
EOF

cat > .npmrc <<'EOF'
public-hoist-pattern[]=*eslint*
EOF

pnpm install

A minimal pnpm workspace with three files: the workspace glob file, an app that links an internal package through the workspace: protocol, and a narrow .npmrc hoisting exception for tools that need a flat node_modules.

Structuring the repo and running tasks with pnpm alone

A layout that holds up is two top-level directories: apps/ for deployable things (services, workers, CLIs) and packages/ for libraries they consume. The split matters because it encodes a direction of dependency. Apps depend on packages, packages may depend on other packages, and nothing depends on an app. List both globs in pnpm-workspace.yaml and pnpm treats every matching directory with a package.json as a workspace member.

Put a tsconfig.base.json at the root with your compiler settings, and have each package extend it, overriding only paths and output directories. Install TypeScript, ESLint, Prettier and your test runner once, as root dev dependencies, so every package compiles and lints against the same versions and upgrades happen in one commit.

For boundaries, internal packages reference each other with the workspace:* protocol, expose a deliberate entry point through the exports field, and never import from another package's src/ by relative path. If you skip that last rule, pnpm's strict linking stops protecting you.

pnpm -r run build runs the script in every package that defines it, in topological order. --filter api... builds api plus everything it depends on. --filter ...shared targets shared and its dependents.

This approach stops being enough in three places. There is no caching, so unchanged packages rebuild every time. Ordering covers a single script, but you cannot declare that test needs dependencies built first. And CI rebuilds everything on every push unless you hand-roll change detection.

cat > pnpm-workspace.yaml <<'EOF'
packages:
  - "apps/*"
  - "packages/*"
EOF

pnpm add -D -w typescript eslint prettier vitest

cat > packages/shared/tsconfig.json <<'EOF'
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "rootDir": "src", "outDir": "dist" },
  "include": ["src"]
}
EOF

pnpm --filter api add @acme/shared@workspace:*

pnpm -r run build

pnpm --filter "api..." run build

pnpm --filter "...shared" run build

pnpm --filter "...[origin/main]" run build

Sets up a pnpm workspace with root-level tooling and a shared TypeScript base config, then runs recursive and filtered builds, including the manual change-detection filter that pnpm alone falls back on without a cache.

What Turborepo and Nx add on top

Both tools sit on top of pnpm workspaces rather than replacing them. pnpm still installs and links packages, and the task runner decides what to run, in what order, and what it can skip.

Turborepo

Turborepo adds a task pipeline in turbo.json. You can declare that test depends on build in upstream packages, and it schedules the work in dependency order, running in parallel where it can. Results are cached locally, with optional remote caching, and keyed on a hash of declared inputs. Your existing package.json scripts remain the unit of work, so adoption is mostly configuration.

Nx

Nx builds a project graph and uses it to find affected projects, meaning only what a change touches. It also adds generators for scaffolding, plugins that infer tasks from tool configs, and module boundary rules that stop, for example, a UI package from importing server code. The tradeoff is a larger set of concepts to learn and more coupling to Nx conventions.

The caveat both share

A cache is only as correct as its keys. If a task reads an undeclared file or environment variable, or writes outputs you didn't list, the tool can restore a stale result and CI passes on code it never ran. Remote caching adds further questions. It is another dependency, someone has to control who can write to it, and every machine that reads it trusts artifacts built by every other machine that can write to it.

Plain pnpm Turborepo Nx
Cross-package task ordering Limited Yes Yes
Caching None Local, optional remote Local, optional remote
Affected detection Filters only Via filters Built in
Generators and boundary rules No No Yes
Concepts to learn Few Moderate Many
// turbo.json at the repo root (Turborepo 2.x uses "tasks"; 1.x used "pipeline")
{
  // Env vars that change the hash of every task
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      // "^" means: run build in upstream workspace dependencies first
      "dependsOn": ["^build"],
      // Only these files affect the cache key; an undeclared input means stale hits
      "inputs": ["src/**", "package.json", "tsconfig.json"],
      // What gets saved and restored on a cache hit; omit a dir and it won't come back
      "outputs": ["dist/**"],
      // Baked into the bundle at build time, so it must be part of the key
      "env": ["API_BASE_URL"]
    },
    "test": {
      // Tests import the built output of upstream packages
      "dependsOn": ["^build"],
      "inputs": ["src/**", "test/**", "package.json"],
      // Nothing to restore, but logs are still replayed on a hit
      "outputs": []
    },
    "lint": {},
    "dev": {
      // Long-running watcher: never cache it
      "cache": false,
      "persistent": true
    }
  }
}

A turbo.json that orders tasks by workspace dependencies and declares the inputs, outputs and environment variables forming each cache key, which is what keeps cache hits correct.

When to add a task runner and when to avoid one

Start from plain pnpm workspaces and wait for a measurable problem before adding anything. A task runner trades build minutes for configuration: pipeline definitions, cache inputs and outputs, and environment variables that must be declared so cache keys stay correct. If those declarations drift from reality, the cache serves stale artifacts. That failure is harder to diagnose than a slow build.

Signal Stay on plain workspaces Add Turborepo or Nx
Package count A handful, mostly apps Many, with shared internal libraries
Dependency graph Shallow, few cross-package imports Deep chains where one change touches many consumers
CI duration Short enough that nobody waits on it Long enough that engineers batch changes to avoid it
Rebuild pattern Unchanged packages rarely rebuild CI repeatedly rebuilds and retests code nobody touched
Tooling ownership One person understands the build Two or more people can debug cache misses and pipeline config

The last row carries the most weight. On a small team, build knowledge usually sits with one engineer. If that person leaves or moves to other work, a misconfigured cache becomes a problem nobody can fix quickly. Plain workspaces with recursive scripts and filters are easy for any engineer to read.

If the signals do point to a runner, choose the smaller step first. Turborepo adds caching and task ordering with little new vocabulary. Nx makes sense when you also want generators and enforced module boundaries, and when someone is willing to learn and maintain its model.

What are the alternatives?

pnpm, Turborepo and Nx are not the only ways to share code across a small team's services. These are the main alternatives and the tradeoffs each one makes.

npm or Yarn workspaces instead of pnpm

Both npm and Yarn support workspaces declared in the root package.json. They link internal packages, use one root lockfile, and can run a script across workspaces. The practical difference is the node_modules layout. Their default layout is hoisted, so phantom dependencies keep working silently instead of failing. pnpm's workspace: protocol is also stricter about never resolving an internal name from the registry. If your team already uses npm or Yarn and the tooling works, switching package managers is not required to get a monorepo. Turborepo and Nx work on top of these workspaces as well.

Separate repositories with published packages

The polyrepo approach keeps each service in its own repository and publishes shared code to a registry, often a private one. Each repo has its own CI, permissions and release cadence, and a broken shared package can't block unrelated services until they choose to upgrade. The cost is the coordination this article started with. A change to a shared library requires publishing a version and then bumping it in every consumer. Consumers also drift onto different versions. This approach suits teams whose services change independently and rarely touch shared code at the same time.

Git submodules or vendored copies

Some teams include shared code as a git submodule or copy it into each repository. Submodules avoid a registry but add extra steps to cloning, branching and updating, and those steps are easy to get wrong. Vendored copies are the simplest option and the quickest to diverge. Both are workable for a small amount of rarely changing code, but neither gives you atomic cross-package changes.

Plain pnpm with hand-written scripts, indefinitely

Staying on plain pnpm workspaces and never adding a task runner is a legitimate end state, not just a starting point. Filters, a cached pnpm store in CI and a nightly full build cover many small repositories for as long as builds stay fast.

Impact on cost and engineering time

Whichever tool you pick, the main cost is CI minutes and engineers waiting on builds. Before adding caching, check whether it pays for its own upkeep.

Illustrative estimate (assumed numbers, not a benchmark). Assume 20 packages, 3 minutes to build and test each one serially, and 85% of packages unchanged on a typical commit.

  • Full build: N × t = 20 × 3 = 60 minutes
  • With caching: N × t × (1 − u) + overhead = 20 × 3 × 0.15 + 2 = 11 minutes

Here u is the unchanged fraction, and the overhead covers restoring and uploading cache artifacts. If your packages build in seconds, or most commits touch shared code that everything depends on, u drops and the savings shrink.

Set those savings against the ongoing costs: pipeline config that someone has to understand, a remote cache service to secure and pay for, and plugins that break on upgrades. A small team often has nobody whose job is to own these.

Setup CI time per commit Maintenance burden
pnpm, build everything Grows with every package Lowest
pnpm, cached store, filtered runs Moderate Low
Task runner with remote cache Lowest when u is high Highest

CI basics apply in every case:

  • Cache the pnpm store so installs don't re-download dependencies on every run.
  • Build only affected packages when your tooling can compute them from the dependency graph.
  • Keep a full-build path, run nightly or before releases, to catch stale cache entries and dependency edges the graph missed.

Without affected-only builds, a monorepo rebuilds everything on every commit. That can make CI slower than the separate repos it replaced, which only built the code that changed.

name: ci
on:
  pull_request:
  schedule:
    - cron: '0 3 * * *'   # nightly full build to catch stale or missed graph edges

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0       # full history so pnpm can diff against origin/main

      - uses: pnpm/action-setup@v4   # pnpm version comes from "packageManager" in package.json

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm          # caches the pnpm store between runs

      - run: pnpm install --frozen-lockfile

      # PRs: build and test only packages changed since main, plus their dependents.
      # A dependent may also need unchanged dependencies built first. If so,
      # build those too, or switch to a task runner that handles this.
      - name: Affected only
        if: github.event_name == 'pull_request'
        run: |
          pnpm --filter "...[origin/main]" run build
          pnpm --filter "...[origin/main]" run test

      # Nightly: build and test everything, ignoring the change filter.
      - name: Full build
        if: github.event_name == 'schedule'
        run: |
          pnpm -r run build
          pnpm -r run test

A GitHub Actions workflow that caches the pnpm store, builds and tests only packages affected since main on pull requests, and keeps a nightly full build for verification.

Migrating incrementally into a monorepo

Start with two repositories, not all of them. Pick a shared library and one service that consumes it. That pair tests the hardest part, replacing a published package with a workspace link, at the smallest scale.

Move one repo at a time

Bring each repo into its own directory under the workspace and get it building and passing tests before you move the next one. If something breaks, you know which import caused it. Keeping history is a team decision. If people use blame and bisect on that code, rewrite the history into a subdirectory before merging it. If they don't, copy the files in and archive the old repo as read-only for reference. Rewriting history takes real effort and is easy to get wrong, so do it only when someone will use the result.

Stabilise before you orchestrate

Before adding a task runner, align shared dependencies on one version and commit a single lockfile. Make sure a clean install works on a fresh machine. Caching and affected-only builds rely on a correct dependency graph. If the graph is unstable, the task runner just makes its errors harder to see.

Keep release paths separate

The main risk is coupling. Two services that used to deploy independently can now break each other through a shared package change. Give each service its own CI job and deploy job, triggered only by changes to its directory or its dependencies, and keep its own version and changelog. A shared repository should not force a shared release.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to javascript monorepo small team, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.