Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Running a Monorepo with Plain pnpm Scripts

Not every monorepo needs Turborepo or Nx. A repository with five to fifteen packages, a CI pipeline that finishes in a few minutes, and a small team can go a long way with pnpm alone: workspaces for linking, recursive runs for topological ordering, filters for targeting and changed-package selection, and a handful of root scripts. Adding a task runner later is easy; removing one you did not need is harder, because configuration, CI steps, cache infrastructure and team habits all grow around it. This guide shows how far plain pnpm scripts go, how to structure them, and the concrete signals that tell you it is time to add a task runner.

What pnpm already provides

pnpm covers most of what small monorepos need from a task runner:

pnpm alone versus pnpm plus a task runner Compares plain pnpm with pnpm plus Turborepo or Nx on ordering, parallelism, targeting, changed-package runs, caching and task-level dependencies. pnpm alone + Turborepo / Nx Topological ordering pnpm -r run per task Parallel execution --workspace-concurrency yes Target packages --filter --filter / projects Changed packages --filter ...[ref] affected, lockfile-aware Local + remote caching no yes Task depends on other task package-level only dependsOn per task
pnpm handles ordering, targeting and changed-package runs; caching and per-task graphs are what a task runner adds.

The recursive and filtering commands are covered in Running Scripts Across Workspaces with pnpm and pnpm Workspace Filtering. What pnpm does not have is caching and task-level dependencies — "run test after this package's build" rather than "run packages in dependency order". For a small repository, neither gap is usually painful: builds are quick enough to repeat, and a sensible order of root script steps covers the task-level sequencing.

A script layout that scales

Keep package scripts small and uniform, and orchestrate from the root:

// packages/ui/package.json
{
  "name": "@acme/ui",
  "scripts": {
    "build": "tsup",
    "test": "vitest run",
    "lint": "eslint src",
    "typecheck": "tsc --noEmit"
  }
}
// package.json (root)
{
  "private": true,
  "packageManager": "pnpm@9.15.4",
  "scripts": {
    "build": "pnpm -r run build",
    "test": "pnpm -r --parallel run test",
    "lint": "pnpm -r --parallel run lint",
    "typecheck": "pnpm -r --parallel run typecheck",
    "check": "pnpm run lint && pnpm run typecheck && pnpm run build && pnpm run test",
    "dev": "pnpm --filter \"./apps/*\" --parallel run dev",
    "affected:test": "pnpm --filter \"...[origin/main]\" run test"
  }
}

The conventions behind it:

  • Every package uses the same script names (build, test, lint, typecheck). Recursive runs skip packages without a script, so uniform names make the root commands predictable.
  • build is ordered (plain pnpm -r), because packages consume each other's output.
  • test, lint and typecheck run with --parallel when they do not depend on built output — for example, when tests resolve workspace packages from source, as described in Using Internal Packages Without a Build Step.
  • dev targets applications only and runs them in parallel, since dev servers never exit.
What pnpm -r run build does pnpm reads the workspace graph, sorts packages topologically, runs independent packages concurrently up to the concurrency limit, and stops on the first failure. read workspace graph workspace: dependencies topological sort tokens, utils, then ui, then apps run with concurrency --workspace-concurr ency=4 no cache unchanged packages rebuild
Recursive runs give you dependency order and parallelism, but every package runs every time.

CI with plain pnpm

A pull request pipeline that stays fast without caching by testing only what changed:

name: ci
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: .nvmrc, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm run lint
      - name: Build changed packages and their dependencies
        run: pnpm --filter "...[origin/${{ github.base_ref }}]..." run build
      - name: Test changed packages and dependents
        run: |
          if git diff --name-only "origin/${{ github.base_ref }}...HEAD" | grep -qvE '^(apps|packages)/'; then
            pnpm -r run test
          else
            pnpm --filter "...[origin/${{ github.base_ref }}]" run test
          fi

The guard runs everything when files outside packages change — the lockfile, root configuration — because changed-file filtering cannot attribute those to packages, as explained in Filtering Packages Changed Since a Git Ref. The pnpm store is cached by setup-node, which covers the slowest part of most small pipelines: installing.

Squeezing more speed out of plain scripts

Before reaching for a task runner, a few low-cost techniques recover much of the time caching would save.

Use each tool's own cache. TypeScript's incremental mode ("incremental": true or project references with tsc -b) writes .tsbuildinfo files and skips unchanged work; ESLint's --cache flag skips unchanged files; Prettier's --cache does the same. Persist those cache files in CI with the same cache action you use for the pnpm store, keyed on the lockfile plus a hash of source files, and warm runs get noticeably faster without any orchestration layer.

