pnpm Workspaces in Production: What Actually Matters

Published: (March 13, 2026 at 05:42 AM EDT)
6 min read
Source: Dev.to

Source: Dev.to

Four packages, all TypeScript, ~8 k lines total, published to npm.
Node 22, pnpm 9, no build tool beyond tsc.

I read a dozen “monorepo setup” articles – most spent 2 000 words comparing Turborepo, Nx, Lerna, and only a couple of paragraphs on the stuff that actually breaks on a random Tuesday afternoon.

Below is the Tuesday‑afternoon stuff.

Two lines of config

Workspace config (pnpm-workspace.yaml)

# pnpm-workspace.yaml
packages:
  - "packages/*"

That’s it – every directory under packages/ is a workspace package.

Root package.json (private, just orchestrates)

{
  "private": true,
  "scripts": {
    "build": "pnpm -r build",
    "dev": "pnpm -r --parallel dev",
    "test": "pnpm -r test",
    "clean": "pnpm -r --parallel exec rm -rf dist"
  },
  "engines": {
    "node": ">=22",
    "pnpm": ">=9"
  }
}
  • -r = run the script in every package.
  • --parallel on dev and clean because those don’t depend on each other.
  • build runs sequentially – one package imports from another, so it must be compiled first. pnpm automatically determines the correct dependency order, so the dependent package always builds after its dependency.

I spent zero time choosing between Turborepo and Nx. pnpm -r handles orchestration, tsc handles compilation. That’s all.

Shared tsconfig (the part that actually saves time)

Every monorepo article tells you to make a shared base tsconfig. They’re right, but few explain what belongs in the base vs. per‑package.

Base (tsconfig.base.json)

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": false,
    "sourceMap": false
  }
}

Per‑package (packages/*/tsconfig.json)

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*"]
}
  • rootDir and outDir are per‑package because they’re relative paths.
  • Everything else is shared.

Before this I had four slightly different tsconfigs and couldn’t remember which one had strictNullChecks. Now I change a setting once and it applies everywhere.

What about TypeScript Project References?

I don’t use them. composite: true gives you cross‑package type checking at build time, but you have to maintain a references array in every tsconfig, keep it in sync with the dependency graph, and deal with tsBuildInfo files that get stale and produce phantom errors.

Four packages, one internal dependency – I just build them in order. If I had ten packages or builds started taking minutes instead of seconds, I’d probably reconsider.

workspace:* vs. workspace:^

When one package depends on another in the monorepo:

{
  "dependencies": {
    "my-daemon": "workspace:*"
  }
}

During development this creates a symlink – a live, always‑current version of the dependency. No rebuild needed.

At publish time pnpm replaces workspace:* with the actual version number, e.g. "my-daemon": "0.2.7" in the published package.json.

The gotcha

I initially used workspace:^ (with a caret). When published, the dependency became "^0.2.7". Consumers could then install a mismatched minor version. For tightly‑coupled internal deps, use workspace:* for exact version pinning.

Phantom dependencies will find you

This one cost me a full afternoon. pnpm uses a strict node_modules structure by default: packages can only access dependencies they explicitly declare. Great—until you realize half your code was relying on phantom dependencies you didn’t know about.

Example:
Package A depends on fastify. Package B doesn’t declare it, yet B can import fastify because it’s hoisted in npm/yarn. With pnpm, B cannot import it, exposing the missing declaration.

I had a bug where a package imported a type from @types/ws without declaring it as a devDependency. It worked locally because another package had it, and VS Code resolved it through the workspace. After publishing, users hit:

error TS2307: Cannot find module 'ws' or its corresponding type declarations.

Fix

Run pnpm why in each workspace and ensure every import has a matching declaration:

$ cd packages/client && pnpm why @types/ws
# nothing? that's your bug
$ pnpm add -D @types/ws

Boring work, but it would have saved an embarrassing npm publish.

Vitest + workspaces

Vitest has a workspace feature. My root config just lists the packages:

// vitest.workspace.ts
import { defineWorkspace } from "vitest/config";

export default defineWorkspace([
  "packages/server",
  "packages/client",
  "packages/cli",
  "packages/core",
]);

Per‑package config

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    testTimeout: 10_000,
    restoreMocks: true,
  },
});

Running pnpm test from the root executes all suites.
Running pnpm --filter server test runs only the server tests.

Gotcha: if your tests import from sibling packages, make sure you’ve built the dependency first.

Vitest Doesn’t Trigger Builds

I ended up with a pretest script that runs pnpm -r build before the test suite.
That’s wasteful—it rebuilds everything even if nothing changed.

The alternative is forgetting to build and then spending 20 minutes debugging why the types don’t match.

Publishing to npm

No Changesets, no Lerna. I bump versions manually and run pnpm publish from each package directory.

The prepublishOnly Hook

{
  "scripts": {
    "prepublishOnly": "pnpm build"
  }
}

Without this, you will eventually publish stale dist/ files—or no dist/ at all. I discovered this on my second publish: npm info showed the dist/ folder was from three commits ago. I eventually realized I had simply forgotten to build before publishing.

Explicit files Field

{
  "files": ["dist", "README.md"],
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  }
}

I once published a package that accidentally included my src/ directory, test fixtures, and a 4 MB debug log. The files field is an allow‑list: only what you list gets published.

npm pack --dry-run is your friend—run it before every publish and actually read the output.

Stuff I Tried That Wasn’t Worth It

  • Turborepo – My full build takes under 30 seconds. Remote caching and smart task scheduling solve a problem I don’t have. pnpm -r build is the whole build system.
  • Separate ESLint‑config package – For four packages that’s a lot of ceremony. I keep the config at the root and reference it with a relative path.
  • “Shared utils” package – It only had three functions. That’s not a package, that’s a file. I deleted it and duplicated the two truly shared functions. Fewer abstractions, fewer symlink headaches.
  • Synchronized version numbers – My client library and server ship on different cadences. Forcing v0.3.1 on both would mean publishing no‑op releases just to keep numbers aligned.

Keep It Boring

After all of this, the thing I keep coming back to is: keep it boring.

  • pnpm-workspace.yaml should be two lines.
  • Your root package.json should have about five scripts.
  • If your monorepo setup needs a README to explain how it works, you’ve gone too far.

When something breaks, don’t reach for shamefully-hoist=true in .npmrc. Figure out why it broke. Nine times out of ten it’s a missing dependency declaration, and your users will hit the same thing when they install your package.

Put prepublishOnly hooks in every publishable package. I cannot stress this enough—future you will forget to build before publishing. It’s not a question of if.

Four packages, plain pnpm, no Turborepo, no Nx. If build times become a problem I’ll add something; they haven’t yet.

0 views
Back to Blog

Related posts

Read more »

slowly install for pulsar

package.json scripts and dependencies json { 'scripts': { 'postinstall': 'cd ./node_modules/.pnpm/pulsar-client@1.12.0/node_modules/pulsar-client && pnpm run i...