We’ve previously covered creating monorepos with various package managers. In this guide we go deeper with a pnpm‑first approach.

Understanding Monorepos

A monorepo stores multiple related projects in a single repository. This works well when apps share dependencies, utilities, or release cadence.

Benefits of Monorepos

  • Unified tooling and configuration
  • Simplified dependencies and version sync
  • Code sharing and reuse
  • Streamlined CI/CD with atomic commits
  • Better collaboration with shared standards
  • Easier refactoring across packages

Common Use Cases

  • Component libraries (design systems)
  • Microservices
  • Full‑stack apps with shared utils
  • Plugin ecosystems
  • Multi‑platform apps

Why Choose pnpm Over Other Tools

pnpm vs npm/yarn

Installation speed (illustrative):

ToolInstall
npm~45s
yarn~35s
pnpm~22s

Disk space usage (illustrative):

ToolSpace
npm~130 MB per project
yarn~125 MB per project
pnpm~85 MB total (shared)

Key Advantages

  • Disk Space Efficiency: content‑addressable storage with symlinks.
  • Built‑in Workspaces: no extra tool needed for monorepos.
  • Strict node_modules: prevents phantom dependencies.
  • Performance: parallel install/link improves speed.
  • Active Maintenance: regular updates and strong community.

Why Not Lerna?

  • Maintenance uncertainty
  • Slower installs vs pnpm
  • pnpm has native monorepo support
  • Modern storage and linking model

Project Setup and Configuration

Prerequisites

bash
node --version
npm install -g pnpm
pnpm --version

Initial Structure

bash
mkdir awesome-monorepo
cd awesome-monorepo
pnpm init

Root Package Configuration

json
{
  "name": "awesome-monorepo",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "preinstall": "npx only-allow pnpm",
    "build": "pnpm --filter=@awesome/* run build",
    "test": "pnpm --filter=@awesome/* run test",
    "lint": "pnpm --filter=@awesome/* run lint",
    "clean": "rimraf 'packages/*/{dist,node_modules}' && rimraf node_modules",
    "changeset": "changeset",
    "version-packages": "changeset version",
    "release": "pnpm build && pnpm changeset publish"
  },
  "devDependencies": {
    "@changesets/cli": "^2.26.0",
    "rimraf": "^4.1.2",
    "typescript": "^4.9.4",
    "only-allow": "^1.1.1"
  }
}

Workspace Configuration

Create pnpm-workspace.yaml in the root directory:

yaml
packages:
  - 'packages/*'
  - 'apps/*'
  - 'tools/*'

Directory Structure

plaintext
awesome-monorepo/
├── .changeset/
│   └── config.json
├── apps/
│   └── web-app/
├── packages/
│   ├── ui-components/
│   ├── utils/
│   └── api-client/
├── tools/
│   └── build-scripts/
├── package.json
├── pnpm-workspace.yaml
├── pnpm-lock.yaml
└── README.md

Creating Packages

Package 1: UI Components Library

bash
mkdir -p packages/ui-components
cd packages/ui-components
pnpm init

packages/ui-components/package.json:

json
{
  "name": "@awesome/ui-components",
  "version": "1.0.0",
  "description": "Reusable UI components library",
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "rollup -c",
    "dev": "rollup -c -w",
    "test": "jest",
    "lint": "eslint src/**/*.{ts,tsx}"
  },
  "peerDependencies": {
    "react": ">=16.8.0",
    "react-dom": ">=16.8.0"
  },
  "devDependencies": {
    "@types/react": "^18.0.0",
    "rollup": "^3.15.0",
    "typescript": "^4.9.4"
  }
}

packages/ui-components/src/Button/index.tsx:

tsx
import React from 'react';
import { ButtonProps } from '../types';

export const Button: React.FC<ButtonProps> = ({
  children,
  variant = 'primary',
  size = 'medium',
  onClick,
  disabled = false,
  ...props
}) => {
  const base = 'px-4 py-2 rounded font-medium transition-colors';
  const variants = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
    danger: 'bg-red-600 text-white hover:bg-red-700'
  } as const;
  const sizes = {
    small: 'px-2 py-1 text-sm',
    medium: 'px-4 py-2',
    large: 'px-6 py-3 text-lg'
  } as const;

  return (
    <button
      className={`${base} ${variants[variant]} ${sizes[size]}`}
      onClick={onClick}
      disabled={disabled}
      {...props}
    >
      {children}
    </button>
  );
};

Package 2: Utility Functions

ts
// String utils
export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);

export const slugify = (s: string) =>
  s.toLowerCase()
   .replace(/[^\w\s-]/g, '')
   .replace(/[\s_-]+/g, '-')
   .replace(/^-+|-+$/g, '');

// Array utils
export const chunk = <T,>(a: T[], size: number): T[][] => {
  const out: T[][] = [];
  for (let i = 0; i < a.length; i += size) out.push(a.slice(i, i + size));
  return out;
};

export const unique = <T,>(a: T[]) => [...new Set(a)];

// Basic validators
export const isEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
export const isURL = (u: string) => { try { new URL(u); return true; } catch { return false; } };

Package 3: API Client

ts
export interface RequestConfig {
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
  headers?: Record<string, string>;
  body?: unknown;
  timeout?: number;
}

