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"
Related articles
You might also enjoy...
How to Easily Support ESM and CJS in Your TypeScript Library
A simple example that works for standalone npm libraries and monorepos
5 min read
How to Create a Super Minimal MDX Writing Experience
Learn to create a custom MDX experience so you can focus on writing without worrying about boilerplate or repetition
12 min read
8 Tips for Debugging Next.js Applications
Tried and tested techniques from 4 years in the trenches
6 min read