Build Yarn Monorepo with Workspaces 🐈
Tags: yarn, javascript, typescript, webdev
Published: 2023-04-24 13:23:28

Hi everyone ! Today I'll try to help you setting up your own monorepo using Yarn Workspaces, Express.js & Webpack.

📦 Source Code : https://github.com/0x2A-git/monorepo-tutorial
🎥 Video : {% embed https://www.youtube.com/watch?v=T-Y3SoUrxZw %}

## Goal ⚽

Building reusable code across our applications. Following this tutorial, you should end up with this project structure :
```
.
|-- apps/
|   [YOUR APPS]
`-- packages/
    [YOUR SHARED PACKAGES]
```

## Initialization 🏗️

Create a new folder that will contain your monorepo and `cd` into it.

First we'll have to upgrade Yarn to the Berry stable version let's run :


```console
$ yarn set version stable
```

Then check your version with :

```console
$ yarn --version
```

It should display a version number like 3.x.x.

You can optionally set the nodeLinker to node-modules, it prevents Yarn PnP from being used ( had some troubles with it in the past :S ) :

```console
$ yarn config set nodeLinker node-modules
```

It's now time to initialize our monorepo ! Let's run these two commands :
```console
$ yarn init
$ yarn
```
Next we'll define which folders will act as our workspaces, open your **package.json** and add these attributes :

```json
  ...
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  ...
```

Great, now we can create these folders :
```
.
|-- .yarn/
|-- apps/ +
|-- node_modules/
|-- packages/ +
|-- .editorconfig
|-- .gitattributes
|-- .gitignore
|-- .yarnrc.yml
|-- package.json
|-- README.md
`-- yarn.lock
```
## Applications 📱

It is now time to initialize our applications we'll have a Back-end called **back** and a Front-end called **web** :

```console
$ cd apps
$ mkdir back web
```

### Back setup 🤖

Let's start with the back :

```console
$ cd back
$ yarn init
$ yarn add -D @types/express nodemon ts-node typescript
$ yarn add express 
$ mkdir src
$ touch src/main.ts tsconfig.json
```

In the **tsconfig.json** file you can paste this configuration :
```json
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es6",
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "baseUrl": "./src",
    "outDir": "./dist"
  },
  "include": ["./**/**.ts"]
}
```

Then in **src/main.ts** you can paste this code in order to have minimal HTTP server setup that you can run :

```typescript
import express, { Router } from 'express'

const app = express()

const router = Router()

router.get('/person', (req, res) => {
  // TODO
})

app.use('/api', router)

app.listen(3000, () => {
  console.log('Server is running !')
})
```

### Web setup 🖥️

```console
$ cd ../web
$ yarn init
$ yarn add -D compression-webpack-plugin ts-loader typescript webpack webpack-cli webpack-dev-server
$ touch tsconfig.json webpack.config.js webpack.dev.js 
$ mkdir src
$ touch src/index.ts
$ mkdir public
$ touch public/index.html
```
You can paste this configuration in **tsconfig.json** :

```json
{
  "compilerOptions": {
    "lib": ["es6", "DOM"],
    "module": "commonjs",
    "target": "es6",
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "baseUrl": "./src",
    "outDir": "./dist"
  },
  "include": ["./**/**.ts"]
}
```

As for webpack we've created two files :

- **webpack.dev.js** - serve the content on a dev server as we develop
- **webpack.config.js** - generate a bundle for our production code.

**webpack.dev.js** content :

```js
const path = require('path');

module.exports = {
  entry: './src/index.ts',
  mode: 'development',
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  devServer: {
    static: {
      directory: path.join(__dirname, 'public'),
    },
    proxy: {
      '/api': 'http://localhost:3000',
    },
    compress: true,
    port: 8080,
    devMiddleware: {
      index: true,
      mimeTypes: { phtml: 'text/html' },
    },
  },
  devtool: 'inline-source-map',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist/'),
    publicPath: '/dist/',
  },
};
```

**webpack.config.js** content :
```js
const path = require('path');
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  entry: './src/index.ts',
  mode: 'production',
  module: {
    rules: [
      {
        test: /\.ts?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  plugins: [
    new CompressionPlugin(),
  ],
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist/'),
    publicPath: '/dist/',
  },
};
```

Here's a default HTML template to paste in **public/index.html** :
```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script src="/dist/bundle.js"></script>
</body>
</html>
```
## Packages 📦

We are going to create our first package, `cd` into the **packages** folder of your monorepo

### `entities` package 🧍

```console
$ mkdir entities
$ cd entities
$ yarn init
$ touch tsconfig.json
$ mkdir src
$ touch src/person.ts src/index.ts
```

Content of tsconfig.json :
```json
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es6",
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "baseUrl": "./src",
    "outDir": "./dist",
    "declaration": true,
    "declarationMap": true
  },
  "include": ["./**/**.ts"]
}
```

In entities' **package.json** we'll add the **main** and **typings** attributes to locate our js files & typings. 

I will also rename the package to **@shared/entities**, it will prevent conflicts with remote packages :

```json
{
  "name": "@shared/entities",
  "packageManager": "yarn@3.5.0",
  "main": "./dist/index.js",
  "typings": "./dist/index.d.ts",
  "devDependencies": {
    "typescript": "^5.0.4"
  }
}
```

Let's define the _Person_ class in **src/person.ts** :

```typescript
class Person {
  private firstName: string | null = null;
  private lastName: string | null = null;

  public constructor(firstName: string, lastName: string) {
    this.firstName = firstName
    this.lastName = lastName
  }

  public toString(): string {
    return `${this.lastName} ${this.firstName}`
  }
}

export { Person }
```

Don't forget to export it in **src/index.ts** :
```typescript
export { Person } from './person'
```

Finally compile your package by running :
```
$ yarn run tsc
```
### Import `entities` package in your apps 🛥️

#### Back

First `cd` into **apps/back** folder

Then edit package.json as follows :

```json
  ...
  "dependencies": {
    "@shared/entities": "workspace:^",
    ...
  },
  ...
```

The syntax `workspace:` tells Yarn to look for the package in your monorepo and prevents Yarn from trying to fetch it from the registry.

Link the package to your back app by running :

```console
$ yarn
```

Alright, we can edit **src/main.ts** and code the missing endpoint :

```typescript
import express, { Router } from 'express'
import { Person } from '@shared/entities'

const app = express()

const router = Router()

router.get('/person', (req, res) => {
  const person = new Person('John', 'Doe')

  return res.status(200).json(person)
})

app.use('/api', router)

app.listen(3000, () => {
  console.log('Server is running !')
})
```

Compile the project and run it :

```console
$ yarn run tsc
$ node dist/main.js
```

Open your browser and go to **http://localhost:3000/api/person**, you should see the following response :


![Browser JSON Response](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lrxw2vxfy1de6p3qounn.png)

### Web

Let's `cd` into **apps/web**, then edit package.json :

```json
  ...
  "dependencies": {
    "@shared/entities": "workspace:^"
  },
  ...
```

Run yarn to link package :

```console
$ yarn
```

Edit **public/index.html** to add a span tag that will be used to display the person's fullname ( or add the HTML tag dynamically through JS ) :

```html
<body>
  ...
  <span id="person"></span>
  ...
</body>
```

Then edit **src/index.ts** to retrieve JSON data from the person's endpoint running on our server :

```typescript
import { Person } from "@shared/entities"

const displayPerson = async () => {
  const response = await fetch('http://localhost:8080/api/person', {
    method: 'GET'
  })

  const jsonResponse = await response.json()

  const person: Person = new Person(jsonResponse.firstName, jsonResponse.lastName)

  const personSpan: HTMLSpanElement | null = document.body.querySelector("#person")

  personSpan && (personSpan.innerText = `Welcome ${person.toString()} !`)
}

displayPerson().then(() => {
  console.log('Person displayed !')
})
```

Run webpack to serve your front :

```console
$ yarn run webpack serve --config=./webpack.dev.js
```

Open your browser and go to **http://localhost:8080** you should now see this page appear :

![Browser fetching JSON from endpoint](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l4sqmib9lbpgosjn7ccn.png)

Finally you can bundle your code by running :

```
$ yarn run webpack
```

This will output a **dist/** folder with your bundle inside.

Congratulations you now have a working monorepo 🎉


## Optional : Add scripts 📜

In **packages/entities/package.json** add the following lines :

```json
  ...
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  }
  ...
```
Add these lines to **apps/back/package.json** :

```json
  ...
  "scripts": {
    "build": "tsc",
    "dev": "nodemon ./src/main.ts"
  }
  ...
```

Then paste the following lines in **apps/web/package.json** :

```json
  ...
  "scripts": {
    "build": "webpack",
    "dev": "webpack serve --config=./webpack.dev.js"
  }
  ...
```

Finally in the root folder of the monorepo edit the package.json add these lines :

```json
  "scripts": {
    "back:build": "yarn workspace back run build",
    "back:dev": "yarn workspace back run dev",
    "web:build": "yarn workspace web run build",
    "web:dev": "yarn workspace web run dev"
  }
```

You're now able to run `yarn run back:build`, `yarn run back:dev`, `yarn run web:build` and `yarn run web:dev` from wherever you are in your monorepo !

## Conclusion

You are now able to use packages across your applications.

Are you wondering about building a CI / CD with Yarn Workspaces ? You should take a look at [yarn workspaces focus](https://yarnpkg.com/cli/workspaces/focus/) it allows you to install packages for a single app / package hence decreasing your jobs execution time 🏃🏻

If you have any question about this blog post feel free to ask it in the comments.

Have a nice day :)