Monorepo with JavaScript: Code Organization in Practice
In modern web development with JavaScript (Node.js), the question increasingly arises: how to efficiently organize code when you have multiple packages, libraries, or microservices that depend on each other? The traditional approach with separate repositories (multirepo) can lead to code duplication, version synchronization difficulties, and slow CI/CD. The solution is a monorepo. In this article, we will explore what a monorepo is, why it is needed, and how to organize it in practice using JavaScript.
What is a Monorepo and Why is it Needed?
A monorepo is an approach where the code of multiple projects (packages, libraries, services) is stored in a single repository. This is not just "putting everything in one folder," but a well-thought-out architecture that solves many problems:
- Single version of dependencies: you avoid situations where different packages use different versions of the same library.
- Easy refactoring: changes to a shared library are immediately visible in all projects that use it.
- Simplified CI/CD: one repository — one build and testing configuration.
- Code sharing: utilities, types, and configurations are available to all packages without publishing to npm.
However, a monorepo is not suitable for everyone. It requires team discipline and good tools. For JavaScript (Node.js), the main tools are npm workspaces (built into npm 7+) and Lerna (a more advanced tool).
Setting Up a Basic Monorepo with npm workspaces
Let's start with a simple example. Suppose we have a project consisting of two packages: @myapp/core (the core with business logic) and @myapp/server (a server on Express).
Let's create the folder structure:
my-monorepo/├── packages/│ ├── core/│ │ ├── src/│ │ │ └── index.js│ │ ├── package.json│ │ └── ...│ └── server/│ ├── src/│ │ └── index.js│ ├── package.json│ └── ...├── package.json└── ...The root package.json must contain the workspaces field:
{ "name": "my-monorepo", "private": true, "workspaces": [ "packages/*" ], "scripts": { "start": "node packages/server/src/index.js", "test": "npm run test --workspaces" }}Now in packages/core/package.json:
{ "name": "@myapp/core", "version": "1.0.0", "main": "src/index.js"}And in packages/server/package.json we add a dependency on core:
{ "name": "@myapp/server", "version": "1.0.0", "dependencies": { "@myapp/core": "*", "express": "^4.18.0" }}After running npm install in the root, npm will create symlinks between the packages. Now in the server code, we can import core directly:
// packages/server/src/index.jsconst express = require('express');const { calculate } = require('@myapp/core');
const app = express();app.get('/', (req, res) => { res.send(calculate(2, 3));});app.listen(3000);This is a basic example, but it already shows the main advantage: changes to @myapp/core are immediately visible in @myapp/server without publishing to the registry.
Advanced Organization with Lerna
When the number of packages grows beyond 5-10, npm workspaces may not be enough. This is where Lerna comes in — a tool that adds powerful capabilities:
- Running commands in all or selected packages.
- Automatic version management (fixed or independent mode).
- Pu