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):
| Tool | Install |
|---|---|
| npm | ~45s |
| yarn | ~35s |
| pnpm | ~22s |
Disk space usage (illustrative):
| Tool | Space |
|---|---|
| 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 --versionInitial Structure
bash
mkdir awesome-monorepo
cd awesome-monorepo
pnpm initRoot 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.mdCreating Packages
Package 1: UI Components Library
bash
mkdir -p packages/ui-components
cd packages/ui-components
pnpm initpackages/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 axiosPackage-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-clientAdvanced 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-packageInter-package Dependencies
Use the workspace: protocol to link local packages.
bash
pnpm add @awesome/utils@workspace:* --filter @awesome/api-clientResult:
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 buildBest Practices and Troubleshooting
Naming
-
@company/ui-*for components -
@company/util-*for utilities -
@company/api-*for API packages
Dependencies
- Prefer
workspace:*for internal links. - Hoist common devDependencies.
- Pin critical versions.
- 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
- Start with 2–3 packages.
- Add lint/test in CI.
- Release with Changesets.
- Measure bundle sizes.