Easy Typescript Monorepo with Nx and NPM Workspaces
A guide on how to create a typescript monorepo using npm workspaces and Nx
This article explains how you can have multiple typescript projects in a monorepo. I’m writing this article because I’m sick of simple examples like an even or odd package that don’t come close to representing a true use case.
You can find the finished implementation of what this article talks about here:
What is a Monorepo
A monorepo is a collection of relevant code grouped together. Generally, it includes multiple applications and libraries which the applications depend on. A monorepo is tracked in one repository, hence the name.
There are many reasons to use a monorepo, including making it easier to run tests, build operations, and create libraries to make code re-useable across multiple applications, and one of my personal favorites, getting the frontend and backend teams to stop acting like they’re on two different planets.
What are we doing here?
We’re setting up a monorepo with 3 typescript packages, two are applications and one is a library imported by both of these applications.
We’ll set the whole thing up in typescript, and we’ll make it so that when we make a change in the imported library, it automatically triggers a re-build of the applications which import it.
An Example Problem
Let’s say we have a basic old fashioned application with a frontend and a backend, and that they both depend on the same library. That could be something used for validation, common domain types, or anything at all.
If the backend and the frontend were in separate repositories, we would have to either duplicate the code to both of them which is something everyone and their grandmother knows not to do at this point, or we would have to export the package to npm and add it to both the backend and the frontend as a dependency, or something along those lines.
That sounds a bit involved and maybe you don’t want to publish your package to npm because it contains sensitive domain data that defines the very problem your software is trying to solve.
The Solution
Since both the frontend and the backend are written in typescript, they can simply share the code. This can be done by a library which both the frontend and the backend import.
This way, updating the library in one place makes the changes instantly happen to both the frontend and the backend, and any issues are caught during your build pipeline, for example.
Making a Monorepo with Npm Workspaces and Nx
Using npm workspaces (introduced in v7) and Nx, we’ll create a monorepo with two applications, a frontend and a backend, as well as a common library the purpose of which we’ll define in a bit.
Note that when running most of the upcoming commands, we’ll be using the -w flag alongside the name of the package we want it to affect:
npm run -w package-name-here commandThis will allow us to run all our commands from the root of the monorepo without having to navigate to any of the packages.
1. Creating a monorepo
First, let’s create a new monorepo called HelloWorld. The easiest way to do this is using the following npx command:
npx create-nx-workspace@latest HelloWorld --preset=npmIf you’re prompted with the following:
Need to install the following packages:
create-nx-workspace@16.5.0Then type y and press enter.
You may also be prompted with the following:
Enable distributed caching to make your CI faster …
Yes I want faster builds
NoThis is outside of this article’s scope, so you should answer with no and move on.
You will end up with the following:
Inside package.json, you will see a section called workspaces:
This is how npm knows what packages you have in your monorepo. The packages/* string tells it that everything under the packages folder is a package of the monorepo.
We’ll add another string here to allow our libs to also be detected:
"workspaces": [
"packages/*",
"libs/*"
]Then create an empty folder in your project root called libs:
We’ll add a library to this folder in an upcoming section.
Next, let’s add two applications to the monorepo, a backend application using NestJS and a frontend application using Angular.
2. Creating a backend
The NestJS application can be created and added by running the following command:
npx nx nest new packages/backendAfter this finishes, nest will have created a new git repository. Make sure to delete it. This can be done by deleting the .git folder under packages/backend which can be done using the following command:
rm -rf packages/backend/.gitTo run the backend, you don’t have to go to its package, instead you can do the following:
npm run -w backend start:devWhere the -w backend part tells npm to run the start:dev command in the backend workspace. The name backend comes from the name field in the package.json file of the backend package.
The backend is ready at this point.
2.1. Creating a frontend
To create a frontend using Angular, first install the cli (globally):
npm install -g @angular/clithen run the following command
cd packages
ng new frontendYou can simply spam enter through the prompts as they don’t have any relevant effects for this article.
You can test out the frontend package using the following:
npm run -w frontend start3. Creating a common library
The difficulty in creating a monorepo involving typescript comes from the fact that we need to transpile the code to javascript first, while still keeping things like types, source maps, etc. accessible to anyone who imports the library.
Frameworks like NestJS and Angular take care of compiling themselves for us, and we don’t really import them anywhere anyway. It’s a different story for a library though.
In short, to create a common library, we’ll have to think about a few things:
- Transpiling from Typescript to Javascript
- Keeping types intact
- Source maps for allowing the original code to easily be reached using IDE tools like “Go to References”
- Automatically rebuilding when changes happen
- Cause any place this library was imported to be re-built if it has its own watch mode.
As an example, we’ll make the library represent a very basic logger which surrounds a given message with a bunch of emojies and logs it to the console. So let’s get to it!
First, we’ll create the library using an npm command. We’ll do this using the following command:
npm init -w ./libs/fancy-logger -yThis will create a package.json file under libs/fancy-logger with (more or less) the following contents:
To allow us to use typescript in this package, we’ll have to install typescript and add a couple of entries to the scripts in this package.json.
Get Aziz Nal’s stories in your inbox
Join Medium for free to get updates from this writer.
We’ll start by installing typescript with the following command:
npm i -w fancy-logger --dev typescriptReminder: make sure you’re running these commands from the root of the monorepo. You don’t have to navigate to your package in the terminal as npm provides a way to target a certain package when running a command.
Having installed typescript, replace the scripts section in fancy-logger‘s package.json
"scripts": {
"build": "tsc",
"build:watch": "tsc -w"
},The first script, build, builds the library once, while build:watch builds the library once then watches for any changes and rebuilds the library.
One more thing is to add a tsconfig.json file. You can copy-paste the following into a new file under libs/fancy-logger/tsconfig.json:
{
"compilerOptions": {
"moduleResolution": "nodenext",
"module": "commonjs",
"baseUrl": "./",
"outDir": "./dist",
"lib": ["es6"],
"declaration": true,
"declarationMap": true,
"target": "ES2021",
"sourceMap": true
}
}Here’s a breakdown of what’s going on in the file:
moduleResolution: "nodenext"as recommended by typescript docsmodule: "commonjs"sets what module system the generated javascript will use.baseUrl: "./"to allow absolute imports likesrc/../..(docs)outDir: "./dist"is the location for the built library.lib: ["es6"]to include type declarations for es6 features likePromise,Set,Map, etc.declaration: trueto generate.d.tsfiles and add them to the builddeclarationMap: trueto allow the resultant.d.tsfiles to be found by the IDE (using things such as “Go to Reference”)target: "ES2021"sets the target syntax for the generated javascriptsourceMap: trueto allow debuggers to find the javascript being currently run.
You can change lib , declaration , declarationMap , target , and sourceMap without too much hassle.
module and moduleResolution change how javascript is generated with regards to imports both internal to your library as well as how your library itself is imported. These properties affect each other, and should be changed while referencing the docs. nodenext is generally a good choice for projects using a modern version of node.
Also, be mindful when changing baseUrl or outDir as they need changes to be done in other files as well as we’ll see shortly.
At this point I also recommend adding a .gitignore either at the top of your monorepo (which probably already exists so just add these to its end) or in this library itself with the following contents:
dist
node_modulesNext, we have to set a couple of entries in the library’s package.json to make sure it’s discovered and ran correctly when it’s imported.
The entries we’re going to set are main and types , the first of which sets the main javascript file representing the entry for the library and exporting everything from it.
types specifies where the generated *.d.ts can be found. We’ll specify this one as a glob pattern to catch all the type files in the project.
Here’s how the main and types entries will look (other entries omitted):
{
"main": "dist/index.js",
"types": "dist/*/**.d.ts"
}Make sure to replace any existing main or types if they happen to be there.
Now we can get to writing some code. Start by creating an index.ts file under libs/fancy-logger/ and then create a folder next to it called src:
index.ts will be the entry point for the library, exporting everything we want from the library.
Let’s add a basic class whose job is only to log a message with some nice emojies. We’ll add this one under libs/fancy-logger/src/fancy-logger.ts :
export class FancyLogger {
static log(message: string) {
console.log(`🚀🚀🚀 ${message} 🚀🚀🚀`);
}
}Then export it from the adjacent index.ts file:
export * from "./src/fancy-logger";Here is the basic file organization so far:
Let’s build the package and see if everything goes to plan. Run the following command:
npm run -w fancy-logger buildHaving done so, you’ll see a new dist folder in the library with the following contents:
Behold, a built library! Now we can import it in our frontend and backend and do a little example with it. Note that the contents of the dist folder could be different if you changed the module or moduleResolution properties in your tsconfig.json.
To import the package, add it to the dependencies array of the both the backend and frontend’s package.json files like so:
"dependencies": {
// ...
"fancy-logger": "*"
}After adding it for both the frontend and the backend, run npm install so npm links and sets the packages up for us. This is something we only have to do once when we first setup the library.
Now if you check your node_modules folder at the top of the monorepo you’ll see the fancy-logger package in there:
Let’s use the package in the frontend and the backend to log a message when they first startup. For the backend, we can do this in the main.ts file under packages/backend/src:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
// Importing our library 🚀
import { FancyLogger } from 'fancy-logger';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
// Using the library 🚀
FancyLogger.log('Backend has started');
}
bootstrap();We’ll do the same for the frontend in the app.component.ts file under packages/frontend/src/app/:
import { Component } from "@angular/core";
// Importing our library 🚀
import { FancyLogger } from "fancy-logger";
@Component({
selector: "app-root",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.css"],
})
export class AppComponent {
title = "frontend";
constructor() {
// Using the library 🚀
FancyLogger.log("Frontend is up and running");
}
}Note that the output for the frontend will be in the browser console, not the terminal.
Now, let’s start the backend, frontend, and library in watch mode and see what’s happening.
The commands are:
npm run -w backend start:dev
npm run -w frontend start
npm run -w fancy-logger build:watchYou can run each of these in a separate terminal / pane.
Now for the moment of truth, here’s the output:
With the backend on the left and the frontend on the right, you can clearly see the output of our logger.
Now, since our library is running in watch mode, changing the surrounding emojies should result in the logs instantly changing as well:
You can find a repository with everything we’ve done in this article here:
Caveats
- We’re not using a bundler. There’s still a lot of room for optimizations such as tree-shaking and faster compilation.
- As you might’ve seen in the terminal when running Angular, the frontend does not like
commonjsmodules. - We haven’t setup
nx.jsonto handle our build operations. I recommend you take a look at the docs for that.
Bonus: Script for instantly creating a library
Look, I get it. We’re all lazy. No one has time to repeat all of that work above when creating a new library, so I created a script which creates one for you! You can find it here:
Conclusion
Using npm workspaces and nx, we created a typescript monorepo with two applications and a library, where the two applications import and use the library.
We created a typescript library and setup its tsconfig to generate javascript which can be imported into our applications, with a watch mode to re-build the library on changes and also cause the importing applications to re-build themselves.

