Creating a Monorepo with Lerna & Yarn Workspaces

LR

Lee Robinson / March 29, 2019

12 min read––– views

Creating a Monorepo with Lerna & Yarn Workspace

As applications scale, you’ll inevitably reach a point where you want to write shared reusable components which can be used everywhere. Historically, we’ve had separate repositories for each package. However, this becomes a problem for a few reasons:

  • It does not scale well. Before you know it, you have dozens of different package repositories repeating the same build, test, and release process.
  • It promotes bundling unnecessary components. Do we need to create a new repo for this button? Let’s put it together with this other package. Now we’ve increased the bundle size for something 95% of consumers won’t use.
  • It makes upgrading difficult. If you update a base component, you now have to update its consumers, its consumer’s consumers, etc. This problem gets worse as you scale.

To make our applications as performant as possible, we need to have small bundle sizes. This means we should only include the code we’re using in our bundle.

Along with this, when developing shared component libraries, we want to have semver over individual pieces instead of the entire package. This prevents scenarios where:

  1. Consumer A only needs the package for one component and is on v1.
  2. Consumer B uses the package for all the components. They’ve helped create and modify other components in the package and it’s grown large. It’s now on v8.
  3. Consumer A now needs a bug fix for the one component they use. They have to update to v8.

Lerna#

Lerna and Yarn Workspaces give us the ability to build libraries and apps in a single repo (a.k.a. Monorepo) without forcing us to publish to NPM until we are ready. This makes it faster to iterate locally when building components that depend on each other.

Lerna also provides high-level commands to optimize the management of multiple packages. For example, with one Lerna command, you can iterate through all the packages, running a series of operations (such as linting, testing, and building) on each package.

Several large JavaScript projects use monorepos including: Babel, React, Jest, Vue, Angular, and more.

Monorepo#

In this guide, we will be utilizing:

  • 🐉 Lerna — The Monorepo manager
  • 📦 Yarn Workspaces — Sane multi-package management
  • 🚀 React — JavaScript library for user interfaces
  • 💅 styled-components — CSS in JS elegance
  • 🛠 Babel — Compiles next-gen JavaScript
  • 📖 Storybook — UI Component Environment
  • 🃏 Jest — Unit/Snapshot Testing

You can either follow along or view the finished repo here.

Okay, let’s begin! First, let’s create a new project and set up Lerna.

$ mkdir monorepo
$ cd monorepo
$ npx lerna init

This creates a package.json file for your project.

package.json
{
  "name": "root",
  "private": true,
  "devDependencies": {
    "lerna": "^3.20.2"
  }
}

You’ll notice a lerna.json file has also been created, as well as a /packages folder which will contain our libraries. Let’s now modify our lerna.json file to use Yarn Workspaces. We’ll use independent versioning so we can properly enforce semver for each package.

lerna.json
{
  "packages": ["packages/*"],
  "npmClient": "yarn",
  "useWorkspaces": true,
  "version": "independent"
}

We’ll also need to modify our package.json to define where the Yarn workspaces are located.

package.json
{
  "name": "root",
  "private": true,
  "workspaces": ["packages/*"],
  "devDependencies": {
    "lerna": "^3.20.2"
  }
}

Babel#

Next, let’s add all of the dependencies we will need for Babel 7.

$ yarn add --dev -W @babel/cli @babel/core @babel/preset-react @babel/preset-env babel-core@7.0.0-bridge.0 babel-loader babel-plugin-styled-components webpack

Using -w instructs Yarn to install the given dependencies for the entire workspace. These dependencies are usually shared between all packages.

Since yarn has ran, you have a /node_modules folder. We don’t want to commit any of these packages, so let’s add a .gitignore.

.gitignore
.log
.DS_Store
.jest-*
lib
node_modules

Okay, back to Babel. To set up the global configuration for Babel, we’ll need a babel.config.js file in the root of the repository.

babel.config.js
module.exports = {
  plugins: ['babel-plugin-styled-components'],
  presets: ['@babel/preset-env', '@babel/preset-react']
};

This file tells Babel how to compile our packages. Now, let’s create a script to execute Babel. We’ll add this to our package.json.

package.json
"scripts": {
    "build": "lerna exec --parallel -- babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js"
}

Let’s dissect this command. lerna exec will take any command and run it over all of the different packages. This command instructs Babel to run in parallel over every package, pulling from the /src folder and compiling into the /lib folder. We don’t want to include any tests or stories (which we’ll get to later) in the compiled output.

