DEV Community

Cover image for Bazel For a Frontend Monorepo
Matti Bar-Zeev
Matti Bar-Zeev

Posted on

Bazel For a Frontend Monorepo

Disclaimer: This was done primarily as a study exercise. The goal was to learn Bazel hands-on and explore whether it could be a good fit for optimizing the build pipeline of this monorepo in the future. The hooks package Bazelification covered here has landed in production by the time you’re reading this, but the full monorepo migration is still a future ambition, not a done deal.

I have a JavaScript monorepo called Pedalboard. It holds a few React component libraries, hooks, linting plugins, and dev tooling. Nothing crazy, but it has been a good playground for experimenting with different build tools over the years.

Right now, whenever a change lands in the repo, a GitHub Actions workflow kicks off and does roughly this:

  1. pnpm install
  2. pnpm run build
  3. pnpm run test:since
  4. pnpm run lint:since

And then it publishes any packages that need a new version.

The test:since and lint:since scripts are smart enough to only run on packages that changed. But pnpm install and pnpm run build run unconditionally every time, on everything. That’s wasteful, and it was bugging me.

I had been hearing about Bazel for a while. It promises smart caching, reproducible builds, and the ability to only rebuild what actually changed. So I figured: why not give it a shot on this monorepo? Worst case I learn something. Best case I have a clear path to a much faster CI pipeline.

To be clear about scope, this post covers Bazelifying one package from the monorepo (the hooks package) end to end: install, build, test, and lint. The goal was not to migrate the whole repo overnight, but to understand the full workflow for one package and figure out whether Bazel is worth the investment.

What I was NOT trying to do here is model the internal dependency graph between packages in the monorepo (for example components depending on hooks). That is a next step and a separate story.

Let’s get into it.

Caching

Caching is a big part of what makes Bazel attractive. It can cache build artifacts locally or remotely, and in a CI context like GitHub Actions that remote cache is where the real speed gains come from. But setting that up properly is its own topic, and we’re not going to deal with it here. This post is about getting the fundamentals right first. That said, we will still benefit from local caching out of the box, which already means that re-running a build or test without any changes will be near-instant.

Preparing a project for Bazel

Rather than installing Bazel directly, I’m going with Bazelisk. Think of it as the .nvmrc equivalent for Bazel: it reads a .bazelversion file and downloads the right binary for you. It also means your team won’t end up on different Bazel versions by accident.

I install it at the root of the monorepo:

pnpm add -D @bazel/bazelisk -w
Enter fullscreen mode Exit fullscreen mode

To be able to invoke it via pnpm, I add a script to the root package.json:

“bazel”: “bazel”
Enter fullscreen mode Exit fullscreen mode

And the .bazelversion file at the project root pins the version:

9.0.1
Enter fullscreen mode Exit fullscreen mode

The MODULE.bazel at the project’s root

The project needs a root build file, for this we create the MODULE.bazel with this content for now:

module(  
   name = "pedalboard",  
   version = "1.0.0"  
)  
Enter fullscreen mode Exit fullscreen mode

Install NPM packages with Bazel

Here is where Bazel throws its first curveball. When Bazel fetches npm packages, it does not put them in node_modules. It downloads them into its own internal cache. My first reaction was “ok but every other tool in this repo expects node_modules to exist, I’m not running two install systems side by side”. Turns out that concern is valid, and the solution is a rule that symlinks Bazel’s downloaded packages back into node_modules. Fair enough.

The ruleset for this is rules_js from aspect-build. I went through the docs to get this right — AI was not helpful here at all, it kept giving me examples with mixed-up APIs from different versions.

Here is the updated MODULE.bazel:

module(
   name = “pedalboard”,
   version = “1.0.0”
)

bazel_dep(name = “rules_nodejs”, version = “6.7.3”)
bazel_dep(name = “aspect_rules_js”, version = “3.0.3”)