Type-check once, not per package. A root tsconfig.json with project references and tsc -b type-checks every package in dependency order, reusing work between them, which is usually faster than running tsc --noEmit in each package separately. The setup is covered in TypeScript Project References in Monorepos.

Lint from the root. One ESLint process over the whole repository with a flat config loads plugins once instead of once per package. For many repositories, eslint . at the root is faster than pnpm -r run lint.

Split slow jobs. If tests dominate, shard them across CI jobs, as described in Splitting Monorepo Tests into Parallel CI Shards, instead of caching them.

Keeping the root scripts honest

Root scripts are the interface developers use every day, so keep them few, named consistently and documented. Two small practices help. First, add a pnpm run check script that runs exactly what CI runs, in the same order, so "it passed locally" means something. Second, avoid clever shell inside package.json — long pipelines with &&, || and subshells are hard to read and behave differently on Windows. Move anything non-trivial into a script file under scripts/ written in JavaScript or TypeScript, which is testable, cross-platform and easy to port into task-runner configuration later.

When to add a task runner

Plain scripts stop being enough when the costs of not caching dominate. Concrete signals:

  • CI routinely exceeds ten minutes even with changed-package filtering, and most of that time rebuilds unchanged packages.
  • Developers wait for full builds after pulling main, because nothing is shared between machines.
  • You need task-level dependencies: test must wait for the same package's build, code generation must precede both, and expressing that in scripts produces long && chains.
  • Lockfile-only dependency updates are frequent enough that the "run everything" guard fires constantly.
Do you need a task runner yet? A decision chain based on CI duration, repeated local builds, task-level dependencies and package count. CI over ~10 min after filtering? Add a task runner remote cache pays off quickly yes Developers rebuild what CI built? Add a task runner shared cache between machines yes no Need task-to-task dependencies? Add a task runner dependsOn per task yes no Stay with pnpm scripts simpler, fewer moving parts no
Add a task runner when caching or task-level dependencies would save real time — not before.

When the time comes, both Turborepo and Nx adopt an existing pnpm workspace without restructuring, and your uniform script names become their task names directly — see Adding Nx to an Existing pnpm Workspace or Turborepo Pipeline Configuration.

Worked example: an eight-package repository

A team with three applications and five libraries runs pnpm -r scripts and a filtered CI pipeline. Median pull request CI time is four minutes, of which two are install and one is the filtered build. They evaluated Turborepo and estimated saving about ninety seconds per run with a remote cache — not enough to justify another tool, another configuration file and another thing to upgrade. They documented the thresholds above in the repository README; eighteen months and eight more packages later, CI crossed twelve minutes, and adding Turborepo took an afternoon because every package already used the same script names.

Common pitfalls

Mistake Impact Remediation
Running build with --parallel Dependents build before their dependencies Keep build ordered
Inconsistent script names Recursive runs silently skip packages Standardise names across packages
Shallow clones with changed filters Nothing selected, CI green with zero tests fetch-depth: 0
Long && chains for task dependencies Hard to maintain, no parallelism Split into steps, or adopt a task runner

Frequently Asked Questions

Is --parallel the same as the default concurrency? No. pnpm -r run respects topological order and runs up to --workspace-concurrency packages at once. --parallel ignores order entirely and runs every package's script immediately — right for independent tasks and dev servers, wrong for builds.

Can I get caching without a task runner? Individual tools have their own caches — TypeScript's .tsbuildinfo, ESLint's --cache, Vitest's changed-file mode — and they help. What you do not get is skipping whole tasks or sharing results between machines.

Does this approach work with npm or Yarn workspaces? Partly. Yarn Berry's workspaces foreach has equivalent ordering and parallelism flags; npm workspaces run in list order without topological sorting, which makes a task runner useful sooner.

How do I run one package and everything it needs? pnpm --filter "@acme/web..." run build builds the application and its workspace dependencies in order. The trailing ... selects dependencies; a leading ... selects dependents.

What about publishing without a task runner? Publishing is independent of task running. Changesets works with plain pnpm workspaces, and pnpm -r publish publishes every changed public package after a recursive build.

Can pnpm scripts run tasks across packages in a specific custom order? Only topological order or none. If you need a custom sequence — generate code in one package, then build three others, then run migrations — express it as explicit steps in a root script, or treat it as a sign that task-level dependencies from a task runner would help.

Related

Choosing a Monorepo Task Runner