Mastering Modern Monorepo Development with pnpm, Workspaces
29 May 202522 min read
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:
1 # Installation speed comparison2 npm install # ~45s3 yarn install # ~35s4 pnpm install # ~22s56 # Disk space usage7 npm: 130MB per project8 yarn: 125MB per project9 pnpm: 85MB total (shared across projects)
Key Advantages
- Disk Space Efficiency: Uses content-addressable storage
- Built-in Workspace Support: No additional tools required
- Strict Node Modules: Prevents phantom dependencies
- Superior Performance: Parallel installation and linking
- 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
1 # Check Node.js version (minimum v14.19.0 for pnpm v7+)2 node --version34 # Install pnpm globally5 npm install -g pnpm67 # Verify installation8 pnpm --version
Initial Project Structure
1 # Create project directory2 mkdir awesome-monorepo3 cd awesome-monorepo45 # Initialize root package.json6 pnpm init
Root Package Configuration
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:
1 packages:2 - 'packages/*'3 - 'apps/*'4 - 'tools/*'
Directory Structure
1 awesome-monorepo/2 ├── .changeset/3 │ └── config.json4 ├── apps/5 │ └── web-app/6 ├── packages/7 │ ├── ui-components/8 │ ├── utils/9 │ └── api-client/10 ├── tools/11 │ └── build-scripts/12 ├── package.json13 ├── pnpm-workspace.yaml14 ├── pnpm-lock.yaml15 └── README.md
Creating Packages
Package 1: UI Components Library
1 # Create packages directory2 mkdir -p packages/ui-components3 cd packages/ui-components45 # Initialize package6 pnpm init
packages/ui-components/package.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:
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:
1 import React from 'react';2 import { ButtonProps } from '../types';34 export const Button: React.FC<ButtonProps> = ({5 children,6 variant = 'primary',7 size = 'medium',8 onClick,9 disabled = false,10 ...props11 }) => {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 };2324 return (25 <button26 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
1 mkdir -p packages/utils2 cd packages/utils3 pnpm init
packages/utils/package.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:
1 // String utilities2 export const capitalize = (str: string): string =>3 str.charAt(0).toUpperCase() + str.slice(1);45 export const slugify = (str: string): string =>6 str7 .toLowerCase()8 .replace(/[^\w\s-]/g, '')9 .replace(/[\s_-]+/g, '-')10 .replace(/^-+|-+$/g, '');1112 // Array utilities13 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 };2021 export const unique = <T>(array: T[]): T[] => [...new Set(array)];2223 // Object utilities24 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 };3637 // Date utilities38 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');4243 return format44 .replace('YYYY', String(year))45 .replace('MM', month)46 .replace('DD', day);47 };4849 // Validation utilities50 export const isEmail = (email: string): boolean => {51 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;52 return emailRegex.test(email);53 };5455 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
1 mkdir -p packages/api-client2 cd packages/api-client3 pnpm init
packages/api-client/package.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:
1 import { isURL } from '@awesome/utils';23 export interface RequestConfig {4 method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';5 headers?: Record<string, string>;6 body?: any;7 timeout?: number;8 }910 export interface APIResponse<T = any> {11 data: T;12 status: number;13 statusText: string;14 headers: Record<string, string>;15 }1617 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 };2425 constructor(baseURL: string) {26 if (!isURL(baseURL)) {27 throw new Error('Invalid base URL provided');28 }2930 this.baseURL = baseURL.replace(/\/$/, '');31 this.defaultHeaders = {32 'Content-Type': 'application/json',33 };34 this.interceptors = {35 request: [],36 response: [],37 };38 }3940 // Add request interceptor41 addRequestInterceptor(interceptor: (config: RequestConfig) => RequestConfig) {42 this.interceptors.request.push(interceptor);43 }4445 // Add response interceptor46 addResponseInterceptor(interceptor: (response: APIResponse) => APIResponse) {47 this.interceptors.response.push(interceptor);48 }4950 // Set default headers51 setDefaultHeaders(headers: Record<string, string>) {52 this.defaultHeaders = { ...this.defaultHeaders, ...headers };53 }5455 // Generic request method56 async request<T = any>(endpoint: string, config: RequestConfig = {}): Promise<APIResponse<T>> {57 const url = `${this.baseURL}${endpoint}`;5859 // Apply request interceptors60 let finalConfig = { ...config };61 this.interceptors.request.forEach(interceptor => {62 finalConfig = interceptor(finalConfig);63 });6465 const requestOptions: RequestInit = {66 method: finalConfig.method || 'GET',67 headers: {68 ...this.defaultHeaders,69 ...finalConfig.headers,70 },71 };7273 if (finalConfig.body && finalConfig.method !== 'GET') {74 requestOptions.body = JSON.stringify(finalConfig.body);75 }7677 const controller = new AbortController();78 const timeout = finalConfig.timeout || 30000;79 const timeoutId = setTimeout(() => controller.abort(), timeout);8081 try {82 const response = await fetch(url, {83 ...requestOptions,84 signal: controller.signal,85 });8687 clearTimeout(timeoutId);8889 const responseData = await response.json();9091 let apiResponse: APIResponse<T> = {92 data: responseData,93 status: response.status,94 statusText: response.statusText,95 headers: Object.fromEntries(response.headers.entries()),96 };9798 // Apply response interceptors99 this.interceptors.response.forEach(interceptor => {100 apiResponse = interceptor(apiResponse);101 });102103 return apiResponse;104 } catch (error) {105 clearTimeout(timeoutId);106 throw error;107 }108 }109110 // Convenience methods111 get<T = any>(endpoint: string, config?: Omit<RequestConfig, 'method'>) {112 return this.request<T>(endpoint, { ...config, method: 'GET' });113 }114115 post<T = any>(endpoint: string, data?: any, config?: Omit<RequestConfig, 'method' | 'body'>) {116 return this.request<T>(endpoint, { ...config, method: 'POST', body: data });117 }118119 put<T = any>(endpoint: string, data?: any, config?: Omit<RequestConfig, 'method' | 'body'>) {120 return this.request<T>(endpoint, { ...config, method: 'PUT', body: data });121 }122123 delete<T = any>(endpoint: string, config?: Omit<RequestConfig, 'method'>) {124 return this.request<T>(endpoint, { ...config, method: 'DELETE' });125 }126 }127128 // Export a factory function129 export const createAPIClient = (baseURL: string) => new APIClient(baseURL);
Managing Dependencies
Global Dependencies
Install development dependencies that are shared across all packages:
1 # Development dependencies for the workspace root2 pnpm add -Dw typescript @types/node eslint prettier jest34 # Production dependencies for workspace root5 pnpm add -w lodash axios
Package-Specific Dependencies
Install dependencies for specific packages using the --filter
flag:
1 # Add React to UI components package2 pnpm add react react-dom --filter @awesome/ui-components34 # Add development dependencies to a specific package5 pnpm add -D @types/jest --filter @awesome/utils67 # Add multiple packages to multiple filters8 pnpm add lodash --filter @awesome/utils --filter @awesome/api-client
Advanced Filtering
1 # Install to all packages matching pattern2 pnpm add dayjs --filter "@awesome/*"34 # Install to packages in specific directory5 pnpm add --filter "./packages/**" some-package67 # Exclude specific packages8 pnpm add --filter "!@awesome/ui-components" some-package910 # 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:
1 # Add @awesome/utils as dependency to @awesome/api-client2 pnpm add @awesome/utils --filter @awesome/api-client34 # Use wildcard version for latest workspace version5 pnpm add @awesome/utils@workspace:* --filter @awesome/api-client
This creates the following in packages/api-client/package.json
:
1 {2 "dependencies": {3 "@awesome/utils": "workspace:*"4 }5 }
Example: Using Workspace Dependencies
packages/web-app/src/components/UserForm.tsx:
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';56 const apiClient = createAPIClient('https://api.example.com');78 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);1516 const validateForm = () => {17 const newErrors: Record<string, string> = {};1819 if (!formData.name.trim()) {20 newErrors.name = 'Name is required';21 }2223 if (!formData.email.trim()) {24 newErrors.email = 'Email is required';25 } else if (!isEmail(formData.email)) {26 newErrors.email = 'Invalid email format';27 }2829 setErrors(newErrors);30 return Object.keys(newErrors).length === 0;31 };3233 const handleSubmit = async (e: React.FormEvent) => {34 e.preventDefault();3536 if (!validateForm()) return;3738 setLoading(true);39 try {40 const response = await apiClient.post('/users', {41 name: capitalize(formData.name),42 email: formData.email.toLowerCase(),43 });4445 console.log('User created:', response.data);46 // Handle success47 } catch (error) {48 console.error('Error creating user:', error);49 // Handle error50 } finally {51 setLoading(false);52 }53 };5455 return (56 <form onSubmit={handleSubmit} className="space-y-4">57 <div>58 <Input59 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>6667 <div>68 <Input69 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>7677 <Button78 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:
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:
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:
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';56 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
1 # Install changesets2 pnpm add -Dw @changesets/cli34 # Initialize changesets5 pnpm changeset init
Changesets Configuration
.changeset/config.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
1 # Create a changeset2 pnpm changeset34 # Example interactive flow:5 # ? Which packages would you like to include?6 # ✓ @awesome/ui-components7 # ✓ @awesome/utils8 #9 # ? Which packages should have a major bump?10 # (none selected)11 #12 # ? Which packages should have a minor bump?13 # ✓ @awesome/ui-components14 #15 # ? Which packages should have a patch bump?16 # ✓ @awesome/utils17 #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:
1 ---2 "@awesome/ui-components": minor3 "@awesome/utils": patch4 ---56 Add new Button variants and fix utility functions78 - Added danger and success variants to Button component9 - Fixed edge case in slugify utility function10 - Updated TypeScript types for better inference
Version Bumping and Publishing
1 # Update package versions based on changesets2 pnpm version-packages34 # Build all packages5 pnpm build67 # Publish to npm8 pnpm changeset publish910 # Or publish with custom registry11 pnpm changeset publish --registry https://npm.your-company.com
Pre-release Workflow
1 # Enter pre-release mode2 pnpm changeset pre enter beta34 # Create changesets as normal5 pnpm changeset6 pnpm version-packages # Results in 1.0.0-beta.178 # Continue development9 pnpm changeset10 pnpm version-packages # Results in 1.0.0-beta.21112 # Exit pre-release mode13 pnpm changeset pre exit14 pnpm changeset15 pnpm version-packages # Results in 1.0.0 (stable)
Automated Scripts
Add these scripts to your root package.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:
1 name: CI/CD Pipeline23 on:4 push:5 branches: [main, develop]6 pull_request:7 branches: [main]89 env:10 NODE_VERSION: '18'11 PNPM_VERSION: '8.15.0'1213 jobs:14 test:15 runs-on: ubuntu-latest1617 steps:18 - name: Checkout code19 uses: actions/checkout@v420 with:21 fetch-depth: 02223 - name: Setup Node.js24 uses: actions/setup-node@v425 with:26 node-version: ${{ env.NODE_VERSION }}2728 - name: Setup pnpm29 uses: pnpm/action-setup@v230 with:31 version: ${{ env.PNPM_VERSION }}3233 - name: Get pnpm store directory34 shell: bash35 run: |36 echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV3738 - name: Setup pnpm cache39 uses: actions/cache@v340 with:41 path: ${{ env.STORE_PATH }}42 key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}43 restore-keys: |44 ${{ runner.os }}-pnpm-store-4546 - name: Install dependencies47 run: pnpm install --frozen-lockfile4849 - name: Lint code50 run: pnpm lint5152 - name: Run tests53 run: pnpm test5455 - name: Build packages56 run: pnpm build5758 - name: Check for build artifacts59 run: |60 for package in packages/*/dist; do61 if [ ! -d "$package" ]; then62 echo "Build artifact missing: $package"63 exit 164 fi65 done6667 release:68 needs: test69 runs-on: ubuntu-latest70 if: github.ref == 'refs/heads/main'7172 steps:73 - name: Checkout code74 uses: actions/checkout@v475 with:76 fetch-depth: 077 token: ${{ secrets.GITHUB_TOKEN }}7879 - name: Setup Node.js80 uses: actions/setup-node@v481 with:82 node-version: ${{ env.NODE_VERSION }}83 registry-url: 'https://registry.npmjs.org'8485 - name: Setup pnpm86 uses: pnpm/action-setup@v287 with:88 version: ${{ env.PNPM_VERSION }}8990 - name: Install dependencies91 run: pnpm install --frozen-lockfile9293 - name: Build packages94 run: pnpm build9596 - name: Create Release Pull Request or Publish97 id: changesets98 uses: changesets/action@v199 with:100 publish: pnpm changeset:publish101 title: 'chore: release packages'102 commit: 'chore: release packages'103 env:104 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}105 NPM_TOKEN: ${{ secrets.NPM_TOKEN }}106107 - name: Send Slack notification108 if: steps.changesets.outputs.published == 'true'109 uses: 8398a7/action-slack@v3110 with:111 status: success112 text: 'New packages published! 🎉'113 env:114 SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Conditional Testing
.github/workflows/test-affected.yml:
1 name: Test Affected Packages23 on:4 pull_request:5 branches: [main]67 jobs:8 affected:9 runs-on: ubuntu-latest1011 steps:12 - uses: actions/checkout@v413 with:14 fetch-depth: 01516 - name: Setup Node.js and pnpm17 # ... setup steps ...1819 - name: Install dependencies20 run: pnpm install --frozen-lockfile2122 - name: Test affected packages23 run: |24 # Get changed files25 CHANGED_FILES=$(git diff --name-only origin/main...HEAD)2627 # Test packages with changes28 if echo "$CHANGED_FILES" | grep -q "packages/ui-components/"; then29 pnpm --filter @awesome/ui-components test30 fi3132 if echo "$CHANGED_FILES" | grep -q "packages/utils/"; then33 pnpm --filter @awesome/utils test34 fi3536 if echo "$CHANGED_FILES" | grep -q "packages/api-client/"; then37 pnpm --filter @awesome/api-client test38 fi3940 - name: Build affected packages41 run: |42 # Similar logic for building only affected packages43 # This saves CI time for large monorepos
Best Practices and Troubleshooting
Package Naming Conventions
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
-
Use workspace protocol for internal dependencies:
json1 {2 "dependencies": {3 "@awesome/utils": "workspace:*"4 }5 } -
Hoist common dependencies to workspace root:
bash1 # Install shared dev dependencies at root2 pnpm add -Dw typescript eslint prettier jest34 # Install shared production dependencies at root5 pnpm add -w lodash date-fns -
Pin exact versions for critical dependencies:
json1 {2 "dependencies": {3 "react": "18.2.0",4 "typescript": "4.9.4"5 }6 } -
Use peer dependencies for shared libraries:
json1 {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:
1 // Component exports2 export { Button } from './Button';3 export { Input } from './Input';4 export { Modal } from './Modal';5 export { Card } from './Card';6 export { Table } from './Table';78 // Hook exports9 export { useToggle } from './hooks/useToggle';10 export { useLocalStorage } from './hooks/useLocalStorage';1112 // Type exports13 export type { ButtonProps, InputProps, ModalProps } from './types';1415 // Utility exports16 export { theme } from './theme';17 export { cn } from './utils/classNames';
Shared Configuration
tools/eslint-config/index.js:
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: true19 }20 };
tools/tsconfig/base.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": true18 }19 }
Testing Strategies
Shared Test Configuration
jest.config.base.js:
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: 8020 }21 },22 moduleNameMapping: {23 '^@/(.*)24 : '<rootDir>/src/$1'25 }26 };
Package-Specific Test Configuration
packages/ui-components/jest.config.js:
1 const baseConfig = require('../../jest.config.base');23 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:
1 import { createAPIClient } from '@awesome/api-client';2 import { isEmail } from '@awesome/utils';34 describe('API Client Integration', () => {5 const mockServer = 'https://jsonplaceholder.typicode.com';6 const client = createAPIClient(mockServer);78 beforeAll(() => {9 // Add auth interceptor10 client.addRequestInterceptor((config) => ({11 ...config,12 headers: {13 ...config.headers,14 'Authorization': 'Bearer test-token'15 }16 }));1718 // Add logging interceptor19 client.addResponseInterceptor((response) => {20 console.log(`API Response: ${response.status}`);21 return response;22 });23 });2425 test('should fetch users successfully', async () => {26 const response = await client.get('/users');2728 expect(response.status).toBe(200);29 expect(Array.isArray(response.data)).toBe(true);30 expect(response.data.length).toBeGreaterThan(0);3132 // Test integration with utils package33 const firstUser = response.data[0];34 expect(isEmail(firstUser.email)).toBe(true);35 });3637 test('should handle POST requests', async () => {38 const newUser = {39 name: 'John Doe',40 email: 'john@example.com'41 };4243 const response = await client.post('/users', newUser);4445 expect(response.status).toBe(201);46 expect(response.data.name).toBe(newUser.name);47 });4849 test('should handle errors gracefully', async () => {50 await expect(client.get('/nonexistent'))51 .rejects52 .toThrow();53 });54 });
Performance Optimization
Build Optimization
tools/build-scripts/optimize-build.js:
1 const { execSync } = require('child_process');2 const path = require('path');3 const fs = require('fs');45 class BuildOptimizer {6 constructor(workspaceRoot) {7 this.workspaceRoot = workspaceRoot;8 this.packagesDir = path.join(workspaceRoot, 'packages');9 }1011 // Get list of changed packages since last commit12 getChangedPackages() {13 try {14 const changedFiles = execSync('git diff --name-only HEAD~1', {15 encoding: 'utf8'16 }).split('\n');1718 const changedPackages = new Set();1920 changedFiles.forEach(file => {21 const match = file.match(/^packages\/([^\/]+)\//);22 if (match) {23 changedPackages.add(match[1]);24 }25 });2627 return Array.from(changedPackages);28 } catch (error) {29 console.log('Unable to detect changes, building all packages');30 return this.getAllPackages();31 }32 }3334 // Get all packages in the workspace35 getAllPackages() {36 return fs.readdirSync(this.packagesDir, { withFileTypes: true })37 .filter(dirent => dirent.isDirectory())38 .map(dirent => dirent.name);39 }4041 // Build only changed packages and their dependents42 buildChanged() {43 const changedPackages = this.getChangedPackages();4445 if (changedPackages.length === 0) {46 console.log('No package changes detected');47 return;48 }4950 console.log('Building changed packages:', changedPackages.join(', '));5152 // Build changed packages53 changedPackages.forEach(pkg => {54 console.log(`Building @awesome/${pkg}...`);55 execSync(`pnpm --filter @awesome/${pkg} run build`, {56 stdio: 'inherit'57 });58 });5960 // Build packages that depend on changed packages61 this.buildDependents(changedPackages);62 }6364 // Build packages that depend on the changed packages65 buildDependents(changedPackages) {66 const allPackages = this.getAllPackages();67 const dependents = new Set();6869 allPackages.forEach(pkg => {70 const pkgJsonPath = path.join(this.packagesDir, pkg, 'package.json');7172 if (fs.existsSync(pkgJsonPath)) {73 const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));74 const deps = {75 ...pkgJson.dependencies,76 ...pkgJson.devDependencies77 };7879 changedPackages.forEach(changedPkg => {80 if (deps[`@awesome/${changedPkg}`]) {81 dependents.add(pkg);82 }83 });84 }85 });8687 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 }9798 // Usage99 const optimizer = new BuildOptimizer(process.cwd());100 optimizer.buildChanged();
Bundle Analysis
tools/analyze-bundle.js:
1 const { exec } = require('child_process');2 const path = require('path');3 const fs = require('fs');45 class BundleAnalyzer {6 constructor() {7 this.packages = this.getPackages();8 }910 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 }1819 analyzeBundleSizes() {20 console.log('📦 Bundle Size Analysis\n');2122 this.packages.forEach(pkg => {23 const distPath = path.join(process.cwd(), 'packages', pkg, 'dist');2425 if (fs.existsSync(distPath)) {26 const files = fs.readdirSync(distPath);27 let totalSize = 0;2829 console.log(`\n📋 @awesome/${pkg}:`);3031 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;3637 console.log(` ${file}: ${sizeKB}KB`);38 });3940 const totalKB = (totalSize / 1024).toFixed(2);41 console.log(` Total: ${totalKB}KB`);4243 // Warn if bundle is too large44 if (totalSize > 100 * 1024) { // 100KB45 console.log(` ⚠️ Large bundle detected!`);46 }47 }48 });49 }5051 checkTreeShaking() {52 console.log('\n🌳 Tree Shaking Analysis\n');5354 this.packages.forEach(pkg => {55 const pkgPath = path.join(process.cwd(), 'packages', pkg);56 const distPath = path.join(pkgPath, 'dist');5758 if (fs.existsSync(distPath)) {59 // Check if ES modules are available60 const esmFile = path.join(distPath, 'index.esm.js');61 const cjsFile = path.join(distPath, 'index.js');6263 console.log(`📦 @awesome/${pkg}:`);64 console.log(` ESM: ${fs.existsSync(esmFile) ? '✅' : '❌'}`);65 console.log(` CJS: ${fs.existsSync(cjsFile) ? '✅' : '❌'}`);6667 // Check for sideEffects field68 const pkgJsonPath = path.join(pkgPath, 'package.json');69 const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));7071 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 }8283 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:
1 # Clear all node_modules and reinstall2 pnpm clean3 pnpm install45 # Check dependency tree6 pnpm list --depth=278 # Force resolve specific version9 pnpm add package-name@exact-version --filter @awesome/package-name
2. Build Failures
Problem: TypeScript compilation errors across packages
Solution:
1 # Build packages in dependency order2 pnpm --filter @awesome/utils run build3 pnpm --filter @awesome/api-client run build4 pnpm --filter @awesome/ui-components run build56 # Or use topological sorting7 pnpm --recursive run build
Enhanced TypeScript Configuration:
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:
1 // tools/detect-circular-deps.js2 const madge = require('madge');3 const path = require('path');45 async function detectCircularDependencies() {6 const packagesDir = path.join(process.cwd(), 'packages');78 try {9 const res = await madge(packagesDir, {10 fileExtensions: ['ts', 'tsx', 'js', 'jsx'],11 tsConfig: 'tsconfig.json'12 });1314 const circular = res.circular();1516 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 }3031 detectCircularDependencies();
4. Publishing Issues
Problem: Packages not publishing or wrong versions
Solution:
1 # Check package contents before publishing2 pnpm pack --dry-run --filter @awesome/package-name34 # Verify changeset configuration5 pnpm changeset status67 # Manual version bump if needed8 pnpm changeset version --ignore @awesome/package-name
5. Workspace Protocol Issues
Problem: workspace:*
not resolving correctly
Solution:
1 {2 "pnpm": {3 "overrides": {4 "@awesome/utils": "workspace:*"5 }6 }7 }
Advanced Patterns
Dynamic Package Loading
packages/plugin-system/src/index.ts:
1 interface Plugin {2 name: string;3 version: string;4 activate: () => void;5 deactivate: () => void;6 }78 class PluginManager {9 private plugins = new Map<string, Plugin>();10 private activePlugins = new Set<string>();1112 async loadPlugin(packageName: string): Promise<void> {13 try {14 // Dynamic import for workspace packages15 const pluginModule = await import(packageName);16 const plugin: Plugin = pluginModule.default || pluginModule;1718 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 }2425 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 }3334 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 }4243 getActivePlugins(): string[] {44 return Array.from(this.activePlugins);45 }46 }4748 export { PluginManager, type Plugin };
Micro-Frontend Architecture
packages/shell-app/src/ModuleFederation.ts:
1 interface RemoteModule {2 name: string;3 url: string;4 scope: string;5 module: string;6 }78 class ModuleFederationManager {9 private remotes = new Map<string, RemoteModule>();10 private loadedModules = new Map<string, any>();1112 registerRemote(remote: RemoteModule): void {13 this.remotes.set(remote.name, remote);14 }1516 async loadRemote(remoteName: string): Promise<any> {17 if (this.loadedModules.has(remoteName)) {18 return this.loadedModules.get(remoteName);19 }2021 const remote = this.remotes.get(remoteName);22 if (!remote) {23 throw new Error(`Remote module not found: ${remoteName}`);24 }2526 try {27 // Load remote module script28 await this.loadScript(remote.url);2930 // Get the remote container31 const container = (window as any)[remote.scope];32 await container.init(__webpack_share_scopes__.default);3334 // Get the module factory35 const factory = await container.get(remote.module);36 const module = factory();3738 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 }4546 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;5253 script.onload = () => resolve();54 script.onerror = () => reject(new Error(`Failed to load script: ${url}`));5556 document.head.appendChild(script);57 });58 }59 }6061 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
- Start with a simple 2-3 package setup
- Implement automated testing and linting
- Set up CI/CD pipeline with GitHub Actions
- Add bundle analysis and performance monitoring
- 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.