Create a monorepo using PNPM workspace

vinomanick

Vinodh Kumar

Posted on August 15, 2023

Create a monorepo using PNPM workspace

Objective

To create a mono repo using the PNPM package manager and its workspace feature.

The main advantage of the PNPM workspace when compared to the yarn workspace is common packages are not hoisted to the root directory thereby making all the workspace packages completely isolated.

Technologies/Features used

The mono repo we are going to build will have the following features. Again this is my set of tools feel free to change it based on your preference.

Feature Technology used
Package manager PNPM
Programming language Typescript
Basic linting ESLint
Code formatting Prettier
Pre-commit hook validator Husky
Linting only staged files lint-staged
Lint git commit subject commitlint

Prerequisites

Tools

You will need the following things properly installed on your computer.

PNPM install

  • If you have installed the latest v16.x or greater node version in your system, then enable the pnpm using the below cmd
corepack enable
corepack prepare pnpm@latest --activate
Enter fullscreen mode Exit fullscreen mode
  • If you are using a lower version of the node in your local system then check this page for additional installation methods https://pnpm.io/installation

Repo basic setup

  • Initialize git if you want and enforce the node version with some info in the README.md.
  mkdir pnpm-monorepo
  cd pnpm-monorepo
  pnpm init
  git init
  echo -e "node_modules" > .gitignore
  npm pkg set engines.node=">=22.11.0" // Use the same node version you installed
  npm pkg set type="module"
  echo "#PNPM monorepo" > README.md
Enter fullscreen mode Exit fullscreen mode
  • Specify the latest PNPM version to use for this project by setting the packageManager property in the package.json file.
  npm pkg set packageManager="pnpm@9.12.3"
  pnpm -v
Enter fullscreen mode Exit fullscreen mode
  • Run pnpm -v to check whether the desired version of PNPM is installed correctly. If the version is not available in your system then it will automatically ask you to download it from web, simply enter y to download it.
! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-9.12.3.tgz
? Do you want to continue? [Y/n]
Enter fullscreen mode Exit fullscreen mode

Code formatter

I'm going with Prettier to format the code. Formatting helps us to keep our code uniform for every developer.

Installation

  • Let's install the plugin and set some defaults. Here I'm setting the single quote to be true, update it according to your preference.
  pnpm add -D prettier
  echo '{\n  "singleQuote": true\n}' > .prettierrc.json
  echo -e "coverage\npublic\ndist\npnpm-lock.yaml\npnpm-workspace.yaml" > .prettierignore
Enter fullscreen mode Exit fullscreen mode

VS Code plugin

  • If you are using VS Code, then navigate to the Extensions and search for Prettier - Code formatter and install the extension.

Extension link: https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode

  • Let's update the workspace to use the prettier as the default formatter and automatically format the file on save.

  • Create the VS Code workspace settings JSON and update it with the following content.

mkdir .vscode && touch .vscode/settings.json
Enter fullscreen mode Exit fullscreen mode
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode"
}
Enter fullscreen mode Exit fullscreen mode

Linting

Linter statically analyses your code to quickly find problems. ESLint is the most preferred tool for linting the Javascript code.

ESLint

  pnpm create @eslint/config@latest
Enter fullscreen mode Exit fullscreen mode
  • The ESLint will ask you a set of questions to set up the linter as per your needs. This is the configuration I've chosen for this project.
? How would you like to use ESLint? …
  To check syntax only
❯ To check syntax and find problems

? What type of modules does your project use? …
❯ JavaScript modules (import/export)
  CommonJS (require/exports)
  None of these

? Which framework does your project use? …
  React
  Vue.js
❯ None of these

Does your project use TypeScript? › No / Yes
- Yes

Where does your code run?
✔ Browser
  Node

