Building a Monorepo with pnpm and Turborepo: A Journey to Efficiency

Vinayak Hegde
5 min readAug 23, 2024

--

Intro

Is this your current state?

  • You have 10+ npm packages spread across individual repositories.
  • Current setup leading to inconsistencies and inefficiencies, slowing down development significantly.
  • You realised that you could no longer move fast and maintain quality in this fragmented environment.

To solve these issues, you may need to decide to move to a monorepo.

Being 20 years in the and performing tons of digital transformations, my recommendation for you (at the time of writing this article) to choose `pnpm` as our package manager and `Turborepo` as our build system. In this article, I will explain how I helped organisations to configured them to work seamlessly together.

Problem

To understand why I recommend a monorepo, it’s important to consider the challenges that organisations faced.

  • Dependency management: Managing dependencies manually between packages was cumbersome. For example, if `package-a` depended on `package-b`, updating `package-a` when `package-b` was updated required manual intervention. Ideally, this should have been automated.
  • CI/CD: With packages housed in separate repositories, each had its own CI/CD pipeline for testing, building, versioning, and releasing. This was inefficient, hard to maintain, and nearly impossible to standardise, leading to inconsistent quality across our npm packages. There is a need for a standardised CI/CD setup across all packages.
  • Refactoring: The interdependence of packages made synchronised development challenging. People often resorted to creating symlinks and copying code between folders, which significantly slowed down development. There is a need for a single process to watch all packages, rebuilding and linking them automatically during development.
  • Consistency: Each package had its own ESLint and Vite configuration. If one package’s configuration changed, People had to manually update the other packages, which often led to mismatches and further slowed down development. Since most packages required similar configurations, centralising these into a single source of truth was crucial.
  • Siloed development: Little collaboration and knowledge sharing occurred among team members because each engineer tended to “own” their repository in a siloed manner. By moving to a monorepo, I see an opportunity to foster collaboration and share expertise more effectively.
  • Reusability: Our packages frequently needed the same helpers or followed similar patterns. With separate repositories, this led to code duplication and varied solutions for the same problems, decreasing code quality and increasing engineering overhead. A monorepo would allow shared code and patterns to be easily accessible across all packages.
  • Visibility: Managing 10+ repositories made it difficult for our community to find the right package to explore, contribute to, or raise issues. It was also challenging to get an overview of the different packages people maintained. I wanted to make it easier for developers to find and contribute to our code through a single entry point.

These were the problems I aimed to solve by moving to a monorepo.

Package manager: pnpm

When choosing a package manager, I needed one that supported workspaces. Yarn is a strong candidate due to its out-of-the-box workspace support, but I found that performance became an issue as the number of packages grew.

After evaluating npm, Yarn, and pnpm, pnpm stood out as the fastest option, particularly in a monorepo setup. Here’s a recap of my performance testing:

  • Installation time: pnpm consistently installed dependencies faster than the alternatives, which was crucial for maintaining developer productivity.
  • Node modules size: pnpm reduced the overall size of the `node_modules/` directory, which helped to optimise storage and speed up operations.

Workspaces with pnpm

Setting up pnpm workspaces is straightforward. You only need a root `package.json` file and a `pnpm-workspace.yaml` that defines the location of the child packages. Here’s what our `pnpm-workspace.yaml` looks like:

packages:
- 'apps/**'
- 'packages/**'

To ensure all contributors use pnpm, I configured a preinstall script to enforce this:

"preinstall": "npx only-allow pnpm"

Build System: Turborepo

There are numerous tools available for managing monorepos in the JavaScript ecosystem, such as Nx, Lerna, and the workspace features of npm, Yarn, and pnpm.

I initially considered Lerna but had mixed feelings due to past experiences and concerns about its maintenance. It also performed slower than both Nx and Turborepo during our testing.

This left me with two main options: Nx and Turborepo.

Setting up a perfect monorepo is challenging, especially when you need to build packages for various targets in JavaScript and TypeScript, including ES modules, CommonJS, and UMD. In some organisations, they also needed compatibility with frameworks like React, Vue, Svelte, and others that might emerge in the future.

After some exploration and testing, I found that Nx’s steep learning curve and opinionated structure made it difficult to customise for majority of the organisation needs. In contrast, Turborepo offered a simpler, more flexible approach that was easier for our team to adopt. Although Turborepo is not as mature as Nx or Lerna, it’s very fast and has strong community support, particularly from Vercel.

Another reason I chose Turborepo was its agnostic model, which allows us to revert to barebone npm, Yarn, or pnpm workspaces if necessary.

Turborepo Configuration

Configuring Turborepo is straightforward. First, you need to define dependencies between development tasks and how artifacts should be cached. Here’s how I defined the build tasks in our `turbo.json` file:

{
"$schema": "https://turborepo.org/schema.json",
"baseBranch": "origin/main",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "umd/**", "build/**", ".next"]
}
}
}

In this configuration, I instruct Turborepo to build package dependencies first (`"dependsOn”: “^build”`), and I specify the outputs that should be cached.

Then defined the most common build tasks in our root `package.json` file:

"scripts": {
"build": "pnpm run build:all",
"build:ui": "pnpm run build:all --filter=ui",
"build:all": "turbo run build - include-dependencies"
}

Retrospectively, the Turborepo cache worked amazingly well. The initial build of our library packages (`pnpm run build`) took about 30 seconds, but subsequent builds from the cache only took 0.2 seconds, saving us a lot of time.

Resolve package builds in both Javascript and Typescript

One of the recurrent challenges in a monorepo is linking packages together so they can be reused as dependencies. While JavaScript projects handle this well with workspaces, TypeScript often struggles because it resolves internal dependencies differently.

I decided on a simple approach. During development, I build each package using the configuration defined in the `package.json` file for fields like `main`, `module`, `types`, and `exports`. This way, the same package configuration is used in both development and production, simplifying our TypeScript setup.

Building and Bundling

In some organisations, requirements for bundling were broad. They needed to produce typed packages that worked as ES modules, CommonJS, and UMD, and they wanted to bundle React or Vue components. Hence I chose Vite as the bundling tool because it’s fast, easy to configure, and sufficiently agnostic to handle above needs.

Configuration

You can anticipate a significant number of configuration files and you may want to keep the root folder as simple as possible, so contributors could focus on the packages rather than the way they’re integrated. You could place configuration files in a separate workspace/directory in the monorepo whenever possible.

References

--

--

Vinayak Hegde

Dad, Husband, Son, Brother, Coder (mostly JavaScript and python), micro-blogger