Monorepo JS Tooling: Fundamentals for Workspaces and Task Runners
AI generated
JS
() =>
JavaScript · Monorepo · Build Tooling
Monorepo JS tooling: fundamentals for workspaces and task runners
from pnpm workspaces to the Nx project graph

Monorepo JS tooling solves a problem that becomes unavoidable as the number of packages in a project grows: shared dependencies, repeated build steps, and versioning across multiple packages. Workspaces in npm, pnpm and yarn lay the foundation, while Turborepo and Nx build on top of them as task runners and drastically cut build time through caching.

18 min read workspaces · Turborepo · Nx · Changesets Node.js 18+ · pnpm 8+

1. What a monorepo solves and when it pays off

A monorepo bundles several logically separate packages, say a frontend app, a backend API, and shared UI components, into a single Git repository. Monorepo JS tooling describes the entire toolset needed to run this structure productively: package management, task execution, caching and versioning across package boundaries. Without this tooling, a monorepo quickly becomes a burden, because every change in a core package would need to be manually propagated to every dependent package.

The main advantage of a monorepo lies in atomic changes: a change to a shared utility library and its consumers can be captured in a single commit and a single pull request, instead of being synchronized across multiple repositories with staggered version updates. For teams maintaining many closely related packages, say a design system with several frontend apps, monorepo JS tooling significantly reduces coordination overhead.

A monorepo does not automatically pay off for every project though. With few, completely independent packages sharing no code, the added tooling overhead often outweighs the benefit. Monorepo JS tooling delivers its value mainly where multiple packages are frequently changed together and consistent versioning across package boundaries matters, for example platform teams with several micro frontends or library monorepos with many small, related npm packages.

2. Workspaces: npm, pnpm and yarn compared

Workspaces are the foundation of any monorepo JS tooling setup: a declaration in the root package.json telling the package manager which subdirectories to treat as standalone packages. All three major package managers, npm, pnpm and yarn, now support workspaces natively, with notable differences in install speed, disk usage, and strictness around implicit dependencies.

npm workspaces, available since version 7, is the most widely used choice because no additional tool needs installing. yarn workspaces, with Yarn Berry (version 2+) and its Plug'n'Play mode, goes a step further and partly replaces node_modules with a zip based structure, which speeds up installs but can cause compatibility issues with tools that expect direct filesystem access to node_modules.


{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint"
  }
}

This basic declaration is nearly identical between npm and yarn, while pnpm instead uses a separate pnpm-workspace.yaml file, more on that in the next section. Important for all three variants: workspaces automatically hoist dependencies needed at the same version across multiple packages into a shared root node_modules directory, significantly cutting install time and disk usage compared to separate repositories.

3. pnpm workspace protocol and symlink strategy

pnpm solves the disk space problem of monorepo JS tooling more radically than npm or yarn: instead of copying packages per project, pnpm keeps a global content addressed store and links packages via hardlinks into a strict, nested node_modules layout. The result is not only significantly less disk usage across many packages with shared dependencies, but also stricter isolation: a package can only access dependencies it explicitly declares in its own package.json, systematically preventing so called phantom dependencies.

For references between workspace packages, pnpm uses the workspace: protocol, which makes explicit that a dependency comes from the local monorepo rather than needing resolution from the npm registry. That prevents a subtle problem: without this protocol, pnpm could theoretically pull an older, publicly published version of the same package name from the registry instead of using the local workspace version.


# pnpm-workspace.yaml — root of the monorepo
packages:
  - "apps/*"
  - "packages/*"
  - "!**/test/**"

{
  "name": "@myorg/web-app",
  "dependencies": {
    "@myorg/ui-components": "workspace:*",
    "@myorg/api-client": "workspace:^1.2.0"
  }
}

When publishing to npm, pnpm automatically replaces the workspace:* protocol with the actual version number current at publish time, so published packages never contain invalid protocol strings. This automatic transformation is one of the reasons pnpm has increasingly become the preferred choice for demanding monorepo JS tooling in many larger open source projects.

4. Task runner: Turborepo fundamentals and caching

