Build Yarn Monorepo with Workspaces šŸˆ

0x2a

Lucas Martin

Posted on April 24, 2023

Build Yarn Monorepo with Workspaces šŸˆ

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 :

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]
Enter fullscreen mode Exit fullscreen mode

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 :

$ yarn set version stable
Enter fullscreen mode Exit fullscreen mode

Then check your version with :

$ yarn --version
Enter fullscreen mode Exit fullscreen mode

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 ) :

$ yarn config set nodeLinker node-modules
Enter fullscreen mode Exit fullscreen mode

It's now time to initialize our monorepo ! Let's run these two commands :

$ yarn init
$ yarn
Enter fullscreen mode Exit fullscreen mode

Next we'll define which folders will act as our workspaces, open your package.json and add these attributes :

  ...
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  ...
Enter fullscreen mode Exit fullscreen mode

Great, now we can create these folders :

.
|-- .yarn/
|-- apps/ +
|-- node_modules/
|-- packages/ +
|-- .editorconfig
|-- .gitattributes
|-- .gitignore
|-- .yarnrc.yml
|-- package.json
|-- README.md
`-- yarn.lock
Enter fullscreen mode Exit fullscreen mode

Applications šŸ“±

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

$ cd apps
$ mkdir back web
Enter fullscreen mode Exit fullscreen mode

Back setup šŸ¤–

Let's start with the back :

$ cd back
$ yarn init
$ yarn add -D @types/express nodemon ts-node typescript
$ yarn add express 
$ mkdir src
$ touch src/main.ts tsconfig.json
Enter fullscreen mode Exit fullscreen mode

In the tsconfig.json file you can paste this configuration :

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es6",
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "baseUrl": "./src",
    "outDir": "./dist"
  },
  "include": ["./**/**.ts"]
}
Enter fullscreen mode Exit fullscreen mode

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

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 !')
})
Enter fullscreen mode Exit fullscreen mode

Web setup šŸ–„ļø

$ 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
Enter fullscreen mode Exit fullscreen mode

You can paste this configuration in tsconfig.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"]
}
Enter fullscreen mode Exit fullscreen mode

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 :

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/',
  },
};
Enter fullscreen mode Exit fullscreen mode

webpack.config.js content :

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/',
  },
};
Enter fullscreen mode Exit fullscreen mode

Here's a default HTML template to paste in public/index.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>
Enter fullscreen mode Exit fullscreen mode

Packages šŸ“¦

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

entities package šŸ§

$ mkdir entities
$ cd entities
$ yarn init
$ touch tsconfig.json
$ mkdir src
$ touch src/person.ts src/index.ts
Enter fullscreen mode Exit fullscreen mode

Content of tsconfig.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"]
}
Enter fullscreen mode Exit fullscreen mode

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 :

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

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

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 }
Enter fullscreen mode Exit fullscreen mode

Don't forget to export it in src/index.ts :

export { Person } from './person'
Enter fullscreen mode Exit fullscreen mode

Finally compile your package by running :

$ yarn run tsc
Enter fullscreen mode Exit fullscreen mode

Import entities package in your apps šŸ›„ļø

Back

First cd into apps/back folder

Then edit package.json as follows :

  ...
  "dependencies": {
    "@shared/entities": "workspace:^",
    ...
  },
  ...
Enter fullscreen mode Exit fullscreen mode

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 :

$ yarn
Enter fullscreen mode Exit fullscreen mode

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

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 !')
})
Enter fullscreen mode Exit fullscreen mode

Compile the project and run it :

$ yarn run tsc
$ node dist/main.js
Enter fullscreen mode Exit fullscreen mode

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

Browser JSON Response

Web

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

  ...
  "dependencies": {
    "@shared/entities": "workspace:^"
  },
  ...
Enter fullscreen mode Exit fullscreen mode

Run yarn to link package :

$ yarn
Enter fullscreen mode Exit fullscreen mode

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 ) :

<body>
  ...
  <span id="person"></span>
  ...
</body>
Enter fullscreen mode Exit fullscreen mode

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

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 !')
})
Enter fullscreen mode Exit fullscreen mode

Run webpack to serve your front :

$ yarn run webpack serve --config=./webpack.dev.js
Enter fullscreen mode Exit fullscreen mode

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

Browser fetching JSON from endpoint

Finally you can bundle your code by running :

$ yarn run webpack
Enter fullscreen mode Exit fullscreen mode

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 :

  ...
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  }
  ...
Enter fullscreen mode Exit fullscreen mode

Add these lines to apps/back/package.json :

  ...
  "scripts": {
    "build": "tsc",
    "dev": "nodemon ./src/main.ts"
  }
  ...
Enter fullscreen mode Exit fullscreen mode

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

  ...
  "scripts": {
    "build": "webpack",
    "dev": "webpack serve --config=./webpack.dev.js"
  }
  ...
Enter fullscreen mode Exit fullscreen mode

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

  "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"
  }
Enter fullscreen mode Exit fullscreen mode

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 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 :)

šŸ’– šŸ’Ŗ šŸ™… šŸš©
0x2a
Lucas Martin

Posted on April 24, 2023

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

Sign up to receive the latest update from our blog.

Related