jsmanifest logojsmanifest

pnpm Workspaces: Managing Monorepos Made Easy

pnpm Workspaces: Managing Monorepos Made Easy

Discover how pnpm workspaces simplify monorepo management with efficient dependency handling, the workspace protocol, and practical examples for production-ready projects.

While I was looking over some monorepo setups the other day, I realized how much unnecessary complexity I had been dealing with for years. I was once guilty of fighting with npm workspaces, wrestling with hoisting issues, and watching my node_modules folder balloon to ridiculous sizes. Little did I know that pnpm workspaces would completely change how I think about managing multiple packages in a single repository.

Why pnpm Workspaces Are Transforming Monorepo Development

When I finally decided to migrate one of my larger projects to pnpm workspaces, I expected a painful transition. Instead, I discovered something fascinating: pnpm's approach to monorepos is fundamentally different from npm and yarn. It uses a content-addressable store that creates hard links instead of copying files everywhere. This means installing dependencies across ten packages feels almost identical to installing them for one.

The performance gains are wonderful, but what really sold me was how pnpm handles workspace dependencies. No more symlinking headaches or mysterious version conflicts. The workspace protocol just works, and it does so with a clarity that makes maintenance actually enjoyable.

Setting Up Your First pnpm Workspace

Let's look at how to create a workspace from scratch. Unlike npm and yarn which use the workspaces field in package.json, pnpm requires a dedicated configuration file. I came across this difference early on, and honestly, I prefer the explicit separation.

Here's your basic project structure:

// pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'

That's it. No complicated glob patterns to debug. This configuration tells pnpm to treat everything in packages/ and apps/ as workspace members. Now let's create a real workspace with two packages:

// packages/shared-utils/package.json
{
  "name": "@myorg/shared-utils",
  "version": "1.0.0",
  "main": "./src/index.ts",
  "scripts": {
    "build": "tsc",
    "test": "vitest"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "vitest": "^1.0.0"
  }
}
 
// packages/shared-utils/src/index.ts
export function formatCurrency(amount: number): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  }).format(amount);
}
 
export function validateEmail(email: string): boolean {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(email);
}

I cannot stress this enough: organizing your utilities into a shared package saves tremendous time down the road. When I finally decided to extract common functions into @myorg/shared-utils, I eliminated hundreds of lines of duplicate code across my applications.

pnpm workspace structure visualization

Understanding the Workspace Protocol for Local Dependencies

Here's where pnpm truly shines. Let's say you want your main application to use that shared-utils package. With npm, you'd deal with file paths or complicated linking. With pnpm, you use the workspace protocol:

// apps/web-app/package.json
{
  "name": "@myorg/web-app",
  "version": "1.0.0",
  "dependencies": {
    "@myorg/shared-utils": "workspace:*",
    "express": "^4.18.0"
  },
  "scripts": {
    "dev": "node server.js",
    "build": "tsc"
  }
}

The workspace:* protocol tells pnpm to always use the local version of the package. In other words, changes you make to shared-utils are immediately available to web-app without any rebuild or reinstall. This was a revelation when I realized it—no more npm link gymnastics.

You can also specify exact versions or ranges like workspace:^1.0.0, which is useful when you want stricter control over which versions your packages consume. When I finally decided to version my internal packages properly, this became invaluable for managing breaking changes.

Managing Cross-Package Dependencies with Real Examples

Let me show you a practical scenario I faced recently. I had an API package that needed to share types with both a web frontend and a mobile app. Here's how I structured it:

// packages/api-types/src/index.ts
export interface User {
  id: string;
  email: string;
  displayName: string;
  createdAt: Date;
}
 
export interface ApiResponse<T> {
  data: T;
  error?: string;
  timestamp: number;
}
 
export type CreateUserRequest = Omit<User, 'id' | 'createdAt'>;
 
// apps/api-server/src/routes/users.ts
import type { User, CreateUserRequest, ApiResponse } from '@myorg/api-types';
import { validateEmail } from '@myorg/shared-utils';
 
export async function createUser(req: CreateUserRequest): Promise<ApiResponse<User>> {
  if (!validateEmail(req.email)) {
    return {
      data: null,
      error: 'Invalid email format',
      timestamp: Date.now()
    };
  }
 
  // Create user logic here
  const user: User = {
    id: generateId(),
    email: req.email,
    displayName: req.displayName,
    createdAt: new Date()
  };
 
  return {
    data: user,
    timestamp: Date.now()
  };
}

