Editor’s note: This post was updated by Yan Sun on 21 April 2023 to include information about semantic versioning, as well as alternatives to Lerna, which has been taken over by Nrwl. For more information about TypeScript monorepos, check out Building a TypeScript app with Turborepo.
It is often quite useful to use a single repository to maintain an entire project with all of the packages in it. Companies like Google and Facebook have used a single repository internally for most of their projects. This solution can be useful when a company uses a similar technology and when all projects share a common set of dependencies and files.
In this article, we will build a TypeScript monorepo with Lerna to showcase an effective approach for organizing and maintaining multiple related projects in one place.
Jump ahead:
- What is a TypeScript monorepo?
- What is Lerna?
- Setting up our project with Lerna
- Preparing our build script
- Configuring TypeScript by file
- Linking packages with Lerna
- Publishing to npm
- Semantic versioning and conventional commits
- Building TypeScript monorepos with other tools
What is a TypeScript monorepo?
A TypeScript monorepo refers to a codebase that contains multiple TypeScript projects, all managed within a single Git repository. Within a TypeScript monorepo, an individual project can have its own dependencies, build processes, and tests, while also being able to share common code and dependencies with other packages.
Benefits of a monorepo
The monorepo approach has become popular in recent years because it has several advantages:
- Code reusability and sharing: By keeping related projects in a single repository, it is easier to share code like common modules, and improve code consistency by using standard tooling across multiple projects
- Simplified release process: Using a monorepo makes it possible to streamline the release process for all projects with a single build and deployment pipeline. The simplified release process can help to ensure that all projects within the repo are consistently released to the appropriate environments
- Better collaboration and discoverability: With all projects in a single repository, it is easier for developers to communicate and collaborate on changes and issues. Additionally, having all of the code in one place makes it easier to search, browse, and access the entire codebase, leading to faster discovery of relevant code and reducing duplication of effort
What is Lerna?
Lerna is a popular and widely used tool written in JavaScript for setting and managing multi-package repositories for Node.js projects with npm and Git.
Lerna has two modes: fixed and independent. The fixed mode keeps all versions of packages at the same level. This approach is quite popular; you may have seen it in Angular:
The independent mode allows us to have different versions per package. If you have a large or complex project, using a single repository for all its packages can help with organization and maintenance. Thankfully, this is quite easy with Lerna.
Setting up our project with Lerna
Before getting started, make sure you have npm and GitHub accounts because we’ll be using those to host our project. Also, create an npm organization so that you can publish the project with scoped packages once we’re done.
With this done, install Lerna as a global dependency:
npm install -g lerna
We’ll build a simple project. Ours will consist of multiple packages for our project, which we’ll call hospital-sdk
.
Create a folder named hospital
and initialize Lerna inside the folder:
lerna init && npm install
This command will create a lerna.json
file with a default folder structure in it:
/packages
is a placeholder for our shared packages, and lerna.json
is a Lerna configuration file. package.json
is our usual manifest file:
{ "packages": [ "packages/*" ], "version": "0.0.0" }
Lerna doesn’t create a .gitignore
file, so we will create one with this content:
node_modules/ lerna-debug.log npm-debug.log packages/*/lib .idea
We will use TypeScript in our project, but as we mentioned before, Lerna doesn’t support TypeScript, so we’ll treat it as a shared dependency. Add it on the top level of your package.json
with:
npm install typescript @types/node — save-dev
This is the recommended approach because we want to use the same tooling across our packages.
Preparing our build script
Alongside TypeScript, we will also install type declarations for Node.js. As Lerna is intended to be used with Node.js, not TypeScript, we will need to add some configuration to make it work.
We’ll have one common tsconfig.json
file defined in the root of our project. Our tsconfig.json
will look like this:
{ "compilerOptions": { "module": "commonjs", "declaration": true, "noImplicitAny": false, "removeComments": true, "noLib": false, "emitDecoratorMetadata": true, "experimentalDecorators": true, "target": "es6", "sourceMap": true, "lib": [ "es6" ] }, "exclude": [ "node_modules", "**/*.spec.ts" ] }
Building our packages with lerna create
As this is a demo project, we’ll assume that we’ll have a few modules: patient
, doctor
, and scheduler
. To create the packages, we’ll use the lerna create
command from the root of our project.
The lerna create
command will guide us through the creation of a new package. It requires your new package’s name to be passed as an argument. In this case, it’s doctor
, resulting in the full name being @hospital-sdk/doctor
:
We will repeat the same process for the patient
and scheduler
packages. The resulting folder structure should now be:
Configuring TypeScript by file
Each individual package requires its own tsconfig.json
file for correct relative paths. Other options can be extended from the tsconfig.json
at the root of the project:
{ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "./lib" }, "include": [ "./src" ] }
After we add a tsconfig.json
to each package, we will create an src
folder inside each package with a TypeScript file for that package. For example, a doctor.ts
file is added to the src
folder for the doctor
package:
// doctor.ts export class Doctor { get(id) { return {}; } }
We also need to register a tsc
script in each individual package.json
. The result should be:
{ "name": "@hospital-sdk/doctor", "version": "0.0.0", "description": "> TODO: description", "author": "Vlado Tesanovic <[email protected]>", "homepage": "", "license": "ISC", "main": "lib/doctor.js", "typings": "lib/doctor.d.ts", "directories": { "lib": "lib", "test": "__tests__" }, "files": ["lib"], "publishConfig": { "access": "public" }, "scripts": { "tsc": "tsc", "test": "echo \"Error: run tests from root\" && exit 1" } }
We added simple logic in each .ts
file. We can test our setup by running this:
lerna run tsc
The command above will run the tsc
script in all created packages:
If all goes well, we will compile TypeScript files from the src
folder into the lib
folder in each package.
Take a look at the package.json
for any package in our project and you’ll see attributes like directories
, files
, typings
, publishConfig
, and main
:
{ "name": "@hospital-sdk/doctor", "version": "0.0.0", "description": "> TODO: description", "author": "Vlado Tesanovic <[email protected]>", "homepage": "", "license": "ISC", "main": "lib/doctor.js", "typings": "lib/doctor.d.ts", "directories": { "lib": "lib", "test": "__tests__" }, "files": ["lib"], "publishConfig": { "access": "public" }, "scripts": { "tsc": "tsc", "test": "echo \"Error: run tests from root\" && exit 1" } }
The packages
and doctor
folders, as well as the package.json
file are very important because they control what will be pushed to npm, and will serve as the entry point for our library (main
and typings
attributes).
We will create a GitHub repository for this project and push all of the code there.
Linking packages with Lerna
With TypeScript compiled, let’s create a test integration
package to see how Lerna handles linking packages:
cd packages mkdir integration cd integration npm init -y cd ../..
To install necessary dependencies from npm, or link ones from the monorepo, use the lerna add
command from the root of the project. In case of any naming conflicts, local packages will always take precedence over remote ones:
lerna add @hospital-sdk/doctor --scope=integration
The --scope
parameter specifies which module to add the package to. The unfortunate limitation of the lerna add
command is that it can only add one package at a time. So, if you need to install more dependencies at once, you can just add them to the package.json
and then run the lerna bootstrap
command:
{ "name": "integration", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "ts-node src/index.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "ts-node": "^7.0.1", "@hospital-sdk/doctor": "^0.0.4", "@hospital-sdk/patient": "^0.0.4", "@hospital-sdk/scheduler": "^0.0.4" } }
lerna bootstrap
installs and links all dependencies, both local and remote. When using lerna add
, the lerna bootstrap
command is called automatically when adding any new packages.
To see whether packages were linked successfully, create an integration/src/index.ts
file with the following contents:
import { Doctor } from "@hospital-sdk/doctor"; import { Patient } from "@hospital-sdk/patient"; import { Scheduler } from "@hospital-sdk/scheduler"; console.log(new Doctor()); console.log(new Scheduler()); console.log(new Patient());
Then, add the start script in integration/package.json
:
{ "scripts": { "start": "ts-node src/index.ts" } }
Running the script from the terminal should produce the following output:
Doctor {} Scheduler {} Patient {}
Publishing to npm
Our goal is to publish all packages under the same npm scope, or organization. npm organizations can be private, as well; in that case, you can control who is going to see and use your packages.
Earlier, we created a free, public organization at npmjs.org:
Log in to that organization from the terminal:
At this moment, we have our organization and build scripts ready. Let’s glue it all together under one npm script from the root package.json
:
{ "scripts": { "publish": "lerna run tsc && lerna publish" } }
From the terminal, run the following command:
npm run publish
Lerna will guide us through the publishing process. We will need to choose a package version and push tags to GitHub. If all goes well, we will see this message at the end: lerna success published 3 packages:
npm run publish > publish > lerna run tsc && lerna publish lerna notice cli v3.4.0 lerna info Executing command in 3 packages: "npm run tsc" > @hospital-sdk/[email protected] tsc > tsc > @hospital-sdk/[email protected] tsc > tsc > @hospital-sdk/do[email protected] tsc > tsc lerna success run Ran npm script 'tsc' in 3 packages: lerna success - @hospital-sdk/doctor lerna success - @hospital-sdk/patient lerna success - @hospital-sdk/scheduler lerna notice cli v3.4.0 lerna info Verifying npm credentials lerna info current version 0.0.3 lerna info Looking for changed packages since v0.0.3 ? Select a new version (currently 0.0.3) Patch (0.0.4) Changes: - @hospital-sdk/doctor: 0.0.3 => 0.0.4 - @hospital-sdk/patient: 0.0.3 => 0.0.4 - @hospital-sdk/scheduler: 0.0.3 => 0.0.4 ? Are you sure you want to publish these packages? Yes lerna info git Pushing tags... lerna info publish Publishing packages to npm... lerna notice ... lerna notice lerna info published @hospital-sdk/doctor 0.0.4 lerna info published @hospital-sdk/patient 0.0.4 lerna info published @hospital-sdk/scheduler 0.0.4 Successfully published: - @hospital-sdk/[email protected] - @hospital-sdk/[email protected] - @hospital-sdk/[email protected] lerna success published 3 packages
Semantic versioning and conventional commits
In the previous publish example, Lerna detected the current version, proposed a few options, and asked you to choose one as the following:
? Select a new version (currently 0.0.3) (Use arrow keys) ❯ Patch (0.0.4) Minor (0.1.0) Major (1.0.0) Prepatch (0.0.4-alpha.0) Preminor (0.1.0-alpha.0) Premajor (1.0.0-alpha.0) Custom Prerelease
Semantic versioning (often abbreviated as “semver”) is a convention used for software versioning in a standardized way. Using semantic versioning, each version number is comprised of three parts: major, minor, and patch, which are incremented when:
- major version: there are significant changes
- minor version: a new feature is added in a backward-compatible way
- patch version: bugs or issues are fixed
Here, semantic versioning is used to allow the manual selection of version numbers. However, Lerna also provides the option to automate the semantic version bump using conventional commits.
Conventional commits is a formatting convention that provides a set of rules to formulate a consistent commit message. It specifies that each commit message should consist of a header, which includes a type, an optional scope and a description, an optional body, and a footer. Below is an example message with a description and breaking change footer:
feat: allow provided config object to extend other configs BREAKING CHANGE: `extends` key in config file is now used for extending other config files
Lerna is able to detect which package has been changed, and infer the automatic semantic version bump if the commit message types fall into one of the following:
- When the message type is
fix
, the patch version will be incremented - When the message type is
feat
, the minor version will be incremented - When a footer type is
BREAKING CHANGE
or!
after the typeCHANGE
, the major version will be incremented
To set up the conventionalCommits
in Lerna, we need to add the following to the lerna.json
config file:
"command": { "publish": { "conventionalCommits": true } }
In addition to the auto version increment, Lerna will also create tags and generate changeLog to reflect the changes made in that version.
Building TypeScript monorepos with other tools
Lerna is one of the most popular and earliest tools in the monorepo ecosystem, but since April 2022, it is no longer being actively maintained. In May 2022, Nrwl announced that they will take over the maintenance of Lerna, ensuring this popular tool will continue to be supported by the Nx team.
Nrwl has stated that they will keep improving Lerna, including bug fixes, security updates, adding new features, and making it more compatible with Nx tools. In other words, Lerna is in good hands now, and it will co-exist in the Nx family.
If you are starting a new TypeScript monorepo project, and looking for an alternative tool to Lerna, here are some options:
- Nx: First released in 2016, Nx is a popular, powerful, and mature tool with a large community. It provides a set of integrated tools and plugins for building, testing, and deploying code, as well as tools for managing dependencies and enforcing project standards
- Turborepo: It is designed to be a high-performance tool, and easy to scale and adapt. Although relatively young, it grows fast and is becoming a strong competitor in the monorepo space
- Rush: Designed to handle large groups of npm packages and dependencies. It offers a range of features, such as linked packages, incremental builds, and parallel build execution. Additionally, Rush integrates seamlessly with the Rush Stack, making it a popular choice for managing complex monorepo projects
Conclusion
Setting up Lerna for TypeScript takes some configuration, but in the long run, it will help you increase the maintainability of your packages, both public and private. You can find the source code for this project at my GitHub, and learn more about Lerna in their docs.
LogRocket: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
200’s only
Monitor failed and slow network requests in production
Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third party services are successful, try LogRocket. 
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.
try nx insted of lerna
FYI lerna is not maintained anymore and should not be used.
See note in their site https://github.com/lerna/lerna
Lerna is now supported by Nx actually.