Do you smell that? Ahh, it’s the scent of a greenfield project’s potential. The aroma of not making old mistakes again. The opportunity to do it better this time. It’s time to set up a monorepo!
Recently, I’ve been experimenting with various ways of setting up monorepos for side projects. Whereas I’d previously shy away from the additional complexity of stringing packages together, modern tools make it a much easier and more pleasant experience. Let’s find out what it’s like today!
For this article, I’ve prepared a repository with the result. If you want, you can check out the commit history to follow along – most commits correspond to a step in this post.
What’s a monorepo again?
A monorepo is a repository that typically holds several applications, packages, or modules. Having these together in a single repository helps to keep these synchronized.
Let’s say you’re publishing several npm packages that are related to each other, like a core package and some plugins. Or maybe you’re building a web application that communicates with a REST API. In both cases, you’ll want to keep these parts in sync. The easier that is, the better. Another benefit is that it helps share code between applications — no need to pull submodules or deploy packages to npm repositories.
The trade-off? We need additional tooling and configuration to make it all work.
Pick your poison
You might’ve heard of Lerna. The tool used to be immensely popular to set up monorepos, until it wasn’t. The project became inactive from early 2021 to early 2022, but it’s back again, this time powered by Nx.
During its inactivity, and perhaps deterred by the commercial aspect of Nx, some have switched from Lerna to Yarn or pnpm workspaces. Both of these are alternative package managers that generally have an edge over npm, node.js’ default package manager, while still leeching off npm’s package repository.
Although npm typically lags behind its alternatives, it did catch up a little bit with npm workspaces. It doesn’t have all the fancy stuff the other tools have, but npm has all the features I need for my uses — maybe for you, too!
If you want advice: take a moment to compare Yarn, pnpm, and npm. Browse their documentation, run some tests, and discover which fits your project best. If they’re all good enough, choose whatever you or your team is most familiar with.
I’ve had relatively little trouble with npm, and as long as node.js is the reigning JavaScript runtime, I suspect it will outlast current and future alternatives. Since the tool works well enough for monorepos, I’ll use npm in the rest of this article. Regardless of which tool you choose, I’m sure you can follow along.
Setting up the workspace
Let’s say we’re building a multilingual web application. We already know that, at some point, we’ll have another user-facing app with the same copy. It’d be useful to have a monorepo with a separate internationalization package, ready to use in applications we’ll build in the future.
We start by creating a package.json
in our root directory. While we’re there, let’s also define our workspaces by adding a workspace
property with a list of paths:
// package.json
{
"private": true,
"workspaces": ["packages/app", "packages/i18n"]
}
To initialize each workspace, we can run npm init --workspace ./packages/{name}
. This command creates the directories and initializes a package.json
. If you prefer, you can do that manually, too.
Note that the name
field in these package.json
files is used when importing files, so it’s recommended to prefix them to avoid clashing with actual npm packages. The name doesn’t have to match the directory name, so you can be creative!
// packages/app/package.json
{
"name": "@mono/app",
"type": "module",
"private": true,
"main": "./index.js"
}
// packages/i18n/package.json
{
"name": "@mono/i18n",
"type": "module",
"private": true,
"main": "./index.js"
}
Let’s add a constant to @mono/i18n
and see if we can import that in @mono/app
:
// packages/i18n/index.js
export const SUPPORTED_LANGUAGES = ['en-GB', 'nl-NL'];
// packages/app/index.js
import { SUPPORTED_LANGUAGES } from '@mono/i18n';
console.log(SUPPORTED_LANGUAGES);
When we run node packages/app/index.js
, we’ll get an error:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@mono/i18n'
Although our root package.json
file is aware of where the packages are, they’re not installed, and the imports won’t resolve. Whenever you add or remove a package, you must run npm install
. This will add symlinks in your node_modules
directory that point to your packages’ source code.
After running npm install
, the command node packages/app/index.js
should now print out the SUPPORTED_LANGUAGES
variable!
Many of the npm commands work the same for workspaces, though you will need to add the --workspace=[pkgname]
argument to run a command for a specific workspace. For example, to run the build
script of the @mono/app
package, we’d run npm run build --workspace=@mono/app
. If you want to run a script for all packages, you can run npm run build --workspaces
. You may want to add the --if-present
option to avoid errors if some of your packages don’t have a build
script defined. To learn more about workspaces, read the documentation.
Adding TypeScript
To add TypeScript, we’ll have to modify some configuration; we must make sure the TypeScript compiler plays nicely with these npm workspaces.
First, we rename our index.js
files from before to index.ts
. Let’s also stick to conventions and move them to a src
subdirectory.
// packages/i18n/src/index.ts
export const SUPPORTED_LANGUAGES = ['en-GB', 'nl-NL'];
// packages/app/src/index.ts
import { SUPPORTED_LANGUAGES } from '@mono/i18n';
console.log(SUPPORTED_LANGUAGES);
Next, let’s set up TypeScript. We can add a common configuration in the root directory that we’ll extend in the tsconfig.json
files of each package:
// tsconfig.common.json
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"skipLibCheck": true
}
}
// packages/i18n/tsconfig.json
// packages/app/tsconfig.json
{
"extends": "../../tsconfig.common.json",
"include": ["src/*"],
"compilerOptions": {
"outDir": "dist"
}
}
Let’s add build scripts to build our packages, and update the main
field to refer to the built file. Note that I’ve commented out some parts of our configuration for brevity.
// packages/app/package.json
{
"name": "@mono/app",
// …
"scripts": {
"build": "tsc"
}
}
// packages/i18n/package.json
{
"name": "@mono/i18n",
// …
"scripts": {
"build": "tsc"
}
}
We’re almost there! Let’s add another build script to our root package.json
to fire all of these in one go:
// package.json
{
// …
"scripts": {
"build": "npm run build --workspaces --if-present"
}
// …
}
Finally, install TypeScript with npm install typescript
. Because we’re not using the --workspace
argument, TypeScript gets installed and saved in the project root, allowing us to access TypeScript in every package.
We can now build our project with npm run build
. Success! If we run node packages/app/dist/index.js
, we’ll see that the value of SUPPORTED_LANGUAGES
is printed. It works!
If we open the project in Visual Studio Code and open packages/app/src/index.ts
, the import statement has a squiggly underline, indicating a warning:
Could not find a declaration file for module '@mono/i18n'.
This is because the main
field in the package.json
of @mono/i18n
refers to a JavaScript file. One that doesn’t have a declaration file. We can get a declaration file by updating packages/i18n/tsconfig.json
and running building that project. This means that to get the correct types in our editor, we’ll need to rebuild @mono/i18n
after every change. That’s a bit inconvenient!
This happens because TypeScript preserves imports during compilation. In our compiled JavaScript file, it still imports @mono/i18n
and uses its package.json
to figure out which file to read, which points us to the compiled JavaScript file — as it should. If we change it to point to the TypeScript files, node.js will throw an error because it doesn’t understand TypeScript files.
Luckily, we can make this process much easier. Instead of a compiler, we can use a bundler, like webpack, Rollup, and Vite. This will produce a bundle; a big file that includes dependencies, too. Realistically, we’re likely to do that anyway, since we’re building a web application.
Adding Vite
For this project, I chose Vite. We’re not going to touch Vite configuration, so I’m sure you can follow along regardless of whether you’re choosing Vite, Astro, Next.js, SvelteKit, Nuxt, or similar tools.
We’re going to avoid installing Vite with npm create vite
, because we already have a directory with files we want to keep. Instead, we’ll do that manually.
Run npm install vite --workspace=@mono/app
to add Vite as a dependency. We can then update our @mono/app
scripts to use Vite:
// packages/app/package.json
{
"name": "@mono/app",
// …
"scripts": {
"dev": "vite",
"build": "tsc && vite build"
}
// …
}
To let TypeScript know importing works subtly differently now, let’s set the module resolution to bundler
:
// tsconfig.common.json
{
// …
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler"
// …
}
}
Finally, we can add a basic HTML file to the web app, which serves as an entry point for Vite:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hello world</title>
</head>
<body>
<script type="module" src="/src/index.ts"></script>
</body>
</html>
Since our bundler will build and bundle @mono/app
and its dependencies in one fell swoop, we no longer need to build @mono/i18n
prior. Let’s update our root package.json
to only run the build script in @mono/app
. While we’re at it, let’s also add a dev
script to start the Vite server:
// package.json
{
// …
"scripts": {
"dev": "npm run dev --workspace=@mono/app",
"build": "npm run build --workspace=@mono/app"
}
// …
}
Finally, let’s update @mono/i18n
’s package.json
to point to the TypeScript files again:
// packages/i18n/package.json
{
"name": "@mono/i18n",
// …
"main": "src/index.ts"
// …
}
Now, if we run npm run dev
and open the webpage, the console will show the value of SUPPORTED_LANGUAGES
again!
Importing other files
All this time we’ve been importing the main file of our packages. We started out importing index.js
and later moved to src/index.ts
. We relied on the main
field in package.json
to tell our tools where to find the file we wanted to import.
Let’s say we have more files in our @mono/i18n
package. How do we import these non-main files?
Many node modules let you import various files. For example, you can import specific functions from lodash:
import get from 'lodash/get';
Because we’ve moved our TypeScript files to the src
directory, we have to include it in our path. Yuck!
import t from '@mono/i18n/src/languages/nl-NL';
console.log(t('navigationAbout'));
We can avoid import paths completely and re-export what we want to expose in packages/i18n/src/index.ts
. This can be done with the export … from …
syntax:
// packages/i18n/src/index.ts
export { default as nl } from './language/nl-NL';
// packages/app/src/index.ts
import { nl as t } from '@mono/i18n';
console.log(t('navigationAbout'));
Another way is by specifying export file mapping in our package.json
, where we can map an import path to an actual file in our package. This also allows us to control what we export, but on a file basis instead of individual exports. Consider the following example:
// packages/i18n/package.json
{
"name": "@mono/i18n",
// …
"exports": {
"./en-GB": "./src/languages/en-GB.ts",
"./nl-NL": "./src/languages/nl-NL.ts"
}
// …
}
With the above configuration, we can import language files as such:
import t from '@mono/i18n/nl-NL';
console.log(t('navigationAbout'));
To summarize, these are our options:
- Move files back to the root directory, removing the
src
directory from the import path. - Re-export in the
src/index.ts
. This gives us control over what code we want to expose and removes paths from import statements. - Add an export mapping to
package.json
. This gives us control over what files we want to expose and lets us define virtual paths to real files.
“What about TypeScript aliases with its paths
option?” That’s a good question! Although the previous examples mainly use TypeScript code, I can think of situations where its compiler doesn’t apply, like when we want to import CSS from another CSS file. Most CSS preprocessors support node-esque module resolution, allowing you to import node packages from CSS files, and make use of the options we’ve explored. If that’s no concern you have, feel free to configure TypeScript’s paths
option instead. If it works, it works! Speaking of which, we should test one last time.
Does npm run build
runs without errors? Check!
If we run npm run dev
and check out the web page, is the translated string printed in the console? Yep! That means everything works.
Now write some code
Phew, that was a lot of work, but now it’s finally time to code. If you had trouble following along: don’t fret! You can check out the repository. It contains everything we’ve covered.
Let me share some closing thoughts: don’t get too hung up about making each package a standalone app or whatever. You know your project best, so you know best what’s worth splitting up. If you’re making a package to mentally separate some code, like separating your application layers, that’s also fine.