Workspaces alone only solve package management, not efficiently running build, test and lint commands across many packages. This is exactly where Turborepo comes in as a task runner: it analyzes dependencies between packages via a turbo.json configuration and only reruns tasks whose input files have actually changed since the last run, otherwise it returns the cached result directly.

This caching is the central value of Turborepo in the monorepo JS tooling ecosystem: a build command that runs in five seconds locally or in CI because only one of twenty packages changed adds up to substantial time savings compared to a full rebuild of every package on every commit. Turborepo additionally supports remote caching, where cache results get shared between different CI runs and even between developer machines.


{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

The ^ prefix in dependsOn means Turborepo first runs the build task of all of a package's dependencies before building the package itself, exactly the order you would also enforce manually. This declarative dependency resolution is the core of what distinguishes monorepo JS tooling from a simple collection of npm scripts.

5. Nx: project graph and affected commands

Nx takes a similar approach to Turborepo but goes beyond pure task caching: Nx builds a complete project graph that captures not only explicit package.json dependencies but also actual code imports between packages. This graph makes Nx especially valuable in large monorepo JS tooling setups with many internal libraries, because it also detects hidden dependencies that are not declared in package.json at all.

The practical benefit shows up primarily in the affected command: instead of running tests or builds for the entire repository, Nx uses the git diff and the project graph to determine which packages a change actually affects, and only runs tasks for those. In a CI pipeline with a hundred packages, where a typical pull request only changes three, this drastically reduces execution time without reducing test coverage.


# Run tests only for packages affected by the current branch diff
npx nx affected --target=test --base=main

# Visualize the actual project dependency graph in the browser
npx nx graph

# Run a target for every project that depends on @myorg/ui-components
npx nx run-many --target=build --projects=tag:depends-on-ui

Nx additionally offers generators that produce new packages following a consistent template, including tests, lint configuration and build setup. For teams frequently creating new internal packages, that reduces boilerplate effort far more than Turborepo, which deliberately focuses on task execution and caching without bringing its own generators.

6. Versioning: Changesets for independent releases

Versioning is one of the most commonly underestimated problems in monorepo JS tooling: when twenty packages live in the same repository but need to be published to npm independently, you need a system that traceably decides which package gets which version number and which changelog entries belong to it. Changesets solves exactly this problem with a simple workflow: developers add a small markdown file for every relevant change, describing which packages are affected and whether it is a patch, minor or major update.

At release time, Changesets collects all pending changeset files, automatically computes the new version numbers following semantic versioning, updates changelogs, and even adjusts internal workspace references between dependent packages if an internal package depends on a new major version of another internal package.


# Add a changeset describing the current change
npx changeset
# Interactive prompt: which packages changed, patch/minor/major, summary text

# Consume all pending changesets: bump versions, update changelogs
npx changeset version

# Publish all packages whose version actually changed
npx changeset publish

The advantage over manual versioning is traceability: every version bump can be traced back to the original changeset files and thus to the associated pull requests. For monorepo JS tooling with publicly published packages, Changesets has become the de facto standard, because it integrates seamlessly into CI pipelines and automates release processes without manual intervention.

7. Shared configs: sharing eslint, tsconfig, tailwind

An often overlooked building block of monorepo JS tooling is managing shared configuration. Without a central strategy, the same ESLint configuration, the same base tsconfig.json, and the same Tailwind configuration end up as twenty slightly different copies that each need maintaining individually on every change. The usual solution: a dedicated internal package, say @myorg/tsconfig or @myorg/eslint-config, included as a workspace dependency in every other package.

TypeScript supports this explicitly through extends in tsconfig.json, so each package only needs to override the options that actually differ, while the shared base is maintained centrally. ESLint works analogously through the extends option in the flat config, and Tailwind allows sharing a base configuration via an exported preset that every frontend package imports into its own tailwind.config and extends with project specific customizations.

8. CI caching strategies for monorepos

In continuous integration, monorepo JS tooling delivers its biggest time savings once caching is configured correctly. The basic requirement is a persistent cache store between CI runs, both for installed dependencies and for task results from Turborepo or Nx. Without this cache, every CI run has to reinstall all dependencies and rebuild all packages, even when only a single line of code has changed.

Remote caching, offered by both Turborepo and Nx, goes a step further: task results are not just shared between consecutive runs of the same CI job, but between all jobs and even between local developer machines and CI. If a developer has already built and tested a package locally, the CI pipeline can pull that result directly from the shared cache without repeating the build step, as long as the relevant input files provably have not changed.

9. npm workspaces vs. pnpm vs. Turborepo vs. Nx

The following overview places the most important tools in the monorepo JS tooling ecosystem by their primary role, since many of these tools do not exclude each other but get used together.

Tool Primary role Strength Combines with
npm workspaces Package management No additional install needed Turborepo, Nx, Changesets
pnpm Package management Disk space, strict node_modules Turborepo, Nx, Changesets
Turborepo Task runner, caching Simple config, fast setup npm/pnpm/yarn workspaces
Nx Task runner, code generation Project graph, affected commands npm/pnpm/yarn workspaces
Changesets Versioning, release Traceable changelogs All of the above

In practice the combination is rarely binary: many teams use pnpm for package management, Turborepo or Nx for task execution and caching, and Changesets for versioning, all three layers of monorepo JS tooling complement rather than replace each other.

Mironsoft

Monorepo architecture and build pipeline optimization

CI run times that keep growing with every new package?

We set up pnpm workspaces, Turborepo or Nx caching, and an automated Changesets release workflow for your JavaScript monorepo, and measurably speed up your CI pipeline.

Monorepo setup

Setting up workspaces, task runner and shared configs from scratch

CI optimization

Remote caching and affected based pipelines for shorter run times

Release automation

Changesets workflow for traceable, independent package releases

10. Summary

Monorepo JS tooling consists of several interacting layers: workspaces in npm, pnpm or yarn lay the foundation for shared dependencies, task runners such as Turborepo and Nx use caching and dependency analysis to ensure only actually affected packages get rebuilt, and Changesets traceably solves the versioning question for independently published packages. pnpm is increasingly becoming the preferred package manager for demanding monorepo JS tooling thanks to strict dependency management and efficient disk usage.

Nx is particularly well suited to large setups with many internal libraries thanks to its project graph and code generators, while Turborepo scores with simpler configuration and a faster on ramp. Shared configs for ESLint, TypeScript and Tailwind, along with consistent CI caching, round out a productive monorepo JS tooling setup and prevent build times from growing linearly with the number of packages.

Monorepo JS tooling — the essentials at a glance

Workspaces

npm, pnpm or yarn declare subdirectories as packages and hoist shared dependencies into a common node_modules.

Task runner

Turborepo and Nx cache task results and only rerun packages whose input has actually changed.

Versioning

Changesets collects small markdown descriptions and automatically computes version numbers and changelogs from them.

CI caching

Remote caching shares task results between CI runs and developer machines for drastically shorter pipeline run times.

11. FAQ: monorepo JS tooling

1What does monorepo JS tooling mean?
The toolset of package management, task execution, caching and versioning for multiple packages in one repository.
2When does a monorepo pay off?
Once packages are frequently changed together and consistent versioning matters, less needed for fully independent projects.
3npm vs. pnpm vs. yarn workspaces?
npm needs no extra install, Yarn Berry uses Plug'n'Play, pnpm offers the strongest disk efficiency and strict node_modules.
4What does workspace: do in pnpm?
Marks a dependency as a local workspace package, automatically replaced with the real version at publish time.
5Turborepo and Nx together?
No, both solve the same basic task. Nx adds a project graph and generators, Turborepo offers simpler configuration.
6What does affected do in Nx?
Determines affected packages via git diff and project graph, running tasks only there instead of across the whole repository.
7How does Changesets work?
Small markdown files per change, from which version numbers and changelogs are computed automatically at release time.
8Sharing configs in a monorepo?
Via an internal package used through extends as a workspace dependency by every other package.
9What is remote caching?
A shared cache for task results across CI jobs and developer machines, not just within a single run.
10Addable to an existing monorepo?
Yes, both tools build on existing workspaces, no repository restructuring needed.