export interface APIResponse<T = unknown> {
  data: T;
  status: number;
  statusText: string;
  headers: Record<string, string>;
}

export class APIClient {
  constructor(private baseURL: string) {
    if (!/^https?:\/\//.test(baseURL)) throw new Error('Invalid base URL');
    this.baseURL = baseURL.replace(/\/$/, '');
  }

  private defaults: Record<string, string> = { 'Content-Type': 'application/json' };
  private req: Array<(c: RequestConfig) => RequestConfig> = [];
  private res: Array<(r: APIResponse) => APIResponse> = [];

  addRequestInterceptor(i: (c: RequestConfig) => RequestConfig) { this.req.push(i); }
  addResponseInterceptor(i: (r: APIResponse) => APIResponse) { this.res.push(i); }

  async request<T = unknown>(endpoint: string, cfg: RequestConfig = {}) {
    const url = `${this.baseURL}${endpoint}`;
    let final = this.req.reduce((c, fn) => fn(c), { ...cfg });

    const init: RequestInit = {
      method: final.method || 'GET',
      headers: { ...this.defaults, ...final.headers }
    };
    if (final.body && final.method !== 'GET') init.body = JSON.stringify(final.body);

    const controller = new AbortController();
    const to = setTimeout(() => controller.abort(), final.timeout ?? 30000);

    try {
      const rsp = await fetch(url, { ...init, signal: controller.signal });
      clearTimeout(to);
      const data = await rsp.json().catch(() => null);
      let out: APIResponse<T> = {
        data,
        status: rsp.status,
        statusText: rsp.statusText,
        headers: Object.fromEntries(rsp.headers.entries())
      };
      out = this.res.reduce((r, fn) => fn(r), out);
      return out;
    } finally {
      clearTimeout(to);
    }
  }

  get<T = unknown>(e: string, c?: Omit<RequestConfig, 'method'>) { return this.request<T>(e, { ...c, method: 'GET' }); }
  post<T = unknown>(e: string, b?: unknown, c?: Omit<RequestConfig, 'method' | 'body'>) { return this.request<T>(e, { ...c, method: 'POST', body: b }); }
}

export const createAPIClient = (baseURL: string) => new APIClient(baseURL);

Managing Dependencies

Global Dependencies

bash
pnpm add -Dw typescript @types/node eslint prettier jest
pnpm add -w lodash axios

Package-Specific Dependencies

bash
pnpm add react react-dom --filter @awesome/ui-components
pnpm add -D @types/jest --filter @awesome/utils
pnpm add lodash --filter @awesome/utils --filter @awesome/api-client

Advanced Filtering

bash
pnpm add dayjs --filter "@awesome/*"
pnpm add --filter "./packages/**" some-package
pnpm add --filter "!@awesome/ui-components" some-package
pnpm add --filter "...[HEAD~1]" some-package

Inter-package Dependencies

Use the workspace: protocol to link local packages.

bash
pnpm add @awesome/utils@workspace:* --filter @awesome/api-client

Result:

json
{
  "dependencies": {
    "@awesome/utils": "workspace:*"
  }
}

Build Configuration

Shared TypeScript

tsconfig.base.json

json
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["DOM", "DOM.Iterable", "ES2020"],
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "jsx": "react-jsx"
  },
  "exclude": ["node_modules", "dist", "build"]
}

Rollup (ui-components)

js
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';

export default {
  input: 'src/index.ts',
  output: [
    { file: 'dist/index.js', format: 'cjs', sourcemap: true },
    { file: 'dist/index.esm.js', format: 'esm', sourcemap: true }
  ],
  plugins: [
    peerDepsExternal(),
    resolve(),
    commonjs(),
    typescript({ tsconfig: './tsconfig.json', exclude: ['**/*.test.*', '**/*.stories.*'] })
  ],
  external: ['react', 'react-dom']
};

Version Management with Changesets

bash
pnpm add -Dw @changesets/cli
pnpm changeset init

.changeset/config.json

json
{
  "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
  "changelog": ["@changesets/changelog-github", { "repo": "your-org/awesome-monorepo" }],
  "commit": false,
  "fixed": [],
  "linked": [["@awesome/*"]],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@awesome/build-tools"],
  "snapshot": {
    "useCalculatedVersion": true,
    "prereleaseTemplate": "{tag}-{datetime}"
  }
}

CI/CD Integration

GitHub Actions (CI)

yaml
name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
        with: { version: '8.15.0' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm test
      - run: pnpm build

Best Practices and Troubleshooting

Naming

  • @company/ui-* for components
  • @company/util-* for utilities
  • @company/api-* for API packages

Dependencies

  1. Prefer workspace:* for internal links.
  2. Hoist common devDependencies.
  3. Pin critical versions.
  4. Use peer dependencies for shared libs like React.

Advanced Patterns

  • Dynamic package loading
  • Module Federation
  • Bundle analysis and tree‑shaking checks

Summary

Benefits Achieved

  • Faster installs with pnpm
  • Unified workflow across packages
  • Automated versioning with Changesets
  • Efficient CI with incremental builds

Next Steps

  1. Start with 2–3 packages.
  2. Add lint/test in CI.
  3. Release with Changesets.
  4. Measure bundle sizes.

Resources