Creating a Monorepo with Lerna & Yarn Workspaces

In this guide, you will learn how to create a Monorepo to manage multiple packages with a shared build, test, and release process.
Source

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

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

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

package.json

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 run, you have a /node_modules folder. We don’t want to commit any of these packages, so let’s add a .gitignore.

.gitignore

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

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.

root level package.json

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.

package.json for button

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.

root level package.json

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/index.js

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.

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

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

.storybook/config.js

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

/packages/button/src/index.story.js

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

root level package.json

Then we can use yarn dev to view our 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

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

root level package.json

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/index.spec.js

Now we can run our test via yarn unit.

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/package.json
/packages/input/src/index.js

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/index.story.js

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.

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

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

/packages/input/src/index.spec.js

Then we can run yarn unit again.

Releasing

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 changed to 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.

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

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

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

If using a Monorepo sounds appealing, come work with us! We’re working to invent the future of grocery, one package at a time.