The config that you`ve selected requires the following dependencies:
eslint, globals, @eslint/js, typescript-eslint

? Would you like to install them now? › No / Yes
- Yes

? Which package manager do you want to use? …
  npm
  yarn
❯ pnpm
  bun
Enter fullscreen mode Exit fullscreen mode
  • Update the eslint config with the ignores list to let the ESLint know which files to not format.
/** @type {import('eslint').Linter.Config[]} */
export default [
  ...
  {
    // Note: there should be no other properties in this object
    ignores: ['coverage', '**/public', '**/dist', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'],
  },
  ...
]
Enter fullscreen mode Exit fullscreen mode

Integrating Prettier with ESLint

Linters usually contain not only code quality rules but also stylistic rules. Most stylistic rules are unnecessary when using Prettier, but worse – they might conflict with Prettier!

We are going to use Prettier for code formatting concerns, and linters for code-quality concerns. So let's make the linter run the stylistic rules of 'Prettier' instead.

  • Install the necessary plugins
  pnpm add -D eslint-config-prettier eslint-plugin-prettier
Enter fullscreen mode Exit fullscreen mode
  • Add the eslintPluginPrettierRecommended as the last element in the array.
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'

/** @type {import('eslint').Linter.Config[]} */
export default [
  ...,
  eslintPluginPrettierRecommended,
]
Enter fullscreen mode Exit fullscreen mode

For more info on this: https://prettier.io/docs/en/integrating-with-linters.html

  • Let's create scripts for running the linter and prettier in the package.json file.
  npm pkg set scripts.lint="eslint ."
  npm pkg set scripts.format="prettier --write ."
Enter fullscreen mode Exit fullscreen mode
  • Run the pnpm lint cmd to run the ESLint and pnpm format cmd to format the files.

Pre-commit hook validation

Even if we added all these linter and formatter mechanisms to maintain the code quality, we can't expect all the developers to use the same editor and execute the lint and format command whenever they are pushing their code.

To automate that we need some kind of pre-commit hook validation. That's where husky and lint-staged plugins come in handy let's install and set them up.

  • Install the husky, commitlint, and lint-staged NPM package and initialize it as shown below,
  pnpm add -D @commitlint/cli @commitlint/config-conventional
  echo -e "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.mjs
  pnpm add -D husky lint-staged
  pnpm exec husky init
  echo "pnpm lint-staged" > .husky/pre-commit
  echo "npx --no -- commitlint --edit \${1}" > .husky/commit-msg
Enter fullscreen mode Exit fullscreen mode
  • Update the package.json file and include the following property. This will run the ESLint on all the script files and Prettier on the other files.
  "lint-staged": {
    "**/*.{js,ts,tsx}": [
      "eslint --fix"
    ],
    "**/*": "prettier --write --ignore-unknown"
  },
Enter fullscreen mode Exit fullscreen mode

Workspace config

  • Create pnpm-workspace.yaml file and add the following content
touch pnpm-workspace.yaml
Enter fullscreen mode Exit fullscreen mode
packages:
  - 'apps/*'
  - 'packages/*'
Enter fullscreen mode Exit fullscreen mode
  • Create the apps and packages directories in the root.
mkdir apps packages
Enter fullscreen mode Exit fullscreen mode

Sample package - Common

  • Create a sample package that can be used in the workspace apps.
cd packages
pnpm create vite common --template vanilla-ts
cd ../
pnpm install
npm pkg set scripts.common="pnpm --filter common"
Enter fullscreen mode Exit fullscreen mode
  • Update the main.ts file with the following content to create a simple isBlank util.
/* eslint-disable @typescript-eslint/no-explicit-any */
export const isEmpty = (data: any) => data === null || data === undefined

export const isObject = (data: any) => data && typeof data === 'object'

export const isBlank = (data: any) =>
  isEmpty(data) ||
  (Array.isArray(data) && data.length === 0) ||
  (isObject(data) && Object.keys(data).length === 0) ||
  (typeof data === 'string' && data.trim().length === 0)
Enter fullscreen mode Exit fullscreen mode
  • Delete the sample files
cd packages/common
rm -rf src/style.css src/counter.ts .gitignore
cd ../../
Enter fullscreen mode Exit fullscreen mode

Library mode

Vite by default builds the assets in app mode with index.html as the entry file. But we want our app to expose our main.ts file as the entry file, so let's update the Vite config to support that.

  • Before that let's install the Vite package to auto-generate the type definitions from the library.
pnpm common add -D vite-plugin-dts
Enter fullscreen mode Exit fullscreen mode
  • Create the vite.config.ts file and update it like this,
 touch packages/common/vite.config.ts
Enter fullscreen mode Exit fullscreen mode
import { defineConfig } from 'vite'
import { resolve } from 'path'
import dts from 'vite-plugin-dts'

// https://vitejs.dev/config/
export default defineConfig({
  build: { lib: { entry: resolve(__dirname, 'src/main.ts'), formats: ['es'] } },
  resolve: { alias: { src: resolve('src/') } },
  plugins: [dts()],
})
Enter fullscreen mode Exit fullscreen mode

The resolve property helps us to use absolute import paths instead of relative ones. For example:

import { add } from 'src/utils/arithmetic'
Enter fullscreen mode Exit fullscreen mode
  • Update the common package package.json file with the entry file for our script as well as the typings.
{
 ...
 "main": "./dist/common.js",
 "types": "./dist/main.d.ts",
}
Enter fullscreen mode Exit fullscreen mode

Sample app - Web app

  • Create a sample app that can make use of the workspace package common.
cd apps
pnpm create vite web-app --template react-ts
cd ../
pnpm install
npm pkg set scripts.app="pnpm --filter web-app"
Enter fullscreen mode Exit fullscreen mode
  • Install the common package as a dependency in our web app by updating the web-app package.json.
"dependencies": {
 "common": "workspace:*",
 ...
 }
Enter fullscreen mode Exit fullscreen mode
  • Run pnpm install again so that 'web-app' can symlink the common package present in the workspace

  • Run pnpm common build so that the common package can be found by the web-app server.

  • Update the App.tsx like below,

import { isBlank } from 'common'

const App = () => {
  return (
    <>
      <p>undefined isBlank - {isBlank(undefined) ? 'true' : 'false'}</p>
      <p>false isBlank - {isBlank(false) ? 'true' : 'false'}</p>
      <p>true isBlank - {isBlank(true) ? 'true' : 'false'}</p>
      <p>Empty object isBlank - {isBlank({}) ? 'true' : 'false'}</p>
    </>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode
  • Run pnpm app dev and check whether the common package util is successfully linked to the app.

That's it. We have successfully created a PNPM mono repo from scratch with typescript support.

Dev mode

  • Most of the time, you just need to build the common package once and use it in the repo apps. But if you are actively making changes in your common package and want to see that in the 'web-app' immediately you can't build the common app again and again for every change.

To avoid this, let's run the common package in watch mode so that any change in the code will rebuild automatically and reflect in the 'web-app' in real-time.

  • Run these commands in different terminals.
pnpm common build --watch
pnpm web-app dev
Enter fullscreen mode Exit fullscreen mode

Linter update

  • Now we have a typescript utility library and a react application in our mono-repo. In the ESLint v9.x the ESLint team recommends keeping a single linter file for all the packages but that will not go well with the monorepo architecture, see the discussion for more info. The ESLint team acknowledged this and built a experimental feature to support multiple config files which will be stable in v10.x, you can enable the feature and try it out but for production apps that is not recommended.

  • So for now we will try to merge the web app linting config created using the vite starter template to the root config file by moving the linting dependencies from web app to the root and updating the config file.

pnpm add -D -w eslint-plugin-react-hooks eslint-plugin-react-refresh eslint-plugin-react
pnpm app remove eslint-plugin-react-hooks eslint-plugin-react-refresh eslint @eslint/js globals typescript-eslint
Enter fullscreen mode Exit fullscreen mode
  • This is how the final eslint config will look like,
import globals from 'globals'
import pluginJs from '@eslint/js'
import tseslint from 'typescript-eslint'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import react from 'eslint-plugin-react'

/** @type {import('eslint').Linter.Config[]} */
export default [
  { files: ['**/*.{js,mjs,cjs,ts}'] },
  {
    languageOptions: { ecmaVersion: 2020, globals: globals.browser },
  },
  {
    // Note: there should be no other properties in this object
    ignores: ['coverage', '**/public', '**/dist', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'],
  },
  {
    files: ['apps/web-app/**/*.{ts,tsx}'],
    settings: { react: { version: '18.3' } },
    languageOptions: {
      // other options...
      parserOptions: {
        project: ['./tsconfig.node.json', './tsconfig.app.json'],
        tsconfigRootDir: './apps/web-app',
      },
    },
    plugins: {
      react,
      'react-hooks': reactHooks,
      'react-refresh': reactRefresh,
    },
    rules: {
      ...react.configs.recommended.rules,
      ...react.configs['jsx-runtime'].rules,
      ...reactHooks.configs.recommended.rules,
      'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
    },
  },
  pluginJs.configs.recommended,
  ...tseslint.configs.recommended,
  eslintPluginPrettierRecommended,
]
Enter fullscreen mode Exit fullscreen mode
  • Feel free to remove the eslint.config.js present in the web-app package as it is no longer needed or comment it out and keep it for reference.

  • Checkout the web app Readme.md file created by Vite and follow the recommendations if needed.

Advantages:

  • All your code will be in one single repo with proper isolation.
  • Only a one-time effort is needed to set up the repo with proper linting, formatting, and pre-commit hook validations which will be extended by the workspace packages.
  • All the packages will have a similar setup, look and feel.

Tips:

  • Check out my blog on creating a TS Util library and React app for creating repo packages with all the bells and whistles. Ignore the prettier, pre-commit hook validations in those packages as they are already handled in the root workspace of this mono repo.

Sample repo

The code for this post is hosted in Github here

Please take a look at the Github repo and let me know your feedback, and queries in the comments section.

💖 💪 🙅 🚩
vinomanick
Vinodh Kumar

Posted on August 15, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Create a monorepo using PNPM workspace
javascript Create a monorepo using PNPM workspace

August 15, 2023