node = use_extension(“@rules_nodejs//nodejs:extensions.bzl”, “node”)
node.toolchain(node_version_from_nvmrc = “//:.nvmrc”)
use_repo(node, “nodejs_toolchains”)

pnpm = use_extension(“@aspect_rules_js//npm:extensions.bzl”, “pnpm”)
use_repo(pnpm, “pnpm”)
pnpm.pnpm(pnpm_version_from = “//:package.json”)

npm = use_extension(“@aspect_rules_js//npm:extensions.bzl”, “npm”)
npm.npm_translate_lock(
   name = “npm”,
   pnpm_lock = “//:pnpm-lock.yaml”,
)
use_repo(npm, “npm”)
Enter fullscreen mode Exit fullscreen mode

We pull in rules_nodejs and aspect_rules_js as dependencies, then use their extensions to pin the Node and pnpm versions. The npm_translate_lock call reads pnpm-lock.yaml and makes all packages available as Bazel targets. This is, by the way, one of the reasons I migrated from Yarn to pnpm — Bazel’s npm support is built around pnpm lockfiles. ;)

We also need a REPO.bazel file at the project root to tell Bazel to ignore node_modules when scanning for source files (related to this open issue):

“””Repository configuration for the Pedalboard monorepo.”””

ignore_directories([“**/node_modules”])
Enter fullscreen mode Exit fullscreen mode

Bazelifying the hooks package

The hooks package is the one I’m starting with. It’s a straightforward TypeScript package — no SCSS, no complex bundling, just source files compiled to ESM and CJS, plus type declarations. A good first target.

Since it’s TypeScript, I need a transpiler Bazel can work with. The recommended option is aspect_rules_swc (GitHub), which wraps the SWC compiler. I add it to MODULE.bazel:

bazel_dep(name = “aspect_rules_swc”, version = “2.7.0”)
Enter fullscreen mode Exit fullscreen mode

Build

Compiling TypeScript

The package currently builds two output formats: ESM and CJS. Each has its own SWC config. Here is .swcrc.esm.json:

{
   “jsc”: {
       “parser”: {
           “syntax”: “typescript”,
           “tsx”: false
       },
       “target”: “es2020
   },
   “module”: {
       “type”: “es6
   },
   “sourceMaps”: true
}
Enter fullscreen mode Exit fullscreen mode

And .swcrc.cjs.json is the same but with ”type”: “commonjs” in the module section.

Now for the BUILD.bazel file in the hooks package. The idea is to declare the source files as a js_library, then pass them to two swc rules — one for each output format:

load(“@aspect_rules_swc//swc:defs.bzl”, “swc”)

filegroup(
   name = “build”,
   srcs = [
       “:compile_esm”,
       “:compile_cjs”,
   ],
)

js_library(
   name = “sources”,
   srcs = [“index.ts”] + glob([“src/**/*.ts”], exclude = [“**/*.test.ts”]),
)

swc(
   name = “compile_esm”,
   srcs = [“:sources”],
   swcrc = “.swcrc.esm.json”,
   source_maps = “true”,
   out_dir = “dist/esm”,
)

swc(
   name = “compile_cjs”,
   srcs = [“:sources”],
   swcrc = “.swcrc.cjs.json”,
   source_maps = “true”,
   out_dir = “dist/cjs”,
)
Enter fullscreen mode Exit fullscreen mode

Running pnpm bazel build :build from inside the package — Bazel compiles and drops the ESM and CJS artifacts into bazel-bin.

Not bad.

Type declarations

SWC compiles TypeScript but does not emit .d.ts files. For that I need aspect_rules_ts, which wraps tsc in declaration-only mode:

bazel_dep(name = “aspect_rules_ts”, version = “3.0.0”)
Enter fullscreen mode Exit fullscreen mode

I also add a flag to .bazelrc so the rule respects skipLibCheck from the tsconfig rather than overriding it:

common --@aspect_rules_ts//ts:skipLibCheck=honor_tsconfig
Enter fullscreen mode Exit fullscreen mode

Before wiring this into the hooks package, I need a root BUILD.bazel file. Two things require it:

  • aspect_rules_js needs the root to be a valid Bazel package so that npm_translate_lock can resolve the //:all target during module extension evaluation.
  • npm_link_all_packages at the root is what child packages reference when they pull in node_modules. Without a root BUILD.bazel, those targets simply don’t exist.

Here is the root BUILD.bazel:

# Root BUILD file required by aspect_rules_js

load(“@npm//:defs.bzl”, “npm_link_all_packages”)
load(“@aspect_rules_ts//ts:defs.bzl”, “ts_config”)

npm_link_all_packages(name = “node_modules”)

ts_config(
   name = “tsconfig_base”,
   src = “tsconfig.base.json”,
   visibility = [“//packages:__subpackages__”],
)
Enter fullscreen mode Exit fullscreen mode

I expose tsconfig_base here because sub-packages extend it and need access to it from within Bazel’s sandbox.

Back in the hooks BUILD.bazel, I add the type compilation target:

load(“@npm//:defs.bzl”, “npm_link_all_packages”)
...
load(“@aspect_rules_ts//ts:defs.bzl”, “ts_config”, “ts_project”)

npm_link_all_packages(name = “node_modules”)

filegroup(
   name = “build”,
   srcs = [
       “:compile_esm”,
       “:compile_cjs”,
       “:compile_types”, # Adding the step to compile types
   ],
)

# Defining the ts config for types, using the base config we defined earlier
ts_config(
   name = “tsconfig”,
   src = “tsconfig.esm.json”,
   deps = [“//:tsconfig_base”],
)

# Building it...
ts_project(
   name = “compile_types”,
   srcs = [“:sources”],
   tsconfig = “:tsconfig”,
   deps = [“:node_modules/@types/react”],
   declaration = True,
   declaration_dir = “dist/types”,
   emit_declaration_only = True,
   validate = False,
)
Enter fullscreen mode Exit fullscreen mode

One of the deps is @types/react, which we get through npm_link_all_packages. Here is where we’re at now:

One gotcha I hit: Bazel does not like the v prefix in .nvmrc (as in v20.0.0). It needs the bare version number. Not the most helpful error message, but ok.

Copying the files to the package’s dist dir

Bazel writes all build outputs to bazel-bin, not back to the source tree. That’s by design. But our publishing tool (Changesets) expects the dist directory to sit inside the package itself. So I need to copy the artifacts back.

For that I add aspect_bazel_lib to MODULE.bazel:

bazel_dep(name = “aspect_bazel_lib”, version = “2.9.4”)
Enter fullscreen mode Exit fullscreen mode

And in the hooks BUILD.bazel, two new targets handle the copy:

copy_to_directory(
   name = “dist_dir”,
   srcs = [
       “:compile_esm”,
       “:compile_cjs”,
       “:compile_types”,
   ],
   replace_prefixes = {“dist/”: “”},
)

write_source_files(
   name = “build_and_copy”,
   files = {
       “dist”: “:dist_dir”,
   },
)
Enter fullscreen mode Exit fullscreen mode

copy_to_directory collects the outputs and strips the dist/ prefix so we end up with esm/, cjs/, and types/ at the top level. write_source_files then copies that directory back into the source tree.

Running the build_and_copy target triggers the full build and writes the results to dist/. The last piece is updating the build npm script in package.json:

“build”: “bazel run //packages/hooks:build_and_copy”
Enter fullscreen mode Exit fullscreen mode

Build done. On to testing.

Testing

Running Jest

Our test runner is Jest, so for that we need to install the aspect_rules_jest for Bazel. In the MODULE.bazel we add:

bazel_dep(name = "aspect_rules_jest", version = "0.25.2")  
Enter fullscreen mode Exit fullscreen mode

In the hookd BUILD.bazel file we load the rule

load("@aspect_rules_jest//jest:defs.bzl", "jest_test")  
Enter fullscreen mode Exit fullscreen mode

The Rule requires the project to have “jest-cli” as a dependency. So we need to add it to the package.json of the project’s root.

The jest configuration we’re using also inherits from a root jest config, and we need to expose it to nested packages. Since the tests in Bazel run its sandbox (bazel-bin) we need to expose this root configuration so it will be available there. This is why we’re using js_libray for it. In the root’s BUILD.bazel we add:

...  
load("@aspect_rules_js//js:defs.bzl", "js_library")

...

js_library(  
   name = "jest_config_base",  
   srcs = ["jest.config.base.js"],  
   visibility = ["//packages:__subpackages__"],  
)

Enter fullscreen mode Exit fullscreen mode

Last we set the target in the hooks package BUILD.bazel file:

...  
load("@aspect_rules_jest//jest:defs.bzl", "jest_test")

...

js_library(  
   name = "test_sources",  
   srcs = glob(["src/**/*.test.ts"]),  
)

...

jest_test(  
   name = "jest",  
   config = "jest.config.js",  
   auto_configure_reporters = False,  
   args = ["--reporters=default"],  
   data = [  
       ":sources",  
       ":test_sources",  
       "jest.config.js",  
       "//:jest_config_base",  
       "//:node_modules/@swc/core",  
       "//:node_modules/@swc/jest",  
       ":node_modules/@testing-library/react-hooks",  
       ":node_modules/react",  
   ],  
   node_modules = ":node_modules",  
)  
Enter fullscreen mode Exit fullscreen mode

And in order to get a logs which are not riddled with a lot of unescaped color chars, you can add this to your .bazelrc

test --test_env=FORCE_COLOR=0  
Enter fullscreen mode Exit fullscreen mode

Last thing remaining to do is replace the package.json test npm script to call bazel, like so:

"test": "bazel run //packages/hooks:jest",  
Enter fullscreen mode Exit fullscreen mode

Coverage report

It’s possible to pass parameters to Bazel, meaning that you run a npm script, and it passes the params to Bazel target. For that you need to add an “extra” 2 “--”, one to pass the Bazel and the other to pass to Jest. For example:

pnpm test -- -- --coverage  
Enter fullscreen mode Exit fullscreen mode

We can make it a bit easier if we set the npm script to include the first “--”:

"test": "bazel run //packages/hooks:jest --",  
Enter fullscreen mode Exit fullscreen mode

And now we can call it like this

pnpm test -- --coverage  
Enter fullscreen mode Exit fullscreen mode

This also helps when we call it from the root of the project when we collect the coverage for all. In that case pnpm knows to pass the param without needing the extra “--”, so nothing has to be changed on the root’s package.json

What we have will trigger the coverage report, but you won’t see the coverage directory in the package, since Bazel writes it into the sandbox. This is not what we want.

So you can tell Bazel test target where you want the coverage dir to be written in, using the coverageDirectory param. Sadly, it does not respect the Jest config for this, and you need to put it in the actual call. So we end up with this:

"test": "bazel run //packages/hooks:jest -- --coverageDirectory=$(pwd)/coverage",  
Enter fullscreen mode Exit fullscreen mode

Coverage paths and the NYC reporter

There is one more wrinkle with coverage. The root's coverage:combined script collects each package's coverage-final.json into .nyc_output/ using the collectFiles script from @pedalboard/scripts, then runs nyc report to produce the final lcov output.

The problem: when Jest runs inside Bazel's sandbox, the file paths it embeds in coverage-final.json point deep into the Bazel cache. NYC tries to open each file at that exact path to annotate the source, but those sandbox paths are gone by the time the report runs. The coverage data is all there — statements, branches, functions — but the reporter can't find the source files and falls over.

The fix is to remap the paths at collection time. Instead of copying each coverage-final.json straight into .nyc_output/, the collectFiles script now normalizes every path in the JSON back to its real relative path from the repo root before writing the file. NYC can then resolve the sources without any extra configuration, regardless of whether the coverage was produced by Bazel or plain Jest.

Watch mode

At the current state the aspect_rules_jest does not support the watch mode for Jest and to make it work in Bazel requires additional configuration, which I think is not necessary. Watch mode is for dev time, and I think it is perfectly fine to use the plain old npm Jest to do that.

For that I create another npm script:

"test:watch": "jest --watch"  
Enter fullscreen mode Exit fullscreen mode

And there we have it.

Cool ! we’re ready to jump to the linting process

Linting

We’re going to go to the Bazel center registry again to look for a rule that can help us. This is the rule we need to add to the MODULE.bazel file:

bazel_dep(name = "aspect_rules_lint", version = "2.3.0")  
Enter fullscreen mode Exit fullscreen mode

There is a version conflict with the rules_go dependency, and to solve that we override its version, like this in the MODULE.bazel file:

single_version_override(  
   module_name = "rules_go",  
   version = "0.60.0",  
)  
Enter fullscreen mode Exit fullscreen mode

Exposing the ESlint config for all sub packages

In the root’s BUILD.bazel file we will make the eslint configuration available for all sub packages. We are using the js_library for that, and notice that we add all the dependencies that this configuration requires:

js_library(  
   name = "eslintrc",  
   srcs = ["eslint.config.mjs"],  
   deps = [  
       ":node_modules/globals",  
       ":node_modules/eslint",  
       ":node_modules/@eslint/js",  
       ":node_modules/eslint-plugin-react",  
       ":node_modules/typescript-eslint",  
       ":node_modules/@typescript-eslint/parser",  
       ":node_modules/@typescript-eslint/eslint-plugin",  
       ":node_modules/@pedalboard/eslint-plugin-craftsmanlint",  
   ],  
   visibility = ["//packages:__subpackages__"],  
)

Enter fullscreen mode Exit fullscreen mode

Making ESlint available for all sub packages

The recommended way to go about linting in Bazel is to create a dedicated tools/lint “package” which makes the ESlint tool available to all packages. For that we create a tools/lint directory and put 2 files in it:

tools/lint/BUILD.bazel

load("@npm//:eslint/package_json.bzl", eslint_bin = "bin")

eslint_bin.eslint_binary(  
   name = "eslint",  
   data = [  
       "//:node_modules/chalk",  
   ],  
)

Enter fullscreen mode Exit fullscreen mode

This declares the eslint_binary target (//tools/lint:eslint) that the aspect uses to actually run ESLint

tools/lint/linterz.bzl

load("@aspect_rules_lint//lint:eslint.bzl", "lint_eslint_aspect")  
load("@aspect_rules_lint//lint:lint_test.bzl", "lint_test")

eslint = lint_eslint_aspect(  
   binary = Label("//tools/lint:eslint"),  
   configs = [  
       Label("//:eslintrc"),  
   ],  
)

eslint_test = lint_test(aspect = eslint)

Enter fullscreen mode Exit fullscreen mode

This is a Starlark library that defines and exports the eslint aspect and eslint_test factory for sub packages to load()

Here we use the lint_eslint_aspect rule and notice that we give it the configuration we declared recently in the root’s BUILD.bazel file.

Using the eslint_test target

Now that we have all this set we can use the eslint_test target. We do that in the package’s BUILD.bazel file:

...

load("@aspect_rules_js//js:defs.bzl", "js_library")  
load("//tools/lint:linters.bzl", "eslint_test")

...

eslint_test(  
   name = "lint",  
   timeout = "short",  
   srcs = [  
       ":compile_types",  # ts_project — all non-test TS sources  
       ":test_sources",   # js_library — test files  
   ],  
)

Enter fullscreen mode Exit fullscreen mode

Last thing remaining to do is replace the package.json test npm script to call bazel, like so:

"lint": "bazel run //packages/hooks:lint",  
Enter fullscreen mode Exit fullscreen mode

Wrapping up

So what did we actually accomplish here?

We took one package from a JavaScript monorepo and plugged it into Bazel end to end: dependency installation via the pnpm lockfile, TypeScript compilation to ESM and CJS, type declaration generation, copying build artifacts back to dist/, running Jest tests with coverage support, and ESLint linting. All through Bazel targets wired up to the existing npm scripts, so nothing in the repo's external interface changed.

Was it worth it? That depends on what you're optimizing for. The setup cost is real — Bazel is not exactly beginner-friendly, and I spent more time than I'd like to admit figuring out why things were failing. The error messages are unhelpful and the documentation has gaps that AI tools made worse, not better.

But the fundamentals are solid. The local caching already pays off during development, and the path to remote caching in CI is clear. Once the dependency graph between packages is modeled in Bazel (still to come), the build system will know exactly what to rebuild and what to skip. That's the goal.

For now, one package is Bazelified and in production. The rest of the monorepo will follow — eventually. No promises on timeline. 😄

You can find the full code in the Pedalboard repo on GitHub.

Top comments (0)