Using --root-mode upward is the special sauce to using Yarn workspaces. This tells Babel the node_modules are located in the root instead of nested inside each of the individual packages. This prevents each package from having the same node_modules and extracts them up to the root. We’ll be utilizing a similar approach for testing later.

React#

We have completed the infrastructure for a Monorepo. Let’s create some packages for it to consume. We’ll be using React and styled-components to develop our UI components, so let’s install those first.

$ yarn add --dev -W react react-dom styled-components

Then, inside of /packages let's create a folder called /button and set up our first package.

packages/button/package.json
{
  "name": "button",
  "version": "1.0.0",
  "main": "lib/index.js",
  "module": "src/index.js",
  "dependencies": {
    "react": "latest",
    "react-dom": "latest",
    "styled-components": "latest"
  },
  "peerDependencies": {
    "react": "^16.0.0",
    "react-dom": "^16.0.0",
    "styled-components": "^5.0.0"
  }
}

This file informs consumers the module will live inside the /src folder and the output ran through Babel (main) will live inside /lib. This will be the main entry point into the package. Listing the peerDependencies helps ensure consumers are including the correct packages.

We’ll also want to link our root dependencies to our newly created package. Let’s create a script to do this inside our package.json.

package.json
"scripts": {
  "bootstrap": "lerna bootstrap --use-workspaces"
}

Now we can simply run yarn bootstrap to install and link all dependencies.

Okay, now let’s create our first component: <Button />.

packages/button/src/Button.js
import styled from 'styled-components';

const Button = styled.button`
  background: red;
  color: #fff;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
  font-weight: 300;
  padding: 9px 36px;
`;

export default Button;

Let’s test if Babel is configured properly. We should be able to run yarn build and see a /lib folder created for our new package.

$ lerna exec --parallel -- babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js
lerna notice cli v3.20.2
lerna info versioning independent
lerna info Executing command in 1 package: "babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js"
button: Successfully compiled 1 file with Babel.
lerna success exec Executed command in 1 package: "babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js"
✨  Done in 2.45s.

Storybook#

Storybook provides us with an interactive UI playground for our components. This makes development a breeze. Let’s set up Storybook to view our newly created Button component.

$ yarn add --dev -W @storybook/react @storybook/addon-docs

We’ll also want to configure Storybook so it knows where to find our stories.

.storybook/main.js
module.exports = {
  stories: ['../packages/**/*.stories.js'],
  addons: ['@storybook/addon-docs']
};

Then, we can create our first story for the newly created Button inside /packages/button/src.

packages/button/src/Button.stories.js
import React from 'react';

import Button from '.';

export default {
  component: Button,
  title: 'Design System|Button'
};

export const primary = () => <Button>{'Button'}</Button>;

Finally, let’s add a script to start Storybook.

package.json
"scripts": {
  "dev": "start-storybook -p 5555"
}

Then we can use yarn dev to view our Button 🎉

Button

Testing#

Before we go any further, let’s set up our testing environment and create a simple test for our button. We’ll utilize Jest for unit testing. It will automatically pick up any files ending with .spec.js.

$ yarn add --dev -W jest jest-styled-components babel-jest react-test-renderer jest-resolve jest-haste-map

Next, let's configure our Jest setup in the root directory.

jest.config.js
module.exports = {
  cacheDirectory: '.jest-cache',
  coverageDirectory: '.jest-coverage',
  coveragePathIgnorePatterns: ['<rootDir>/packages/(?:.+?)/lib/'],
  coverageReporters: ['html', 'text'],
  coverageThreshold: {
    global: {
      branches: 100,
      functions: 100,
      lines: 100,
      statements: 100
    }
  },
  testPathIgnorePatterns: ['<rootDir>/packages/(?:.+?)/lib/']
};

You can modify this as you see fit. We’ll also want to add some scripts to our package.json.

package.json
"scripts": {
  "coverage": "jest --coverage",
  "unit": "jest"
}

Finally, let’s create our first test alongside our button component. We’ll utilize Snapshot testing since this is a purely presentational component. For more information, see our other post here.

packages/button/src/Button.spec.js
import React from 'react';
import renderer from 'react-test-renderer';
import 'jest-styled-components';

import Button from '.';

describe('Button', () => {
  test('renders correctly', () => {
    const tree = renderer.create(<Button>{'Test'}</Button>).toJSON();
    expect(tree).toMatchSnapshot();
  });
});

Now we can run our test via yarn unit.

$ jest
 PASS  packages/button/src/Button.spec.js
  Button
    ✓ renders correctly (23ms)1 snapshot written.
Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 written, 1 total
Time:        1.25s
Ran all test suites.
✨  Done in 2.48s.

