Building a Monorepo with Yarn Workspaces

In this article, we’ll explore how to set up a monorepo using Yarn Workspaces. We’ll also look at how to use it to manage dependencies and scripts across multiple packages.

Published on
May 30, 2024

Read time
5 min read

Introduction

When I started coding, it seemed like everybody was talking about microservices. It was touted as the modern way to manage a large number of services, offering teams the independence to choose the best tools for the job.

But it feels like the tide is turning back again. For many teams, the complexity of managing multiple codebases outweighs the potential benefits. That's not to say that managing monorepos is easy, but tools like Lerna, Yarn Workspaces, and Nx make the process much more straightforward.

In this article, we’ll explore how Yarn Workspaces can be used to set up a production-ready monorepo. Let’s dive in!

My goals

When choosing a tech stack, I try to choose tools that allow me (and any collaborators) to:

  • Work quickly,
  • Play to our strengths,
  • Collaborate with minimal friction, and
  • Keep costs as low as possible.

Right now, my go-to tech stack for personal projects is Next.js hosted on Vercel, MongoDB hosted on Atlas as the database, and GitHub for version control. These products all have good free tiers, and Next.js allows us to have a single codebase for both client and server. But there are some limitations.

The problem

On Vercel’s free tier, API functions timeout after 10 seconds. Until now, this suited us fine. But recently, I have needed to perform some heavy duty tasks that can last several minutes.

Being strapped for cash, I'm keen to delay upgrading to Vercel's pro tier for as long as possible. A more cost-effective choice would be to use AWS Lambda functions (via the Serverless Framework), which are much more affordable in my situation: they are priced based on usage, rather than a flat monthly fee.

The Next.js app and AWS Lambda functions will need to share some code, such as database funtionality. Compared to managing several codebases for the two applications and various shared libraries, a monorepo seemed much more convenient.

Project setup

To set up a monorepo using Yarn Workspaces, you need to create a new directory and initialize a new Yarn project. You can do this by running the following commands:

mkdir my-monorepo
cd my-monorepo
yarn init -y

Next, you need to enable Yarn Workspaces in your package.json file by adding the "workspaces" field.

{
  "private": true,
  "workspaces": ["apps/*", "packages/*"]
}

I like to use the apps directory for anything that gets deployed, whether it's a front-end application or a back-end service, while the packages directory is used for shared libraries whose code can be used in the apps, but which isn't deployed independently.

Each app or package should have it’s own package.json file, which you can create by running yarn init -y in each directory.

The --cwd flag

When running scripts in a monorepo, I have found the --cwd flag to be very useful. The flag simply allows you to avoid having to cd into (and then out of) the directory of the package you want to run a script in.

This is great for deploying on places like Vercel, which requires a single command for install and build scripts.

We need to set the directory as the root directory of whichever application is being deployed, but then running yarn install only in that directory will mean the shared libraries dependencies will not be installed.

To get around this, we can set the install script to:

yarn --cwd ../.. install

Then we can either run yarn build as normal, or - if we need something more complex - we can create a "build" script in the package.json of the root folder, and run that in the same way.

yarn --cwd ../.. run build

It's best to run commands from the root directory. I first tried to create a custom "install" script inside the application's package.json, which navigated to the root folder and ran yarn install, but this caused an infinite loop!

GitHub Actions

Since I’ve been trying hard to avoid using Vercel’s pro plan, I also ran into a problem that I couldn’t add any collaborators to Vercel to see the deployment logs. Once again, I have found a cost-effective (in this case, free) alternative: GitHub Actions!

For this to be useful, our new action needs to follow the same pattern as the Vercel deployment, which - thankfully - is quite simple.

Here's an example for a Next.js app. Inside .github/workflows, create a new run-build.yml file, and add the following:

name: Run Build

on:
  push:
    branches: ["main", "staging"]
  pull_request:
    branches: ["main", "staging"]

env:
  EXAMPLEenvVAR: ${{ secrets.EXAMPLEenvVAR }}

jobs:
  build:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [20.x]

    steps:
      - uses: actions/checkout@v4
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"
      - name: Install root dependencies
        run: yarn --cwd ../.. install
      - name: Build app
        run: yarn build
        env:
          STEPenvVAR: ${{ secrets.STEPenvVAR }}

In my example, we run this whenever pushing to or creating a pull request to merge into the main or staging branches. I have also added examples of how to use environment variables, either for the whole job or for individual steps.

Compiling library code to ESM and CJS

This particular tech stack comes with an additional consideration. Next.js uses ESM for imports but AWS Lambdas require the older CJS approach.

If you want wrote an article dedicated to solving this problem, but in summary, for each library, I use a package.json like this:

{
  "name": "my-package-name",
  "version": "1.0.0",
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "types": "dist/types/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    }
  },
  "private": true,
  "scripts": {
    "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
    "build:esm": "tsc -p tsconfig.esm.json && echo '{\"type\": \"module\"}' > dist/esm/package.json",
    "build": "yarn build:cjs && yarn build:esm"
  }
}

There are three tsconfig.json files. For shared configurations, I use a tsconfig.base.json file:

{
  "compilerOptions": {
    "rootDir": "./src",
    "strict": true,
    "moduleResolution": "node",
    "skipLibCheck": true
  },
  "exclude": ["node_modules", "dist"]
}

For ESM, we have a tsconfig.esm.json file, which also outputs a types.d.ts file for TypeScript support via the "declaration" field:

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "outDir": "./dist/esm",
    "declaration": true,
    "declarationDir": "./dist/types"
  }
}

For CJS, there is a tsconfig.cjs file:

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "outDir": "./dist/cjs",
    "declaration": false
  }
}

Though not strictly necessary in my case, you can reference shared packages in an application's package.json dependency list using the file: protocol, like so:

"my-package-name": "file:../../packages/my-package-name"

Finally, I have a "prebuild" script in my root package.json, which I run before building the Next.js app.

"prebuid": "yarn --cwd packages/my-package-name build",
"build": "yarn --cwd apps/next-app build"

© 2024 Bret Cameron