Monorepos with pnpm and changesets
pnpmchangesets

Monorepos with pnpm and changesets


I usually end up with one GitHub repo per npm package or app, but there are cases where a monorepo pays off — mostly when you want to share code between projects without the overhead of publishing and bumping an npm package every time.

pnpm workspaces are my go-to for this. They're small enough to fit in your head, and for smaller projects that's all you need. The whole workspace config fits in one file.

Setting up pnpm workspaces

This post walks through it step by step for Tailwind and TypeScript. Short version:

Add pnpm-workspace.yaml in the repo root:

packages:
  - "apps/*"
  - "packages/*"

Add a root tsconfig.json that the per-package configs will extend:

{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "module": "ESNext",
    "skipLibCheck": true,
    "noImplicitAny": false,
    "allowJs": true,
    "noErrorTruncation": true,
 
    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
 
    /* Linting */
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
 
    "declaration": true,
    "composite": true,
    "sourceMap": true,
    "declarationMap": true
  }
}

And a tsconfig.node.json:

{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true
  }
}

Create your first package inside apps/ or packages/:

pnpm create vite frontend --template react-ts

Update the generated tsconfig.json to extend the root one:

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "baseUrl": ".",
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

And the matching tsconfig.node.json:

{
  "extends": "../../tsconfig.node.json",
  "include": ["vite.config.ts"]
}

Add a shortcut script in the root package.json so you can run per-package commands without cd-ing in:

"scripts": {
  "frontend": "pnpm --filter frontend"
}

Now pnpm frontend dev from the root starts the Vite dev server. That's pretty much it.

Changesets

Once you've made changes, pnpm changeset walks you through what changed: which packages are affected, whether it's major, minor or patch, and a changelog message. The result is a small markdown file committed to the repo.

When you're ready to ship, pnpm changeset version consumes those files, bumps the versions in the relevant package.jsons and updates the CHANGELOG.mds. Commit those changes, then pnpm publish -r publishes every package that has a new version.

Other monorepo options

The other popular option is NX. It takes a pretty different angle: not JavaScript-specific, and it encourages pinning the same dependency versions across all projects. Keeping versions aligned makes upgrades cleaner in theory — in practice I've seen it go the other way, because any dependency bump touches everything, and nobody wants to be the one who broke five projects at once. I'd rather stagger upgrades with something like Renovate and let each package move at its own pace.