Multiple Packages#

The main reason for the Monorepo structure is to support multiple packages. This allows us to have a single lint, build, test, and release process for all packages. Let’s create an input package and add a new component.

packages/input/src/package.json
{
  "name": "input",
  "version": "1.0.0",
  "main": "lib/index.js",
  "module": "src/index.js",
  "dependencies": {
    "react": "latest",
    "react-dom": "latest",
    "styled-components": "latest"
  },
  "peerDependencies": {
    "react": "^16.0.0",
    "react-dom": "^16.0.0",
    "styled-components": "^5.0.0"
  }
}
packages/input/src/Input.js
import styled from 'styled-components';

const Input = styled.input`
  border: 1px solid #ccc;
  border-radius: 4px;
  box-sizing: border-box;
  font-size: 16px;
  font-weight: 300;
  padding: 10px 40px 10px 10px;
  width: 150px;
`;

export default Input;

Okay, now we have an Input component. Let’s run yarn bootstrap again to link our packages together and create a new story.

packages/input/src/Input.stories.js
import React from 'react';

import Input from '.';

export default {
  component: Input,
  title: 'Design System|Input'
};

export const placeholder = () => <Input placeholder="user@gmail.com" />;

Our Storybook instance should still be running via yarn dev, but if not, then re-run the command. We can observe our component was rendered correctly.

Storybook

Finally, let’s ensure Babel works as expected for multiple packages by running yarn build.

$ lerna exec --parallel -- babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js
lerna notice cli v3.20.2
lerna info versioning independent
lerna info Executing command in 2 packages: "babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js"
button: Successfully compiled 1 file with Babel.
input: Successfully compiled 1 file with Babel.
lerna success exec Executed command in 2 packages: "babel --root-mode upward src -d lib --ignore **/*.stories.js,**/*.spec.js"
✨  Done in 2.45s.

Both packages were compiled successfully 🎉 What about testing? Let’s create another test for the Input component.

packages/input/src/Input.spec.js
import React from 'react';
import renderer from 'react-test-renderer';
import 'jest-styled-components';

import Input from '.';

describe('Input', () => {
  test('renders correctly', () => {
    const tree = renderer
      .create(<Input placeholder="user@gmail.com" />)
      .toJSON();
    expect(tree).toMatchSnapshot();
  });
});

Then we can run yarn unit again.

$ jest
 PASS  packages/button/src/Button.spec.js
 PASS  packages/input/src/Input.spec.js
 › 1 snapshot written.

Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   1 written, 1 passed, 2 total
Time:        1.96s, estimated 2s
Ran all test suites.
✨  Done in 3.22s.

Releasing#

Note: You need to commit and push to your repository before releasing. If you haven’t done that, do it now.

Let’s release the first version of our packages. We can use npx lerna changedto see which packages have changed. You can also use npx lerna diff to see specifically what lines changed.

Note: lerna could be installed globally to remove the need to use npx. You could also add scripts in your package.json since lerna is a devDependency.

$ npx lerna changed
lerna notice cli v3.20.2
lerna info versioning independent
lerna info Assuming all packages changed
button
input
lerna success found 2 packages ready to publish

We can see it recognizes both the button and the input package. Now, let’s run npx lerna version to simulate releasing them.

$ npx lerna version
lerna notice cli v3.20.2
lerna info versioning independent
lerna info Assuming all packages changed
? Select a new version for button (currently 1.0.0)
? Select a new version for button (currently 1.0.0)

Changes:
 -  button: 1.0.0 => 1.0.1
 -  input: 1.0.0 => 1.0.1

? Are you sure you want to create these releases? Yes
lerna info execute Skipping GitHub releases
lerna info git Pushing tags...
lerna success version finished

Congrats! 🎉 The tags have been pushed to GitHub. If we wanted to release our packages to NPM, we would use npx lerna publish instead.

Yarn Workspaces

Linting & Formatting#

It should be as easy as possible for others to contribute. That’s why all formatting and linting is taken care of with:

Thanks to git pre-commit hooks with husky, we’re ensured all code is formatted correctly before getting pushed to GitHub. Proper linting and formatting saved us time and headaches while reviewing pull requests. We recommend you develop and agree upon linting and formatting rules to reduce the amount of “nitpick” type code review comments. You can view our rules here.

Conclusion#

Congratulations, you now have a fully-featured monorepo! If you’d like to take this even further, here are some other ideas:

Further Reading#

Subscribe to the newsletter

Get emails from me about web development, tech, and early access to new articles.

Discuss on TwitterEdit on GitHub