Skip to main content

Node.js handbook

Utilizing JavaScript (and TypeScript) in a monorepo can be a daunting task, especially when using Node.js, as there are many ways to structure your code and to configure your tools. With this handbook, we'll help guide you through this process.

info

This guide is a living document and will continue to be updated over time!

moon setup

For this part of the handbook, we'll be focusing on moon, our task runner. To start, languages in moon act like plugins, where their functionality and support is not enabled unless explicitly configured. We follow this approach to avoid unnecessary overhead.

Enabling the language

To enable JavaScript support via Node.js, define the node setting in .moon/toolchain.yml, even if an empty object.

.moon/toolchain.yml
# Enable Node.js
node: {}

# Enable Node.js and override default settings
node:
packageManager: 'pnpm'

Or by pinning a node version in .prototools in the workspace root.

.prototools
node = "18.0.0"
pnpm = "7.29.0"

This will enable the Node.js platform and provide the following automations around its ecosystem:

  • Node modules will automatically be installed if dependencies in package.json have changed, or the lockfile has changed, since the last time a task has ran.
    • We'll also take package.json workspaces into account and install modules in the correct location; either the workspace root, in a project, or both.
  • Relationships between projects will automatically be discovered based on dependencies, devDependencies, and peerDependencies in package.json.
    • The versions of these packages will also be automatically synced when changed.
  • Tasks can be automatically inferred from package.json scripts.
  • And much more!

Utilizing the toolchain

When a language is enabled, moon by default will assume that the language's binary is available within the current environment (typically on PATH). This has the downside of requiring all developers and machines to manually install the correct version of the language, and to stay in sync.

Instead, you can utilize moon's toolchain, which will download and install the language in the background, and ensure every task is executed using the exact version across all machines.

Enabling the toolchain is as simple as defining the node.version setting.

.moon/toolchain.yml
# Enable Node.js toolchain with an explicit version
node:
version: '18.0.0'

Versions can also be defined with .prototools.

Using package.json names and scripts

If you're looking to prototype moon, or reduce the migration effort to moon tasks, you can configure moon to inherit package.json scripts, and internally convert them to moon tasks. This can be achieved with the node.inferTasksFromScripts setting.

.moon/toolchain.yml
node:
inferTasksFromScripts: true

Furthermore, if you'd like to reference projects by their package name (both in configuration and on the command line), instead of the moon project name, enable the node.aliasPackageNames setting.

.moon/toolchain.yml
node:
aliasPackageNames: 'name-and-scope'

These are not enabled by default as we want to encourage the use of moon tasks.

Repository structure

JavaScript monorepo's work best when projects are split into applications and packages, with each project containing its own package.json and dependencies. A root package.json must also exist that pieces all projects together through workspaces.

For small repositories, the following structure typically works well:

/
├── .moon/
├── package.json
├── apps/
│ └── client/
| ├── ...
│ └── package.json
│ └── server/
| ├── ...
│ └── package.json
└── packages/
├── components/
| ├── ...
│ └── package.json
├── theme/
| ├── ...
│ └── package.json
└── utils/
├── ...
└── package.json

For large repositories, grouping projects by team or department helps with ownership and organization. With this structure, applications and libraries can be nested at any depth.

/
├── .moon/
├── package.json
├── infra/
│ └── ...
├── internal/
│ └── ...
├── payments/
│ └── ...
└── shared/
└── ...

Applications

Applications are runnable or executable, like an HTTP server, and are pieced together with packages and its own encapsulated code. They represent the whole, while packages are the pieces. Applications can import and depend on packages, but they must not import and depend on other applications.

In moon, you can denote a project as an application using the type setting in moon.yml.

moon.yml
type: 'application'

Packages

Packages (also known as a libraries) are self-contained reusable pieces of code, and are the suggested pattern for code sharing. Packages can import and depend on other packages, but they must not import and depend on applications!

In moon, you can denote a project as a library using the type setting in moon.yml.

moon.yml
type: 'library'

Configuration

Every tool that you'll utilize in a repository will have its own configuration file. This will be a lot of config files, but regardless of what tool it is, where the config file should go will fall into 1 of these categories:

  • Settings are inherited by all projects. These are known as universal tools, and enforce code consistency and quality across the entire repository. Their config file must exist in the repository root, but may support overrides in each project.
  • Settings are unique per project. These are developers tools that must be configured separately for each project, as they'll have different concerns. Their config file must exist in each project, but a shared configuration may exist as a base (for example, Jest presets).
  • Settings are one-offs. These are typically for applications or tools that require their own config, but aren't prevalent throughout the entire repository.

Dependency management

Dependencies, also known as node modules, are required by all projects, and are installed through a package manager like npm, pnpm, or yarn. It doesn't matter which package manager you choose, but we highly suggest choosing one that has proper workspaces support. If you're unfamiliar with workspaces, they will:

  • Resolve all package.json's in a repository using glob patterns.
  • Install dependencies from all package.json's at once, in the required locations.
  • Create symlinks of local packages in node_modules (to emulate an installed package).
  • Deduplicate and hoist node_modules when applicable.