Luckily we can reference both @myorg/api-types and @myorg/shared-utils using the workspace protocol. When I changed the User interface to add a new field, TypeScript immediately showed errors everywhere that interface was used—across all packages. This kind of tight integration is what makes monorepos so powerful.

pnpm Workspaces vs npm/yarn Workspaces: Key Differences

I was once guilty of assuming all workspace implementations were basically the same. They're not. Here are the differences that matter:

Installation Speed: pnpm is consistently 2-3x faster than npm workspaces in my testing. The content-addressable store means shared dependencies are truly shared, not duplicated with symlinks.

Disk Space: My typical monorepo with npm workspaces consumed about 2GB in node_modules across all packages. The same project with pnpm? About 600MB. The difference is staggering.

Configuration: npm and yarn use package.json for workspace configuration. pnpm uses pnpm-workspace.yaml. While this feels like extra ceremony initially, it keeps concerns separated cleanly.

Strictness: pnpm is stricter about peer dependencies and hoisting by default. I came across situations where code worked in npm workspaces but failed in pnpm because it was accidentally relying on phantom dependencies. This strictness is wonderful—it catches bugs before production.

comparison of monorepo tools

Running Scripts Across Multiple Packages Efficiently

One of my favorite pnpm features is the --filter flag. It lets you run commands across specific packages without custom scripting:

// Run tests only in packages that changed
pnpm --filter "./packages/*" test
 
// Build all apps
pnpm --filter "./apps/*" build
 
// Run dev server for specific package
pnpm --filter @myorg/web-app dev
 
// Run tests in packages that depend on shared-utils
pnpm --filter "...@myorg/shared-utils" test

That last example is particularly powerful. The ... prefix means "this package and everything that depends on it." When I finally decided to refactor shared-utils, I could instantly verify that all dependent packages still worked correctly.

You can also use recursive flags to run commands everywhere:

// Install dependencies across entire monorepo
pnpm install -r
 
// Run tests everywhere
pnpm -r test
 
// Build all packages in dependency order
pnpm -r build

The -r flag respects your package dependency graph. If web-app depends on shared-utils, pnpm builds shared-utils first automatically. No more manually ordering your build scripts.

Best Practices for Structuring Your Monorepo

After managing several pnpm monorepos, I've learned what works and what creates headaches:

Separate Apps from Packages: Keep your deployable applications in apps/ and reusable libraries in packages/. This distinction makes it clear what's meant to be published or deployed versus what's internal infrastructure.

Use Consistent Naming: Prefix all your packages with a scope like @myorg/. This prevents naming conflicts and makes it obvious which packages are internal to your monorepo.

Version Carefully: Internal packages don't always need version bumps. I was once guilty of incrementing versions obsessively. Now I only bump versions for packages I publish externally. Internal packages can stay at 1.0.0 indefinitely if you're using workspace:*.

Centralize Configuration: Create a @myorg/config package for shared ESLint, TypeScript, and Prettier configurations. Having one source of truth for tooling config eliminates drift between packages.

Test Holistically: Run integration tests that span multiple packages. Unit tests are great, but monorepos let you verify that packages actually work together before deployment.

Taking Your pnpm Monorepo to Production

When I finally decided to deploy my first pnpm monorepo to production, I discovered that the workspace protocol needs special handling in your build process. Here's the key insight: you need to resolve workspace dependencies before deploying.

For Docker deployments, use pnpm deploy which bundles a specific package with all its dependencies:

// Dockerfile for web-app
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN corepack enable pnpm
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @myorg/web-app build
 
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app /app
RUN pnpm deploy --filter @myorg/web-app --prod /deploy
WORKDIR /deploy
CMD ["node", "dist/server.js"]

The pnpm deploy command creates a clean directory with just the files needed to run that package in production. No workspace protocol references, no symlinks—just a standard node_modules structure.

For CI/CD, leverage pnpm's caching aggressively. The content-addressable store makes cache hits incredibly efficient:

// .github/workflows/ci.yml excerpt
- name: Setup pnpm
  uses: pnpm/action-setup@v2
  with:
    version: 8
 
- name: Get pnpm store directory
  id: pnpm-cache
  run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
 
- name: Setup pnpm cache
  uses: actions/cache@v3
  with:
    path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
    key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}

This caching setup reduced my CI build times from 8 minutes to under 2 minutes. The first run caches the entire pnpm store, and subsequent runs with unchanged dependencies barely need to download anything.

And that concludes the end of this post! I hope you found this valuable and look out for more in the future!