Mastering Modern Monorepo Development with Pnpm, Workspaces

4 months ago 22
JavaScript Development SpaceHowtosSnippetsFriday
  1. Home
  2. Nodejs
  3. Complete Monorepo Guide: pnpm + Workspace + Changesets (2025)
29 May 202522 min read

 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:

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

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

1 # Create project directory

2 mkdir awesome-monorepo

3 cd awesome-monorepo

4

5 # Initialize root package.json

6 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.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

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:

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';

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

1 mkdir -p packages/utils

2 cd packages/utils

3 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

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

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

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

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

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

1 mkdir -p packages/api-client

2 cd packages/api-client

3 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';

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

41 addRequestInterceptor(interceptor: (config: RequestConfig) => RequestConfig) {

42 this.interceptors.request.push(interceptor);

43 }

44

45

46 addResponseInterceptor(interceptor: (response: APIResponse) => APIResponse) {

47 this.interceptors.response.push(interceptor);

48 }

49

50

51 setDefaultHeaders(headers: Record<string, string>) {

52 this.defaultHeaders = { ...this.defaultHeaders, ...headers };

53 }

54

55

56 async request<T = any>(endpoint: string, config: RequestConfig = {}): Promise<APIResponse<T>> {

57 const url = `${this.baseURL}${endpoint}`;

58

59

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

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

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

129 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 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:

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

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:

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:

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';

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

47 } catch (error) {

48 console.error('Error creating user:', error);

49

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

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';

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

1 # Install changesets

2 pnpm add -Dw @changesets/cli

3

4 # Initialize changesets

5 pnpm changeset init

Changesets Configuration

.changeset/config.json:

1 {

2 "$schema": "https://unpkg.com/@changesets/[email protected]/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 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:

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

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

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:

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 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:

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

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

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

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:

    1 {

    2 "dependencies": {

    3 "@awesome/utils": "workspace:*"

    4 }

    5 }

  2. Hoist common dependencies to workspace root:

    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:

    1 {

    2 "dependencies": {

    3 "react": "18.2.0",

    4 "typescript": "4.9.4"

    5 }

    6 }

  4. Use peer dependencies for shared libraries:

    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:

1

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

9 export { useToggle } from './hooks/useToggle';

10 export { useLocalStorage } from './hooks/useLocalStorage';

11

12

13 export type { ButtonProps, InputProps, ModalProps } from './types';

14

15

16 export { theme } from './theme';

17 export { cn } from './utils/classNames';

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: true

19 }

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": true

18 }

19 }

Testing Strategies

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: 80

20 }

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');

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:

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

10 client.addRequestInterceptor((config) => ({

11 ...config,

12 headers: {

13 ...config.headers,

14 'Authorization': 'Bearer test-token'

15 }

16 }));

17

18

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

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: '[email protected]'

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:

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

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

35 getAllPackages() {

36 return fs.readdirSync(this.packagesDir, { withFileTypes: true })

37 .filter(dirent => dirent.isDirectory())

38 .map(dirent => dirent.name);

39 }

40

41

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

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

61 this.buildDependents(changedPackages);

62 }

63

64

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

99 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');

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

44 if (totalSize > 100 * 1024) {

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

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

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:

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:

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:

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

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:

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:

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 }

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

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:

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

28 await this.loadScript(remote.url);

29

30

31 const container = (window as any)[remote.scope];

32 await container.init(__webpack_share_scopes__.default);

33

34

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.

Read Entire Article