JavaScript Development Space

Mastering Modern Monorepo Development with pnpm, Workspaces

29 May 202522 min read
Complete Monorepo Guide: pnpm + Workspace + Changesets (2025)

We've previously covered creating monorepos with various package managers, but now we'll dive deeper and focus specifically on using pnpm for our implementation.

Understanding Monorepos

A monorepo (monolithic repository) is a software development strategy where multiple related projects are stored in a single repository. This approach offers significant advantages for teams managing multiple packages, applications, or services that share common dependencies or business logic.

Benefits of Monorepos

  • Unified tooling and configuration across all projects
  • Simplified dependency management and version synchronization
  • Enhanced code sharing and reusability
  • Streamlined CI/CD processes with atomic commits
  • Better collaboration with unified code standards
  • Easier refactoring across multiple packages

Common Monorepo Use Cases

  • Component libraries (Design systems)
  • Microservices architectures
  • Full-stack applications with shared utilities
  • Plugin ecosystems
  • Multi-platform applications

Why Choose pnpm Over Other Tools

pnpm vs npm/yarn

pnpm (performant npm) is a fast, disk space-efficient package manager that excels in monorepo scenarios:

bash
1 # Installation speed comparison
2 npm install # ~45s
3 yarn install # ~35s
4 pnpm install # ~22s
5
6 # Disk space usage
7 npm: 130MB per project
8 yarn: 125MB per project
9 pnpm: 85MB total (shared across projects)

Key Advantages

  1. Disk Space Efficiency: Uses content-addressable storage
  2. Built-in Workspace Support: No additional tools required
  3. Strict Node Modules: Prevents phantom dependencies
  4. Superior Performance: Parallel installation and linking
  5. Active Maintenance: Regular updates and community support

Why Not Lerna?

  • Maintenance Status: Lerna is no longer actively maintained
  • Performance: pnpm offers better installation speeds
  • Native Support: pnpm has built-in monorepo capabilities
  • Modern Architecture: Content-addressable storage system

Project Setup and Configuration

Prerequisites

bash
1 # Check Node.js version (minimum v14.19.0 for pnpm v7+)
2 node --version
3
4 # Install pnpm globally
5 npm install -g pnpm
6
7 # Verify installation
8 pnpm --version

Initial Project Structure

bash
1 # Create project directory
2 mkdir awesome-monorepo
3 cd awesome-monorepo
4
5 # Initialize root package.json
6 pnpm init

Root Package Configuration

json
1 {
2 "name": "awesome-monorepo",
3 "version": "1.0.0",
4 "private": true,
5 "scripts": {
6 "preinstall": "npx only-allow pnpm",
7 "build": "pnpm --filter=@awesome/* run build",
8 "test": "pnpm --filter=@awesome/* run test",
9 "lint": "pnpm --filter=@awesome/* run lint",
10 "clean": "rimraf 'packages/*/{dist,node_modules}' && rimraf node_modules",
11 "changeset": "changeset",
12 "version-packages": "changeset version",
13 "release": "pnpm build && pnpm changeset publish"
14 },
15 "devDependencies": {
16 "@changesets/cli": "^2.26.0",
17 "rimraf": "^4.1.2",
18 "typescript": "^4.9.4",
19 "only-allow": "^1.1.1"
20 }
21 }

Workspace Configuration

Create pnpm-workspace.yaml in the root directory:

yaml
1 packages:
2 - 'packages/*'
3 - 'apps/*'
4 - 'tools/*'

Directory Structure

1 awesome-monorepo/
2 ├── .changeset/
3 │ └── config.json
4 ├── apps/
5 │ └── web-app/
6 ├── packages/
7 │ ├── ui-components/
8 │ ├── utils/
9 │ └── api-client/
10 ├── tools/
11 │ └── build-scripts/
12 ├── package.json
13 ├── pnpm-workspace.yaml
14 ├── pnpm-lock.yaml
15 └── README.md

Creating Packages

Package 1: UI Components Library

bash
1 # Create packages directory
2 mkdir -p packages/ui-components
3 cd packages/ui-components
4
5 # Initialize package
6 pnpm init

packages/ui-components/package.json:

json
1 {
2 "name": "@awesome/ui-components",
3 "version": "1.0.0",
4 "description": "Reusable UI components library",
5 "main": "dist/index.js",
6 "module": "dist/index.esm.js",
7 "types": "dist/index.d.ts",
8 "files": ["dist"],
9 "scripts": {
10 "build": "rollup -c",
11 "dev": "rollup -c -w",
12 "test": "jest",
13 "lint": "eslint src/**/*.{ts,tsx}"
14 },
15 "peerDependencies": {
16 "react": ">=16.8.0",
17 "react-dom": ">=16.8.0"
18 },
19 "devDependencies": {
20 "@types/react": "^18.0.0",
21 "rollup": "^3.15.0",
22 "typescript": "^4.9.4"
23 }
24 }

packages/ui-components/src/index.ts:

typescript
1 export { Button } from './Button';
2 export { Input } from './Input';
3 export { Modal } from './Modal';
4 export * from './types';

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

