DevToolBoxGRATIS
Blog

Panduan pnpm Workspace Monorepo

12 menitoleh DevToolBox

pnpm Workspace Monorepo Setup: The Complete Guide

pnpm workspaces provide the fastest, most disk-efficient way to manage a JavaScript/TypeScript monorepo. Unlike npm and yarn, pnpm uses a content-addressable store and hard links, meaning shared dependencies are stored only once on disk regardless of how many packages use them. This guide walks through setting up a production-ready pnpm monorepo from scratch, including shared configs, internal packages, and CI/CD pipelines.

Why pnpm for Monorepos?

Featurenpm workspacesyarn workspacespnpm workspaces
Disk usage (500 deps)~800 MB~700 MB~400 MB
Install speed (cold)~25s~18s~12s
Install speed (warm)~10s~6s~2s
Strict node_modulesNo (hoisted)Partial (PnP)Yes (isolated by default)
Phantom deps preventionNoWith PnPYes (strict by default)
Filtering commandsBasic (-w flag)Good (foreach)Excellent (--filter)
Content-addressable storeNoNoYes
Side effects cacheNoNoYes

Initial Setup

# Install pnpm globally
npm install -g pnpm@latest
# Or use corepack (Node.js 16+)
corepack enable
corepack prepare pnpm@latest --activate

# Create project directory
mkdir my-monorepo && cd my-monorepo

# Initialize root package.json
pnpm init

# Create the workspace configuration
touch pnpm-workspace.yaml

pnpm-workspace.yaml

# pnpm-workspace.yaml
packages:
  - 'apps/*'        # Applications (web, api, admin, etc.)
  - 'packages/*'    # Shared libraries and configs
  - 'tools/*'       # Build tools, scripts, generators

Root package.json

{
  "name": "my-monorepo",
  "private": true,
  "packageManager": "pnpm@9.5.0",
  "engines": {
    "node": ">=20.0.0",
    "pnpm": ">=9.0.0"
  },
  "scripts": {
    "dev": "pnpm -r --parallel run dev",
    "build": "pnpm -r run build",
    "test": "pnpm -r run test",
    "lint": "pnpm -r run lint",
    "type-check": "pnpm -r run type-check",
    "clean": "pnpm -r run clean && rm -rf node_modules",
    "format": "prettier --write '**/*.{ts,tsx,js,json,md,yaml}' --ignore-path .gitignore",
    "prepare": "husky"
  },
  "devDependencies": {
    "prettier": "^3.3.0",
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0",
    "typescript": "^5.5.0"
  },
  "lint-staged": {
    "*.{ts,tsx,js}": ["prettier --write", "eslint --fix"],
    "*.{json,md,yaml}": ["prettier --write"]
  }
}

.npmrc Configuration

# .npmrc — pnpm-specific settings

# Hoist only these packages to the root (needed for some tools)
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=*eslint*

# Strict mode — prevent importing undeclared dependencies
strict-peer-dependencies=false
auto-install-peers=true

# Performance
prefer-frozen-lockfile=true

# Save exact versions
save-exact=true

Project Structure

my-monorepo/
├── apps/
│   ├── web/                       # Next.js web application
│   │   ├── package.json           # name: "@acme/web"
│   │   ├── tsconfig.json
│   │   ├── next.config.ts
│   │   └── src/
│   ├── api/                       # Express/Fastify API server
│   │   ├── package.json           # name: "@acme/api"
│   │   ├── tsconfig.json
│   │   └── src/
│   └── admin/                     # Admin dashboard
│       ├── package.json           # name: "@acme/admin"
│       └── src/
├── packages/
│   ├── ui/                        # Shared React component library
│   │   ├── package.json           # name: "@acme/ui"
│   │   ├── tsconfig.json
│   │   └── src/
│   │       ├── components/
│   │       │   ├── Button.tsx
│   │       │   ├── Input.tsx
│   │       │   └── Modal.tsx
│   │       └── index.ts
│   ├── shared/                    # Shared types and utilities
│   │   ├── package.json           # name: "@acme/shared"
│   │   └── src/
│   │       ├── types.ts
│   │       ├── validators.ts
│   │       └── index.ts
│   ├── config-eslint/             # Shared ESLint config
│   │   ├── package.json           # name: "@acme/eslint-config"
│   │   └── index.js
│   └── config-typescript/         # Shared TypeScript configs
│       ├── package.json           # name: "@acme/typescript-config"
│       ├── base.json
│       ├── nextjs.json
│       └── node.json
├── pnpm-workspace.yaml
├── pnpm-lock.yaml
├── package.json
├── .npmrc
├── .gitignore
└── turbo.json                     # Optional: Turborepo for task caching

Creating Internal Packages

Shared UI Library

