Development: A simple vanilla monorepo

Jérôme Beau
3 min readJun 6, 2024

Interest for mono-repositories has grown since 2016, when people learned with astonishment that Google stored the major part of its code lines into a single repository, using Bazel.

But the main trend about using them started in 2021, with the introduction of npm workspaces, and yarn supported it since its first version. Since then, a number of products were built on those foundations, like Lerna, Turbo, Nx and many others since then.

Why

A most common motivation for using a monorepo is to reference common types between a client and a server: when some request or response payload format changes, you don’t want to (forget to) update it on both sides.

One directory per workspace under the main app root, and one single directory to host packages dependencies. Packages dependencies are in red.

To do so, you’ll typically want to create some “common” or “shared” package that both your client and server would reference. However, publishing and referencing a new version of such a package any time a change occurs in common types (even in a private repository) would be as much a pain during development than updating the types manually on each side.

How

Here we will describe monorepo setup using npm, as this is the (less hyped but) most popular package manager (with the advantage of being shipped with Node).

To avoid publishing changes in packages, files from other packages should be accessible just like any local path. Before built-in support for “workspaces”, package managers allowed this through symbolic linking, which could be configured using commands like npm link.

Using workspaces

All you have to do is to declare modules in the workspaces property your main package.json:

{
"name": "app",
"workspaces": [
"client",
"common",
"server"
]
}

Then just create modules sub-directories with their own package.json with references to dependent workspaces. For instance:

{
"name": "@app/client",
"dependencies": {
"@app/common": "*"
}
}

Of course, each workspace will also have its own dependencies, which will be implicitly installed at the root level in a single node_modules directory when running npm install from that root.

and reference other packages using the app name. For instance a source file from the client package might contain:

import { CommonType } from "@app/common/src/index.js"

To insert such import, just type the name, and IDEs like JetBrains WebStorm will be able to import such a full path for you.

Building

Now, what about building for production? Both client and server need to be deployed along with their dependent common code.

Hopefully, build tools like vite will detect dependencies from sources and generate production code including such dependencies:

{
"name": "@app/client",
"type": "module",
"main": "index.html",
"scripts": {
"dev": "vite --host",
"build": "vite build --mode production"
},
"dependencies": {
"@app/common": "*"
}
"devDependencies": {
"vite": "^5.2.12"
}
}

Should you target ES modules (and you should) like in this example, just specify it in vite.config.js:

export default {
build: {
target: 'esnext'
}
}

Conclusion

With no other tool or package than a standard Node install, it is simple to setup a monorepo. A build tool remains required to build for production.

--

--

Jérôme Beau

Software engineer for three decades, I would like to share my memory. https://javarome.com