typescript
1 import React from 'react';
2 import { ButtonProps } from '../types';
3
4 export const Button: React.FC<ButtonProps> = ({
5 children,
6 variant = 'primary',
7 size = 'medium',
8 onClick,
9 disabled = false,
10 ...props
11 }) => {
12 const baseClasses = 'px-4 py-2 rounded font-medium transition-colors';
13 const variantClasses = {
14 primary: 'bg-blue-600 text-white hover:bg-blue-700',
15 secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
16 danger: 'bg-red-600 text-white hover:bg-red-700'
17 };
18 const sizeClasses = {
19 small: 'px-2 py-1 text-sm',
20 medium: 'px-4 py-2',
21 large: 'px-6 py-3 text-lg'
22 };
23
24 return (
25 <button
26 className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`}
27 onClick={onClick}
28 disabled={disabled}
29 {...props}
30 >
31 {children}
32 </button>
33 );
34 };

Package 2: Utility Functions

bash
1 mkdir -p packages/utils
2 cd packages/utils
3 pnpm init

packages/utils/package.json:

json
1 {
2 "name": "@awesome/utils",
3 "version": "1.0.0",
4 "description": "Common utility functions",
5 "main": "dist/index.js",
6 "module": "dist/index.esm.js",
7 "types": "dist/index.d.ts",
8 "files": ["dist"],
9 "scripts": {
10 "build": "tsup src/index.ts --format cjs,esm --dts",
11 "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
12 "test": "jest"
13 },
14 "devDependencies": {
15 "tsup": "^6.5.0",
16 "typescript": "^4.9.4"
17 }
18 }

packages/utils/src/index.ts:

typescript
1 // String utilities
2 export const capitalize = (str: string): string =>
3 str.charAt(0).toUpperCase() + str.slice(1);
4
5 export const slugify = (str: string): string =>
6 str
7 .toLowerCase()
8 .replace(/[^\w\s-]/g, '')
9 .replace(/[\s_-]+/g, '-')
10 .replace(/^-+|-+$/g, '');
11
12 // Array utilities
13 export const chunk = <T>(array: T[], size: number): T[][] => {
14 const chunks: T[][] = [];
15 for (let i = 0; i < array.length; i += size) {
16 chunks.push(array.slice(i, i + size));
17 }
18 return chunks;
19 };
20
21 export const unique = <T>(array: T[]): T[] => [...new Set(array)];
22
23 // Object utilities
24 export const pick = <T, K extends keyof T>(
25 obj: T,
26 keys: K[]
27 ): Pick<T, K> => {
28 const result = {} as Pick<T, K>;
29 keys.forEach(key => {
30 if (key in obj) {
31 result[key] = obj[key];
32 }
33 });
34 return result;
35 };
36
37 // Date utilities
38 export const formatDate = (date: Date, format: string = 'YYYY-MM-DD'): string => {
39 const year = date.getFullYear();
40 const month = String(date.getMonth() + 1).padStart(2, '0');
41 const day = String(date.getDate()).padStart(2, '0');
42
43 return format
44 .replace('YYYY', String(year))
45 .replace('MM', month)
46 .replace('DD', day);
47 };
48
49 // Validation utilities
50 export const isEmail = (email: string): boolean => {
51 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
52 return emailRegex.test(email);
53 };
54
55 export const isURL = (url: string): boolean => {
56 try {
57 new URL(url);
58 return true;
59 } catch {
60 return false;
61 }
62 };

Package 3: API Client

bash
1 mkdir -p packages/api-client
2 cd packages/api-client
3 pnpm init

packages/api-client/package.json:

json
1 {
2 "name": "@awesome/api-client",
3 "version": "1.0.0",
4 "description": "HTTP API client with interceptors",
5 "main": "dist/index.js",
6 "module": "dist/index.esm.js",
7 "types": "dist/index.d.ts",
8 "files": ["dist"],
9 "scripts": {
10 "build": "tsup src/index.ts --format cjs,esm --dts",
11 "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
12 "test": "jest"
13 },
14 "dependencies": {
15 "@awesome/utils": "workspace:*"
16 },
17 "devDependencies": {
18 "tsup": "^6.5.0",
19 "typescript": "^4.9.4"
20 }
21 }

packages/api-client/src/index.ts:

typescript
1 import { isURL } from '@awesome/utils';
2
3 export interface RequestConfig {
4 method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
5 headers?: Record<string, string>;
6 body?: any;
7 timeout?: number;
8 }
9
10 export interface APIResponse<T = any> {
11 data: T;
12 status: number;
13 statusText: string;
14 headers: Record<string, string>;
15 }
16
17 export class APIClient {
18 private baseURL: string;
19 private defaultHeaders: Record<string, string>;
20 private interceptors: {
21 request: ((config: RequestConfig) => RequestConfig)[];
22 response: ((response: APIResponse) => APIResponse)[];
23 };
24
25 constructor(baseURL: string) {
26 if (!isURL(baseURL)) {
27 throw new Error('Invalid base URL provided');
28 }
29
30 this.baseURL = baseURL.replace(/\/$/, '');
31 this.defaultHeaders = {
32 'Content-Type': 'application/json',
33 };
34 this.interceptors = {
35 request: [],
36 response: [],
37 };
38 }
39
40 // Add request interceptor
41 addRequestInterceptor(interceptor: (config: RequestConfig) => RequestConfig) {
42 this.interceptors.request.push(interceptor);
43 }
44
45 // Add response interceptor
46 addResponseInterceptor(interceptor: (response: APIResponse) => APIResponse) {
47 this.interceptors.response.push(interceptor);
48 }
49
50 // Set default headers
51 setDefaultHeaders(headers: Record<string, string>) {
52 this.defaultHeaders = { ...this.defaultHeaders, ...headers };
53 }
54
55 // Generic request method
56 async request<T = any>(endpoint: string, config: RequestConfig = {}): Promise<APIResponse<T>> {
57 const url = `${this.baseURL}${endpoint}`;
58
59 // Apply request interceptors
60 let finalConfig = { ...config };
61 this.interceptors.request.forEach(interceptor => {
62 finalConfig = interceptor(finalConfig);
63 });
64
65 const requestOptions: RequestInit = {
66 method: finalConfig.method || 'GET',
67 headers: {
68 ...this.defaultHeaders,
69 ...finalConfig.headers,
70 },
71 };
72
73 if (finalConfig.body && finalConfig.method !== 'GET') {
74 requestOptions.body = JSON.stringify(finalConfig.body);
75 }
76
77 const controller = new AbortController();
78 const timeout = finalConfig.timeout || 30000;
79 const timeoutId = setTimeout(() => controller.abort(), timeout);
80
81 try {
82 const response = await fetch(url, {
83 ...requestOptions,
84 signal: controller.signal,
85 });
86
87 clearTimeout(timeoutId);
88
89 const responseData = await response.json();
90
91 let apiResponse: APIResponse<T> = {
92 data: responseData,
93 status: response.status,
94 statusText: response.statusText,
95 headers: Object.fromEntries(response.headers.entries()),
96 };
97
98 // Apply response interceptors
99 this.interceptors.response.forEach(interceptor => {
100 apiResponse = interceptor(apiResponse);
101 });
102
103 return apiResponse;
104 } catch (error) {
105 clearTimeout(timeoutId);
106 throw error;
107 }
108 }
109
110 // Convenience methods
111 get<T = any>(endpoint: string, config?: Omit<RequestConfig, 'method'>) {
112 return this.request<T>(endpoint, { ...config, method: 'GET' });
113 }
114
115 post<T = any>(endpoint: string, data?: any, config?: Omit<RequestConfig, 'method' | 'body'>) {
116 return this.request<T>(endpoint, { ...config, method: 'POST', body: data });
117 }
118
119 put<T = any>(endpoint: string, data?: any, config?: Omit<RequestConfig, 'method' | 'body'>) {
120 return this.request<T>(endpoint, { ...config, method: 'PUT', body: data });
121 }
122
123 delete<T = any>(endpoint: string, config?: Omit<RequestConfig, 'method'>) {
124 return this.request<T>(endpoint, { ...config, method: 'DELETE' });
125 }
126 }
127
128 // Export a factory function
129 export const createAPIClient = (baseURL: string) => new APIClient(baseURL);

Managing Dependencies

Global Dependencies

Install development dependencies that are shared across all packages:

bash
1 # Development dependencies for the workspace root
2 pnpm add -Dw typescript @types/node eslint prettier jest
3
4 # Production dependencies for workspace root
5 pnpm add -w lodash axios

Package-Specific Dependencies

Install dependencies for specific packages using the --filter flag:

bash
1 # Add React to UI components package
2 pnpm add react react-dom --filter @awesome/ui-components
3
4 # Add development dependencies to a specific package
5 pnpm add -D @types/jest --filter @awesome/utils
6
7 # Add multiple packages to multiple filters
8 pnpm add lodash --filter @awesome/utils --filter @awesome/api-client

Advanced Filtering

bash
1 # Install to all packages matching pattern
2 pnpm add dayjs --filter "@awesome/*"
3
4 # Install to packages in specific directory
5 pnpm add --filter "./packages/**" some-package
6
7 # Exclude specific packages
8 pnpm add --filter "!@awesome/ui-components" some-package
9
10 # Install based on changed files (useful in CI)
11 pnpm add --filter "...[HEAD~1]" some-package

Inter-package Dependencies

Setting Up Workspace Dependencies

Reference other packages in your monorepo using the workspace: protocol:

bash
1 # Add @awesome/utils as dependency to @awesome/api-client
2 pnpm add @awesome/utils --filter @awesome/api-client
3
4 # Use wildcard version for latest workspace version
5 pnpm add @awesome/utils@workspace:* --filter @awesome/api-client

This creates the following in packages/api-client/package.json:

json
1 {
2 "dependencies": {
3 "@awesome/utils": "workspace:*"
4 }
5 }

Example: Using Workspace Dependencies

packages/web-app/src/components/UserForm.tsx:

typescript
1 import React, { useState } from 'react';
2 import { Button, Input } from '@awesome/ui-components';
3 import { isEmail, capitalize } from '@awesome/utils';
4 import { createAPIClient } from '@awesome/api-client';
5
6 const apiClient = createAPIClient('https://api.example.com');
7
8 export const UserForm: React.FC = () => {
9 const [formData, setFormData] = useState({
10 name: '',
11 email: '',
12 });
13 const [errors, setErrors] = useState<Record<string, string>>({});
14 const [loading, setLoading] = useState(false);
15
16 const validateForm = () => {
17 const newErrors: Record<string, string> = {};
18
19 if (!formData.name.trim()) {
20 newErrors.name = 'Name is required';
21 }
22
23 if (!formData.email.trim()) {
24 newErrors.email = 'Email is required';
25 } else if (!isEmail(formData.email)) {
26 newErrors.email = 'Invalid email format';
27 }
28
29 setErrors(newErrors);
30 return Object.keys(newErrors).length === 0;
31 };
32
33 const handleSubmit = async (e: React.FormEvent) => {
34 e.preventDefault();
35
36 if (!validateForm()) return;
37
38 setLoading(true);
39 try {
40 const response = await apiClient.post('/users', {
41 name: capitalize(formData.name),
42 email: formData.email.toLowerCase(),
43 });
44
45 console.log('User created:', response.data);
46 // Handle success
47 } catch (error) {
48 console.error('Error creating user:', error);
49 // Handle error
50 } finally {
51 setLoading(false);
52 }
53 };
54
55 return (
56 <form onSubmit={handleSubmit} className="space-y-4">
57 <div>
58 <Input
59 type="text"
60 placeholder="Enter your name"
61 value={formData.name}
62 onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
63 error={errors.name}
64 />
65 </div>
66
67 <div>
68 <Input
69 type="email"
70 placeholder="Enter your email"
71 value={formData.email}
72 onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
73 error={errors.email}
74 />
75 </div>
76
77 <Button
78 type="submit"
79 disabled={loading}
80 variant="primary"
81 size="large"
82 >
83 {loading ? 'Creating...' : 'Create User'}
84 </Button>
85 </form>
86 );
87 };

Build Configuration

Shared TypeScript Configuration

tsconfig.base.json:

json
1 {
2 "compilerOptions": {
3 "target": "ES2020",
4 "lib": ["DOM", "DOM.Iterable", "ES2020"],
5 "allowJs": true,
6 "skipLibCheck": true,
7 "esModuleInterop": true,
8 "allowSyntheticDefaultImports": true,
9 "strict": true,
10 "forceConsistentCasingInFileNames": true,
11 "moduleResolution": "node",
12 "resolveJsonModule": true,
13 "isolatedModules": true,
14 "noEmit": false,
15 "declaration": true,
16 "declarationMap": true,
17 "sourceMap": true,
18 "jsx": "react-jsx"
19 },
20 "exclude": ["node_modules", "dist", "build"]
21 }

packages/ui-components/tsconfig.json:

json
1 {
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "outDir": "./dist",
5 "rootDir": "./src"
6 },
7 "include": ["src/**/*"],
8 "references": [
9 { "path": "../utils" }
10 ]
11 }

Rollup Configuration

packages/ui-components/rollup.config.js:

javascript
1 import typescript from '@rollup/plugin-typescript';
2 import resolve from '@rollup/plugin-node-resolve';
3 import commonjs from '@rollup/plugin-commonjs';
4 import peerDepsExternal from 'rollup-plugin-peer-deps-external';
5
6 export default {
7 input: 'src/index.ts',
8 output: [
9 {
10 file: 'dist/index.js',
11 format: 'cjs',
12 sourcemap: true,
13 },
14 {
15 file: 'dist/index.esm.js',
16 format: 'esm',
17 sourcemap: true,
18 },
19 ],
20 plugins: [
21 peerDepsExternal(),
22 resolve(),
23 commonjs(),
24 typescript({
25 tsconfig: './tsconfig.json',
26 exclude: ['**/*.test.*', '**/*.stories.*'],
27 }),
28 ],
29 external: ['react', 'react-dom'],
30 };

Version Management with Changesets

Installation and Configuration

bash
1 # Install changesets
2 pnpm add -Dw @changesets/cli
3
4 # Initialize changesets
5 pnpm changeset init

Changesets Configuration

.changeset/config.json:

json
1 {
2 "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
3 "changelog": [
4 "@changesets/changelog-github",
5 {
6 "repo": "your-org/awesome-monorepo"
7 }
8 ],
9 "commit": false,
10 "fixed": [],
11 "linked": [["@awesome/*"]],
12 "access": "public",
13 "baseBranch": "main",
14 "updateInternalDependencies": "patch",
15 "ignore": ["@awesome/build-tools"],
16 "snapshot": {
17 "useCalculatedVersion": true,
18 "prereleaseTemplate": "{tag}-{datetime}"
19 },
20 "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
21 "onlyUpdatePeerDependentsWhenOutOfRange": true,
22 "updateInternalDependents": "always"
23 }
24 }

Creating Changesets

bash
1 # Create a changeset
2 pnpm changeset
3
4 # Example interactive flow:
5 # ? Which packages would you like to include?
6 # ✓ @awesome/ui-components
7 # ✓ @awesome/utils
8 #
9 # ? Which packages should have a major bump?
10 # (none selected)
11 #
12 # ? Which packages should have a minor bump?
13 # ✓ @awesome/ui-components
14 #
15 # ? Which packages should have a patch bump?
16 # ✓ @awesome/utils
17 #
18 # ? Please enter a summary for this change:
19 # Add new Button variants and fix utility functions

This creates a changeset file:

.changeset/funny-lions-dance.md:

markdown
1 ---
2 "@awesome/ui-components": minor
3 "@awesome/utils": patch
4 ---
5
6 Add new Button variants and fix utility functions
7
8 - Added danger and success variants to Button component
9 - Fixed edge case in slugify utility function
10 - Updated TypeScript types for better inference

Version Bumping and Publishing

bash
1 # Update package versions based on changesets
2 pnpm version-packages
3
4 # Build all packages
5 pnpm build
6
7 # Publish to npm
8 pnpm changeset publish
9
10 # Or publish with custom registry
11 pnpm changeset publish --registry https://npm.your-company.com

Pre-release Workflow

bash
1 # Enter pre-release mode
2 pnpm changeset pre enter beta
3
4 # Create changesets as normal
5 pnpm changeset
6 pnpm version-packages # Results in 1.0.0-beta.1
7
8 # Continue development
9 pnpm changeset
10 pnpm version-packages # Results in 1.0.0-beta.2
11
12 # Exit pre-release mode
13 pnpm changeset pre exit
14 pnpm changeset
15 pnpm version-packages # Results in 1.0.0 (stable)

Automated Scripts

Add these scripts to your root package.json:

json
1 {
2 "scripts": {
3 "changeset": "changeset",
4 "changeset:version": "changeset version && pnpm install --lockfile-only",
5 "changeset:publish": "changeset publish",
6 "changeset:snapshot": "changeset version --snapshot && changeset publish --tag snapshot",
7 "release": "pnpm build && pnpm changeset:publish",
8 "release:snapshot": "pnpm build && pnpm changeset:snapshot"
9 }
10 }

CI/CD Integration

GitHub Actions Workflow

.github/workflows/ci.yml:

yaml
1 name: CI/CD Pipeline
2
3 on:
4 push:
5 branches: [main, develop]
6 pull_request:
7 branches: [main]
8
9 env:
10 NODE_VERSION: '18'
11 PNPM_VERSION: '8.15.0'
12
13 jobs:
14 test:
15 runs-on: ubuntu-latest
16
17 steps:
18 - name: Checkout code
19 uses: actions/checkout@v4
20 with:
21 fetch-depth: 0
22
23 - name: Setup Node.js
24 uses: actions/setup-node@v4
25 with:
26 node-version: ${{ env.NODE_VERSION }}
27
28 - name: Setup pnpm
29 uses: pnpm/action-setup@v2
30 with:
31 version: ${{ env.PNPM_VERSION }}
32
33 - name: Get pnpm store directory
34 shell: bash
35 run: |
36 echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
37
38 - name: Setup pnpm cache
39 uses: actions/cache@v3
40 with:
41 path: ${{ env.STORE_PATH }}
42 key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
43 restore-keys: |
44 ${{ runner.os }}-pnpm-store-
45
46 - name: Install dependencies
47 run: pnpm install --frozen-lockfile
48
49 - name: Lint code
50 run: pnpm lint
51
52 - name: Run tests
53 run: pnpm test
54
55 - name: Build packages
56 run: pnpm build
57
58 - name: Check for build artifacts
59 run: |
60 for package in packages/*/dist; do
61 if [ ! -d "$package" ]; then
62 echo "Build artifact missing: $package"
63 exit 1
64 fi
65 done
66
67 release:
68 needs: test
69 runs-on: ubuntu-latest
70 if: github.ref == 'refs/heads/main'
71
72 steps:
73 - name: Checkout code
74 uses: actions/checkout@v4
75 with:
76 fetch-depth: 0
77 token: ${{ secrets.GITHUB_TOKEN }}
78
79 - name: Setup Node.js
80 uses: actions/setup-node@v4
81 with:
82 node-version: ${{ env.NODE_VERSION }}
83 registry-url: 'https://registry.npmjs.org'
84
85 - name: Setup pnpm
86 uses: pnpm/action-setup@v2
87 with:
88 version: ${{ env.PNPM_VERSION }}
89
90 - name: Install dependencies
91 run: pnpm install --frozen-lockfile
92
93 - name: Build packages
94 run: pnpm build
95
96 - name: Create Release Pull Request or Publish
97 id: changesets
98 uses: changesets/action@v1
99 with:
100 publish: pnpm changeset:publish
101 title: 'chore: release packages'
102 commit: 'chore: release packages'
103 env:
104 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
105 NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
106
107 - name: Send Slack notification
108 if: steps.changesets.outputs.published == 'true'
109 uses: 8398a7/action-slack@v3
110 with:
111 status: success
112 text: 'New packages published! 🎉'
113 env:
114 SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Conditional Testing

.github/workflows/test-affected.yml:

yaml
1 name: Test Affected Packages
2
3 on:
4 pull_request:
5 branches: [main]
6
7 jobs:
8 affected:
9 runs-on: ubuntu-latest
10
11 steps:
12 - uses: actions/checkout@v4
13 with:
14 fetch-depth: 0
15
16 - name: Setup Node.js and pnpm
17 # ... setup steps ...
18
19 - name: Install dependencies
20 run: pnpm install --frozen-lockfile
21
22 - name: Test affected packages
23 run: |
24 # Get changed files
25 CHANGED_FILES=$(git diff --name-only origin/main...HEAD)
26
27 # Test packages with changes
28 if echo "$CHANGED_FILES" | grep -q "packages/ui-components/"; then
29 pnpm --filter @awesome/ui-components test
30 fi
31
32 if echo "$CHANGED_FILES" | grep -q "packages/utils/"; then
33 pnpm --filter @awesome/utils test
34 fi
35
36 if echo "$CHANGED_FILES" | grep -q "packages/api-client/"; then
37 pnpm --filter @awesome/api-client test
38 fi
39
40 - name: Build affected packages
41 run: |
42 # Similar logic for building only affected packages
43 # This saves CI time for large monorepos

Best Practices and Troubleshooting

Package Naming Conventions

json
1 {
2 "name": "@company/package-name",
3 "version": "1.0.0"
4 }

Use consistent naming patterns:

  • @company/ui-* for UI components
  • @company/util-* for utilities
  • @company/api-* for API-related packages
  • @company/config-* for configuration packages

Dependency Management Best Practices

  1. Use workspace protocol for internal dependencies:

    json
    1 {
    2 "dependencies": {
    3 "@awesome/utils": "workspace:*"
    4 }
    5 }
  2. Hoist common dependencies to workspace root:

    bash
    1 # Install shared dev dependencies at root
    2 pnpm add -Dw typescript eslint prettier jest
    3
    4 # Install shared production dependencies at root
    5 pnpm add -w lodash date-fns
  3. Pin exact versions for critical dependencies:

    json
    1 {
    2 "dependencies": {
    3 "react": "18.2.0",
    4 "typescript": "4.9.4"
    5 }
    6 }
  4. Use peer dependencies for shared libraries:

    json
    1 {
    2 "peerDependencies": {
    3 "react": ">=16.8.0",
    4 "react-dom": ">=16.8.0"
    5 }
    6 }

Code Organization Patterns

Barrel Exports

packages/ui-components/src/index.ts:

typescript
1 // Component exports
2 export { Button } from './Button';
3 export { Input } from './Input';
4 export { Modal } from './Modal';
5 export { Card } from './Card';
6 export { Table } from './Table';
7
8 // Hook exports
9 export { useToggle } from './hooks/useToggle';
10 export { useLocalStorage } from './hooks/useLocalStorage';
11
12 // Type exports
13 export type { ButtonProps, InputProps, ModalProps } from './types';
14
15 // Utility exports
16 export { theme } from './theme';
17 export { cn } from './utils/classNames';

Shared Configuration

tools/eslint-config/index.js:

javascript
1 module.exports = {
2 extends: [
3 'eslint:recommended',
4 '@typescript-eslint/recommended',
5 'prettier'
6 ],
7 parser: '@typescript-eslint/parser',
8 plugins: ['@typescript-eslint'],
9 rules: {
10 '@typescript-eslint/no-unused-vars': 'error',
11 '@typescript-eslint/explicit-function-return-type': 'warn',
12 'prefer-const': 'error',
13 'no-var': 'error'
14 },
15 env: {
16 node: true,
17 browser: true,
18 es2020: true
19 }
20 };

tools/tsconfig/base.json:

json
1 {
2 "compilerOptions": {
3 "target": "ES2020",
4 "lib": ["DOM", "DOM.Iterable", "ES2020"],
5 "allowJs": true,
6 "skipLibCheck": true,
7 "esModuleInterop": true,
8 "allowSyntheticDefaultImports": true,
9 "strict": true,
10 "forceConsistentCasingInFileNames": true,
11 "moduleResolution": "node",
12 "resolveJsonModule": true,
13 "isolatedModules": true,
14 "noEmit": false,
15 "declaration": true,
16 "declarationMap": true,
17 "sourceMap": true
18 }
19 }

Testing Strategies

Shared Test Configuration

jest.config.base.js:

javascript
1 module.exports = {
2 preset: 'ts-jest',
3 testEnvironment: 'jsdom',
4 setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
5 testMatch: [
6 '**/__tests__/**/*.(ts|tsx)',
7 '**/*.(test|spec).(ts|tsx)'
8 ],
9 collectCoverageFrom: [
10 'src/**/*.(ts|tsx)',
11 '!src/**/*.d.ts',
12 '!src/**/*.stories.*'
13 ],
14 coverageThreshold: {
15 global: {
16 branches: 80,
17 functions: 80,
18 lines: 80,
19 statements: 80
20 }
21 },
22 moduleNameMapping: {
23 '^@/(.*)
24 : '<rootDir>/src/$1'
25 }
26 };

Package-Specific Test Configuration

packages/ui-components/jest.config.js:

javascript
1 const baseConfig = require('../../jest.config.base');
2
3 module.exports = {
4 ...baseConfig,
5 displayName: '@awesome/ui-components',
6 setupFilesAfterEnv: [
7 ...baseConfig.setupFilesAfterEnv,
8 '<rootDir>/test/ui-setup.ts'
9 ]
10 };

Integration Tests

tests/integration/api-client.test.ts:

typescript
1 import { createAPIClient } from '@awesome/api-client';
2 import { isEmail } from '@awesome/utils';
3
4 describe('API Client Integration', () => {
5 const mockServer = 'https://jsonplaceholder.typicode.com';
6 const client = createAPIClient(mockServer);
7
8 beforeAll(() => {
9 // Add auth interceptor
10 client.addRequestInterceptor((config) => ({
11 ...config,
12 headers: {
13 ...config.headers,
14 'Authorization': 'Bearer test-token'
15 }
16 }));
17
18 // Add logging interceptor
19 client.addResponseInterceptor((response) => {
20 console.log(`API Response: ${response.status}`);
21 return response;
22 });
23 });
24
25 test('should fetch users successfully', async () => {
26 const response = await client.get('/users');
27
28 expect(response.status).toBe(200);
29 expect(Array.isArray(response.data)).toBe(true);
30 expect(response.data.length).toBeGreaterThan(0);
31
32 // Test integration with utils package
33 const firstUser = response.data[0];
34 expect(isEmail(firstUser.email)).toBe(true);
35 });
36
37 test('should handle POST requests', async () => {
38 const newUser = {
39 name: 'John Doe',
40 email: 'john@example.com'
41 };
42
43 const response = await client.post('/users', newUser);
44
45 expect(response.status).toBe(201);
46 expect(response.data.name).toBe(newUser.name);
47 });
48
49 test('should handle errors gracefully', async () => {
50 await expect(client.get('/nonexistent'))
51 .rejects
52 .toThrow();
53 });
54 });

Performance Optimization

Build Optimization

tools/build-scripts/optimize-build.js:

javascript
1 const { execSync } = require('child_process');
2 const path = require('path');
3 const fs = require('fs');
4
5 class BuildOptimizer {
6 constructor(workspaceRoot) {
7 this.workspaceRoot = workspaceRoot;
8 this.packagesDir = path.join(workspaceRoot, 'packages');
9 }
10
11 // Get list of changed packages since last commit
12 getChangedPackages() {
13 try {
14 const changedFiles = execSync('git diff --name-only HEAD~1', {
15 encoding: 'utf8'
16 }).split('\n');
17
18 const changedPackages = new Set();
19
20 changedFiles.forEach(file => {
21 const match = file.match(/^packages\/([^\/]+)\//);
22 if (match) {
23 changedPackages.add(match[1]);
24 }
25 });
26
27 return Array.from(changedPackages);
28 } catch (error) {
29 console.log('Unable to detect changes, building all packages');
30 return this.getAllPackages();
31 }
32 }
33
34 // Get all packages in the workspace
35 getAllPackages() {
36 return fs.readdirSync(this.packagesDir, { withFileTypes: true })
37 .filter(dirent => dirent.isDirectory())
38 .map(dirent => dirent.name);
39 }
40
41 // Build only changed packages and their dependents
42 buildChanged() {
43 const changedPackages = this.getChangedPackages();
44
45 if (changedPackages.length === 0) {
46 console.log('No package changes detected');
47 return;
48 }
49
50 console.log('Building changed packages:', changedPackages.join(', '));
51
52 // Build changed packages
53 changedPackages.forEach(pkg => {
54 console.log(`Building @awesome/${pkg}...`);
55 execSync(`pnpm --filter @awesome/${pkg} run build`, {
56 stdio: 'inherit'
57 });
58 });
59
60 // Build packages that depend on changed packages
61 this.buildDependents(changedPackages);
62 }
63
64 // Build packages that depend on the changed packages
65 buildDependents(changedPackages) {
66 const allPackages = this.getAllPackages();
67 const dependents = new Set();
68
69 allPackages.forEach(pkg => {
70 const pkgJsonPath = path.join(this.packagesDir, pkg, 'package.json');
71
72 if (fs.existsSync(pkgJsonPath)) {
73 const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
74 const deps = {
75 ...pkgJson.dependencies,
76 ...pkgJson.devDependencies
77 };
78
79 changedPackages.forEach(changedPkg => {
80 if (deps[`@awesome/${changedPkg}`]) {
81 dependents.add(pkg);
82 }
83 });
84 }
85 });
86
87 if (dependents.size > 0) {
88 console.log('Building dependent packages:', Array.from(dependents).join(', '));
89 dependents.forEach(pkg => {
90 execSync(`pnpm --filter @awesome/${pkg} run build`, {
91 stdio: 'inherit'
92 });
93 });
94 }
95 }
96 }
97
98 // Usage
99 const optimizer = new BuildOptimizer(process.cwd());
100 optimizer.buildChanged();

Bundle Analysis

tools/analyze-bundle.js:

javascript
1 const { exec } = require('child_process');
2 const path = require('path');
3 const fs = require('fs');
4
5 class BundleAnalyzer {
6 constructor() {
7 this.packages = this.getPackages();
8 }
9
10 getPackages() {
11 const packagesDir = path.join(process.cwd(), 'packages');
12 return fs.readdirSync(packagesDir)
13 .filter(name => {
14 const pkgPath = path.join(packagesDir, name, 'package.json');
15 return fs.existsSync(pkgPath);
16 });
17 }
18
19 analyzeBundleSizes() {
20 console.log('📦 Bundle Size Analysis\n');
21
22 this.packages.forEach(pkg => {
23 const distPath = path.join(process.cwd(), 'packages', pkg, 'dist');
24
25 if (fs.existsSync(distPath)) {
26 const files = fs.readdirSync(distPath);
27 let totalSize = 0;
28
29 console.log(`\n📋 @awesome/${pkg}:`);
30
31 files.forEach(file => {
32 const filePath = path.join(distPath, file);
33 const stats = fs.statSync(filePath);
34 const sizeKB = (stats.size / 1024).toFixed(2);
35 totalSize += stats.size;
36
37 console.log(` ${file}: ${sizeKB}KB`);
38 });
39
40 const totalKB = (totalSize / 1024).toFixed(2);
41 console.log(` Total: ${totalKB}KB`);
42
43 // Warn if bundle is too large
44 if (totalSize > 100 * 1024) { // 100KB
45 console.log(` ⚠️ Large bundle detected!`);
46 }
47 }
48 });
49 }
50
51 checkTreeShaking() {
52 console.log('\n🌳 Tree Shaking Analysis\n');
53
54 this.packages.forEach(pkg => {
55 const pkgPath = path.join(process.cwd(), 'packages', pkg);
56 const distPath = path.join(pkgPath, 'dist');
57
58 if (fs.existsSync(distPath)) {
59 // Check if ES modules are available
60 const esmFile = path.join(distPath, 'index.esm.js');
61 const cjsFile = path.join(distPath, 'index.js');
62
63 console.log(`📦 @awesome/${pkg}:`);
64 console.log(` ESM: ${fs.existsSync(esmFile) ? '✅' : '❌'}`);
65 console.log(` CJS: ${fs.existsSync(cjsFile) ? '✅' : '❌'}`);
66
67 // Check for sideEffects field
68 const pkgJsonPath = path.join(pkgPath, 'package.json');
69 const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
70
71 if (pkgJson.sideEffects === false) {
72 console.log(` Side Effects: ✅ None`);
73 } else if (Array.isArray(pkgJson.sideEffects)) {
74 console.log(` Side Effects: ⚠️ ${pkgJson.sideEffects.length} files`);
75 } else {
76 console.log(` Side Effects: ❌ Not specified`);
77 }
78 }
79 });
80 }
81 }
82
83 const analyzer = new BundleAnalyzer();
84 analyzer.analyzeBundleSizes();
85 analyzer.checkTreeShaking();

Troubleshooting Common Issues

1. Dependency Resolution Problems

Problem: Package not found or incorrect version resolved

Solution:

bash
1 # Clear all node_modules and reinstall
2 pnpm clean
3 pnpm install
4
5 # Check dependency tree
6 pnpm list --depth=2
7
8 # Force resolve specific version
9 pnpm add package-name@exact-version --filter @awesome/package-name

2. Build Failures

Problem: TypeScript compilation errors across packages

Solution:

bash
1 # Build packages in dependency order
2 pnpm --filter @awesome/utils run build
3 pnpm --filter @awesome/api-client run build
4 pnpm --filter @awesome/ui-components run build
5
6 # Or use topological sorting
7 pnpm --recursive run build

Enhanced TypeScript Configuration:

json
1 {
2 "compilerOptions": {
3 "composite": true,
4 "incremental": true,
5 "tsBuildInfoFile": ".tsbuildinfo"
6 },
7 "references": [
8 { "path": "../utils" },
9 { "path": "../api-client" }
10 ]
11 }

3. Circular Dependencies

Problem: Packages referencing each other creating circular deps

Detection Script:

javascript
1 // tools/detect-circular-deps.js
2 const madge = require('madge');
3 const path = require('path');
4
5 async function detectCircularDependencies() {
6 const packagesDir = path.join(process.cwd(), 'packages');
7
8 try {
9 const res = await madge(packagesDir, {
10 fileExtensions: ['ts', 'tsx', 'js', 'jsx'],
11 tsConfig: 'tsconfig.json'
12 });
13
14 const circular = res.circular();
15
16 if (circular.length > 0) {
17 console.log('🔄 Circular dependencies detected:');
18 circular.forEach((cycle, index) => {
19 console.log(`${index + 1}. ${cycle.join(' → ')}`);
20 });
21 process.exit(1);
22 } else {
23 console.log('✅ No circular dependencies found');
24 }
25 } catch (error) {
26 console.error('Error analyzing dependencies:', error);
27 process.exit(1);
28 }
29 }
30
31 detectCircularDependencies();

4. Publishing Issues

Problem: Packages not publishing or wrong versions

Solution:

bash
1 # Check package contents before publishing
2 pnpm pack --dry-run --filter @awesome/package-name
3
4 # Verify changeset configuration
5 pnpm changeset status
6
7 # Manual version bump if needed
8 pnpm changeset version --ignore @awesome/package-name

5. Workspace Protocol Issues

Problem: workspace:* not resolving correctly

Solution:

json
1 {
2 "pnpm": {
3 "overrides": {
4 "@awesome/utils": "workspace:*"
5 }
6 }
7 }

Advanced Patterns

Dynamic Package Loading

packages/plugin-system/src/index.ts:

typescript
1 interface Plugin {
2 name: string;
3 version: string;
4 activate: () => void;
5 deactivate: () => void;
6 }
7
8 class PluginManager {
9 private plugins = new Map<string, Plugin>();
10 private activePlugins = new Set<string>();
11
12 async loadPlugin(packageName: string): Promise<void> {
13 try {
14 // Dynamic import for workspace packages
15 const pluginModule = await import(packageName);
16 const plugin: Plugin = pluginModule.default || pluginModule;
17
18 this.plugins.set(plugin.name, plugin);
19 console.log(`Plugin loaded: ${plugin.name}@${plugin.version}`);
20 } catch (error) {
21 console.error(`Failed to load plugin ${packageName}:`, error);
22 }
23 }
24
25 activatePlugin(name: string): void {
26 const plugin = this.plugins.get(name);
27 if (plugin && !this.activePlugins.has(name)) {
28 plugin.activate();
29 this.activePlugins.add(name);
30 console.log(`Plugin activated: ${name}`);
31 }
32 }
33
34 deactivatePlugin(name: string): void {
35 const plugin = this.plugins.get(name);
36 if (plugin && this.activePlugins.has(name)) {
37 plugin.deactivate();
38 this.activePlugins.delete(name);
39 console.log(`Plugin deactivated: ${name}`);
40 }
41 }
42
43 getActivePlugins(): string[] {
44 return Array.from(this.activePlugins);
45 }
46 }
47
48 export { PluginManager, type Plugin };

Micro-Frontend Architecture

packages/shell-app/src/ModuleFederation.ts:

typescript
1 interface RemoteModule {
2 name: string;
3 url: string;
4 scope: string;
5 module: string;
6 }
7
8 class ModuleFederationManager {
9 private remotes = new Map<string, RemoteModule>();
10 private loadedModules = new Map<string, any>();
11
12 registerRemote(remote: RemoteModule): void {
13 this.remotes.set(remote.name, remote);
14 }
15
16 async loadRemote(remoteName: string): Promise<any> {
17 if (this.loadedModules.has(remoteName)) {
18 return this.loadedModules.get(remoteName);
19 }
20
21 const remote = this.remotes.get(remoteName);
22 if (!remote) {
23 throw new Error(`Remote module not found: ${remoteName}`);
24 }
25
26 try {
27 // Load remote module script
28 await this.loadScript(remote.url);
29
30 // Get the remote container
31 const container = (window as any)[remote.scope];
32 await container.init(__webpack_share_scopes__.default);
33
34 // Get the module factory
35 const factory = await container.get(remote.module);
36 const module = factory();
37
38 this.loadedModules.set(remoteName, module);
39 return module;
40 } catch (error) {
41 console.error(`Failed to load remote module ${remoteName}:`, error);
42 throw error;
43 }
44 }
45
46 private loadScript(url: string): Promise<void> {
47 return new Promise((resolve, reject) => {
48 const script = document.createElement('script');
49 script.type = 'text/javascript';
50 script.async = true;
51 script.src = url;
52
53 script.onload = () => resolve();
54 script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
55
56 document.head.appendChild(script);
57 });
58 }
59 }
60
61 export { ModuleFederationManager, type RemoteModule };

Summary

This comprehensive guide covers building production-ready monorepos using pnpm, workspace, and changesets. Key takeaways:

✅ Benefits Achieved

  • 50%+ faster dependency installation with pnpm
  • Unified development experience across packages
  • Automated version management with changesets
  • Efficient CI/CD with incremental builds
  • Type-safe inter-package dependencies

🚀 Next Steps

  1. Start with a simple 2-3 package setup
  2. Implement automated testing and linting
  3. Set up CI/CD pipeline with GitHub Actions
  4. Add bundle analysis and performance monitoring
  5. Scale gradually with more packages

📚 Additional Resources

The monorepo approach with pnpm and changesets provides a solid foundation for scaling JavaScript/TypeScript projects while maintaining developer productivity and code quality.

JavaScript Development Space

JSDev Space – Your go-to hub for JavaScript development. Explore expert guides, best practices, and the latest trends in web development, React, Node.js, and more. Stay ahead with cutting-edge tutorials, tools, and insights for modern JS developers. 🚀

Join our growing community of developers! Follow us on social media for updates, coding tips, and exclusive content. Stay connected and level up your JavaScript skills with us! 🔥

© 2025 JavaScript Development Space - Master JS and NodeJS. All rights reserved.