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:
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. buildis ordered (plainpnpm -r), because packages consume each other's output.test,lintandtypecheckrun with--parallelwhen 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.devtargets applications only and runs them in parallel, since dev servers never exit.
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:
testmust wait for the same package'sbuild, 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.
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 compares the options when you outgrow plain scripts.
- Running Workspace Scripts in Topological Order explains the ordering pnpm provides.
- Filtering Packages Changed Since a Git Ref makes CI fast without caching.
- Caching the pnpm Store in GitHub Actions speeds up the install step.