// packages/ui/package.json
{
  "name": "@acme/ui",
  "version": "0.0.1",
  "private": true,
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "import": "./src/index.ts"
    },
    "./components/*": {
      "types": "./src/components/*.tsx",
      "import": "./src/components/*.tsx"
    }
  },
  "scripts": {
    "lint": "eslint src/",
    "type-check": "tsc --noEmit"
  },
  "dependencies": {
    "clsx": "^2.1.0"
  },
  "devDependencies": {
    "@acme/typescript-config": "workspace:*",
    "@types/react": "^18.3.0",
    "typescript": "^5.5.0"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  }
}
// packages/ui/src/components/Button.tsx
import { type ButtonHTMLAttributes, forwardRef } from 'react';
import { clsx } from 'clsx';

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  isLoading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ variant = 'primary', size = 'md', isLoading, children, className, disabled, ...props }, ref) => {
    return (
      <button
        ref={ref}
        disabled={disabled || isLoading}
        className={clsx(
          'inline-flex items-center justify-center rounded-lg font-medium transition-colors',
          {
            'bg-blue-600 text-white hover:bg-blue-700': variant === 'primary',
            'bg-gray-100 text-gray-900 hover:bg-gray-200': variant === 'secondary',
            'bg-transparent hover:bg-gray-100': variant === 'ghost',
            'bg-red-600 text-white hover:bg-red-700': variant === 'danger',
            'px-3 py-1.5 text-sm': size === 'sm',
            'px-4 py-2 text-base': size === 'md',
            'px-6 py-3 text-lg': size === 'lg',
            'opacity-50 cursor-not-allowed': disabled || isLoading,
          },
          className
        )}
        {...props}
      >
        {isLoading ? 'Loading...' : children}
      </button>
    );
  }
);

Button.displayName = 'Button';

// packages/ui/src/index.ts
export { Button, type ButtonProps } from './components/Button';
export { Input, type InputProps } from './components/Input';
export { Modal, type ModalProps } from './components/Modal';

Shared TypeScript Config

// packages/config-typescript/base.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true
  },
  "exclude": ["node_modules", "dist", ".next"]
}

// packages/config-typescript/nextjs.json
{
  "extends": "./base.json",
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "ES2022"],
    "allowJs": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./src/*"] }
  }
}

Essential pnpm Commands

# Install all dependencies
pnpm install

# Add a dependency to a specific package
pnpm add zod --filter @acme/api
pnpm add -D vitest --filter @acme/shared

# Add a workspace dependency (internal package)
pnpm add @acme/shared --filter @acme/api --workspace
pnpm add @acme/ui --filter @acme/web --workspace

# Run a script in a specific package
pnpm --filter @acme/web dev
pnpm --filter @acme/api test

# Run across all packages
pnpm -r run build          # Run build in all packages
pnpm -r --parallel run dev # Run dev in parallel

# Filter by directory
pnpm --filter ./apps/* run build
pnpm --filter ./packages/* run type-check

# Filter by dependency graph
pnpm --filter @acme/web... run build    # web and all its deps
pnpm --filter ...@acme/shared run test  # shared and all dependents

# Filter by changed files (great for CI)
pnpm --filter "...[origin/main]" run test  # Test changed packages

# List all workspace packages
pnpm ls -r --depth -1

# Why is a package installed?
pnpm why react --filter @acme/web

# Update dependencies across workspace
pnpm -r update typescript
pnpm -r update --latest     # Update to latest major versions

Consuming Internal Packages

// apps/web/package.json
{
  "name": "@acme/web",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "type-check": "tsc --noEmit"
  },
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/shared": "workspace:*",
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@acme/eslint-config": "workspace:*",
    "@acme/typescript-config": "workspace:*",
    "@types/react": "^18.3.0",
    "typescript": "^5.5.0"
  }
}
// apps/web/src/app/page.tsx
import { Button } from '@acme/ui';
import { formatDate, type User } from '@acme/shared';

export default function Home() {
  return (
    <main>
      <h1>Welcome</h1>
      <Button variant="primary" size="lg">
        Get Started
      </Button>
    </main>
  );
}

CI/CD with pnpm

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Type check
        run: pnpm -r run type-check

      - name: Lint
        run: pnpm -r run lint

      - name: Test changed packages
        run: pnpm --filter "...[origin/main]" run test

      - name: Build
        run: pnpm -r run build

Integrating with Turborepo

// turbo.json — optional but recommended for caching
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "dev": {
      "dependsOn": ["^build"],
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": { "outputs": [] },
    "type-check": { "dependsOn": ["^build"], "outputs": [] }
  }
}
# Install Turborepo as a dev dependency
pnpm add -D turbo -w

# Use turbo instead of pnpm -r for task running
pnpm turbo build      # Builds with caching and correct order
pnpm turbo test       # Only re-tests changed packages
pnpm turbo dev        # Starts all dev servers in parallel

Best Practices

  • Use workspace:* protocol — always reference internal packages with workspace:*
  • Pin pnpm version — set packageManager in root package.json for reproducible installs
  • Enable strict mode — pnpm prevents phantom dependencies by default; do not disable this
  • Use --frozen-lockfile in CI — never let CI modify the lockfile
  • Scope dev dependencies — shared tools (prettier, husky) go in root; package-specific tools go in the package
  • Add Turborepo for caching — pnpm handles dependencies, Turborepo handles task orchestration
  • Use filter for CI — only build/test packages affected by the PR

Validate your package.json and turbo.json files with our JSON Formatter. For a broader look at monorepo tooling, read our Monorepo Guide 2026 covering Turborepo and Nx. For comparing pnpm with other package managers, see our npm vs yarn vs pnpm vs Bun comparison.

𝕏 Twitterin LinkedIn
Apakah ini membantu?

Tetap Update

Dapatkan tips dev mingguan dan tool baru.

Tanpa spam. Berhenti kapan saja.

Coba Alat Terkait

{ }JSON Formatter📋YAML Formatter

Artikel Terkait

Panduan Monorepo 2026: Turborepo vs Nx

Panduan lengkap untuk setup monorepo.

npm vs yarn vs pnpm vs bun: Package Manager Mana di 2026?

Bandingkan npm, yarn, pnpm, dan bun dengan benchmark nyata.

CI/CD Pipeline Best Practices: GitHub Actions, Testing & Deployment

Bangun pipeline CI/CD yang andal dengan GitHub Actions — strategi testing dan pola deployment.