All of this functionality enables robust monorepo support, and can be enabled with the following:

package.json
{
// ...
"workspaces": ["apps/*", "packages/*"]
}
.yarnrc.yml
# ...
nodeLinker: 'node-modules'
caution

Package workspaces are not a requirement for monorepos, but they do solve an array of problems around module resolution, avoiding duplicate packages in bundles, and general interoperability. Proceed with caution for non-workspaces setups!

Workspace commands

The following common commands can be used for adding, removing, or managing dependencies in a workspace. View the package manager's official documentation for a thorough list of commands.

Install dependencies:

yarn install

Add a package:

# At the root
yarn add <dependency>

# In a project
yarn workspace <project> add <dependency>

Remove a package:

# At the root
yarn remove <dependency>

# In a project
yarn workspace <project> remove <dependency>

Update packages:

yarn upgrade-interactive

Developer tools at the root

While not a strict guideline to follow, we've found that installing universal developer tool related dependencies (Babel, ESLint, Jest, TypeScript, etc) in the root package.json as devDependencies to be a good pattern for consistency, quality, and the health of the repository. It provides the following benefits:

  • It ensures all projects are utilizing the same version (and sometimes configuration) of a tool.
  • It allows the tool to easily be upgraded. Upgrade once, applied everywhere.
  • It avoids conflicting or outdated versions of the same package.

With that being said, this does not include development dependencies that are unique to a project!

Product libraries in a project

Product, application, and or framework specific packages should be installed as production dependencies in a project's package.json. We've found this pattern to work well for the following reasons:

  • Application dependencies are pinned per project, avoiding accidental regressions.
  • Applications can upgrade their dependencies and avoid breaking neighbor applications.

Code sharing

One of the primary reasons to use a monorepo is to easily share code between projects. When code is co-located within the same repository, it avoids the overhead of the "build -> version -> publish to registry -> upgrade in consumer" workflow (when the code is located in an external repository).

Co-locating code also provides the benefit of fast iteration, fast adoption, and easier migration (when making breaking changes for example).

With package workspaces, code sharing is a breeze. As mentioned above, every project that contains a package.json that is part of the workspace, will be symlinked into node_modules. Because of this, these packages can easily be imported using their package.json name.

// Imports from /packages/utils/package.json
import utils from '@company/utils';

Depending on packages

Because packages are symlinked into node_modules, we can depend on them as if they were normal npm packages, but with 1 key difference. Since these packages aren't published, they do not have a version to reference, and instead, we can use the special workspace:^ version (yarn and pnpm only, use * for npm).

{
"name": "@company/consumer",
"dependencies": {
"@company/provider": "workspace:^"
}
}

The workspace: version basically means "use the package found in the current workspace". The :^ determines the version range to substitute with when publishing. For example, the workspace:^ above would be replaced with version of @company/provider as ^<version> when the @company/consumer package is published.

There's also workspace:~ and workspace:* which substitutes to ~<version> and <version> respectively. We suggest using :^ so that version ranges can be deduped.

Types of packages

When sharing packages in a monorepo, there's typically 3 different kinds of packages:

Local only

A local only package is just that, it's only available locally to the repository and is not published to a registry, and is not available to external repositories. For teams and companies that utilize a single repository, this will be the most common type of package.

A benefit of local packages is that they do not require a build step, as source files can be imported directly (when configured correctly). This avoids a lot of package.json overhead, especially in regards to exports, imports, and other import patterns.

Internally published

An internal package is published to a private registry, and is not available to the public. Published packages are far more strict than local packages, as the package.json structure plays a much larger role for downstream consumers, as it dictates how files are imported, where they can be found, what type of formats are supported (CJS, ESM), so on and so forth.

Published packages require a build step, for both source code and TypeScript types (when applicable). We suggest using esbuild or Packemon to handle this entire flow. With that being said, local projects can still import their source files.

Externally published

An external package is structured similarly to an internal package, but instead of publishing to a private registry, it's published to the npm public registry.

External packages are primarily for open source projects, and require the repository to also be public.

Bundler integration

Co-locating packages is great, but how do you import and use them effectively? The easiest solution is to configure resolver aliases within your bundler (Webpack, Vite, etc). By doing so, you enable the following functionality:

  • Avoids having to build (and rebuild) the package everytime its code changes.
  • Enables file system watching of the package, not just the application.
  • Allows for hot module reloading (HMR) to work.
  • Package code is transpiled and bundled alongside application code.
vite.config.ts
import path from 'path';
import { defineConfig } from 'vite';

export default defineConfig({
// ...
resolve: {
alias: {
'@company/utils': path.join(__dirname, '../packages/utils/src'),
},
},
});
info

When configuring aliases, we suggest using the package.json name as the alias! This ensures that on the consuming side, you're using the package as if it's a normal node module, and avoids deviating from the ecosystem.

TypeScript integration

We suggest using TypeScript project references. Luckily, we have an in-depth guide on how to properly and efficiently integrate them!