← Go back

Building a JavaScript Monorepo with Lerna

· 5 min read

JavaScript nowadays is almost everywhere: on the backend, frontend, desktop, mobile, tooling etc.

If your project consists of multiple JavaScript repositories, now, it’s much better to move them into a single/mono repository and control them using Lerna.

Poster



What is Lerna?

Note: Lerna is a tool for managing JavaScript projects with multiple packages.

I recommend you take a look at lerna commands before we proceed.


Why Monorepo?

Monorepo is NOT about mixing everything together inside a project(do not confuse with monolith).

Monorepo is about having source codes of multiple applications, services, and libraries that belong to one domain inside a single repository.

Note: Monorepo can be organized in any comfortable way, directory tree, it’s up to developer/team.

Pros:

  • Fixed versioning for the whole system (apps/services/libraries).
  • Cross-project changes. For instance, a feature that changes multiple parts of the system (libraries/services/apps) can be done in one Pull Requests.
  • Single clone. No need to clone several repositories.
  • Access to all parts of the system. Simplified refactoring of the whole system.

Cons:

  • Version control system performance downside.
  • Security. Access to all parts of the system by all developers.

Requirements

  • Node.JS version 8 or above

Getting Started

For the purpose of this example, we will create a simple app consisting of:

  • API: API service (backend)
  • frontend: frontend/web app

Also, in order not to mix all logic together, we’ll create separate packages:

  • validator: custom validation library/package
  • logger: custom logging library/package

The overall file structure of our monorepo project would be:

packages/        # directory for our custom libraries
../validator      # custom validation helpers
../logger         # custom logger library
apps/            # directory for our apps/services
../api            # API backend
../frontend       # frontend/web

1. Initialize Monorepo

# install lerna globally
npm i -g lerna
# create a new dir for project
mkdir my-project && cd my-project
# initialize a new lerna managed repository
lerna init

and Edit lerna.json

{
  "packages": ["packages/*", "apps/*"],
  "version": "0.1.0"
}

Note: We’ll use npm scoped package naming for all our apps and packages. Example: @my-project/{package-name}

Let’s start with library packages.

2. Create a “validator” library package

  1. Create and initialize @my-project/validator package:
# create library directory and cd inside
mkdir -p packages/validator && cd packages/validator
# initialize library with scope name
npm init --scope=my-project --yes
  1. Add packages/validator/index.js with the following content:
/**
 * Checks if given value is null or undefined or whitespace string
 * @param {string?} value
 */
exports.isNullOrWhitespace = value =>
  value === undefined || value === null || !value.trim()

3. Create a “logger” library package

  1. Create and initialize @my-project/logger package:

    # From the root directory of the repository
    # create library directory and cd inside
    mkdir -p packages/logger && cd packages/logger
    # initialize library with scope name
    npm init --scope=my-project --yes
  2. Add packages/logger/index.js with the following content:

    const CYAN = "\x1b[36m"
    const RED = "\x1b[31m"
    const YELLOW = "\x1b[33m"
    
    const log = (color, ...args) => console.log(color + "%s", ...args)
    
    exports.info = (...args) => log(CYAN, ...args)
    exports.warn = (...args) => log(YELLOW, ...args)
    exports.error = (...args) => log(RED, ...args)

4. Create an “api” application package

  1. Create and initialize @my-project/api package:

    # From the root directory of the repository
    # create app directory and cd inside
    mkdir -p apps/api && cd apps/api
    # initialize app with scope name
    npm init --scope=my-project --yes
    # install express
    npm i express --save
    # add our logger library as dependency to our api app
    lerna add @my-project/logger --scope=@my-project/api
  2. Add apps/api/index.js file:

    const express = require("express")
    const logger = require("@my-project/logger")
    
    const PORT = process.env.PORT || 8080
    const app = express()
    
    app.get("/greeting", (req, res) => {
     logger.info("/greeting was called")
     res.send({
       message: `Hello, ${req.query.name || "World"}!`,
     })
    })
    
    app.listen(PORT, () => console.log(`Example app listening on port ${PORT}!`))
  3. Add start script to apps/api/package.json:
  "scripts": {
      "start": "node index.js"
      // ...
  }
  1. Run app: npm start and open http://localhost:8080/greeting

5. Create a “frontend” application package

  1. Create @my-project/frontend using create-react-app:

    # From the root directory of the repository
    # create frontend app using create-react-app
    cd apps && npx create-react-app frontend
  2. Edit apps/frontend/package.json:

    {
     "name": "@my-project/frontend",
     // ...
     "proxy": "http://localhost:8080"
    }
  3. Add our validator as a dependency to our frontend.

    # Add validator library as a dependency to frontend
    lerna add @my-project/validator --scope=@my-project/frontend
  4. Add apps/frontend/src/Greeting.js:

    import React, { Component } from "react"
    import { isNullOrWhitespace } from "@my-project/validator"
    
    export class Greeting extends Component {
     state = {
       name: "",
     }
    
     onSubmit = () => {
       const { name } = this.state
       if (isNullOrWhitespace(name)) {
         alert("Please, type your name first.")
         return
       }
    
       fetch(`/greeting?name=${name}`)
         .then(response => response.json())
         .then(({ message }) => this.setState({ message, error: null }))
         .catch(error => this.setState({ error }))
     }
    
     render() {
       const { name, message, error } = this.state
       return (
         <div style={{ padding: "10px" }}>
           {message && <div style={{ fontSize: "50px" }}>{message}</div>}
           <input
             value={name}
             onChange={event => this.setState({ name: event.target.value })}
             placeholder="Type your name"
           />
           <button onClick={this.onSubmit}>Submit</button>
           {error && <pre>{JSON.stringify(error)}</pre>}
         </div>
       )
     }
    }
  5. Add <Greeting /> somewhere inside apps/frontend/src/App.js:

    // ...
    import { Greeting } from "./Greeting"
    
    class App extends Component {
     // ...
     render() {
       return (
         <div className="App">
           <header className="App-header">
             {/* ... */}
             <Greeting />
           </header>
         </div>
       )
     }
    }
  6. Run frontend app: npm start and open http://localhost:3000

Conclusion

As you can see, a mono repository can contain as many apps, libraries as needed for the project. The important thing is to keep everything loosely coupled:

  • One app/service/library → One package

Common logic, utils or helpers can be placed in a separate package. Thus, we can build highly independent packages, which is much simpler to understand, maintain and refactor.

See full source code on Github.

Also, you can look at the more advanced monorepo example here.

© 2021 Erzhan Torokulov