I've been running a pnpm workspaces monorepo in production. Four packages, all TypeScript, about 8k lines total, published to npm. Node 22, pnpm 9, no build tool beyond tsc. Before I started I read a dozen "monorepo setup" articles and most of them were 2000 words on Turborepo vs Nx vs Lerna, and maybe two paragraphs on the stuff that actually breaks on a random Tuesday afternoon.
This is the Tuesday afternoon stuff.
Two lines of config
My workspace config is two lines:
# pnpm-workspace.yaml
packages:
- "packages/*"
That's it. Every directory under packages/ is a workspace package. The root package.json is private and 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 means "run in every package." --parallel on dev and clean because those don't depend on each other. But build runs sequentially, because one of my packages imports from another and needs it compiled first. pnpm figures out the dependency order on its own, 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 it.
Shared tsconfig (the part that actually saves time)
Every monorepo article tells you to make a shared base tsconfig. They're right. But nobody tells you which settings go in the base vs per-package.
My base:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": false,
"sourceMap": false
}
}
Each package extends it:
{
"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 them in sync with your 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. Live, always-current version of the dependency. No rebuild needed.
At publish time, pnpm replaces workspace:* with the actual version number. So "my-daemon": "workspace:*" becomes "my-daemon": "0.2.7" in the published package.json.
Here's where it bit me: I initially had workspace:^ (with a caret). Published, and the dependency became "^0.2.7". Consumers could install mismatched minor versions. 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. Which is great, until you realize half your code was relying on phantom dependencies and you didn't know it.
If package A depends on fastify and package B doesn't declare it, B can't import fastify even though it's installed in the monorepo. With npm or yarn, hoisting would let B "accidentally" use A's fastify. You'd never notice until you publish and a user's install fails.
I had this exact bug. One of my packages was importing a type from @types/ws without declaring it as a devDependency. Worked fine locally because another package had it, and VS Code happily resolved it through the workspace. Published to npm, got an issue within two days:
error TS2307: Cannot find module 'ws' or its corresponding type declarations.
Felt pretty dumb.
The fix: run pnpm why in each workspace, check that 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 me 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,
},
});
pnpm test from root runs all suites. pnpm --filter server test runs just one.
One 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. Wasteful, rebuilds everything even if nothing changed. But 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 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 did this on my second publish. Ran npm info on the package, stared at the file list, wondered why dist/ was from three commits ago. Took me way too long to realize I just forgot to build before publishing.
Also, 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 4MB debug log. The files field is an allowlist. Only what you list gets published. npm pack --dry-run is your friend here, run it once before every publish and actually read the output.
Stuff I tried that wasn't worth it
I tried Turborepo for about a day. My full build takes under 30 seconds. Remote caching and smart task scheduling are solving a problem I don't have. pnpm -r build is the whole build system.
I also see monorepos with a packages/eslint-config internal package. For four packages that's a lot of ceremony for an eslint config. Mine lives at the root, each package references it with a relative path. Done.
Early on I extracted a "shared utils" package. It had three functions in it. That's not a package, that's a file. I deleted it and just duplicated the two functions that were actually shared. Less abstraction, fewer symlink headaches.
And I gave up on synchronized version numbers pretty quickly. My client library and server ship at 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 like 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.
And 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.
Top comments (0)