Skip to main content
This guide demonstrates how to build a web app in a Bazel monorepo using TypeScript. You’ll create a shared library, use it in a web app, and run both with and without Bazel.

What you’ll learn

This guide covers:
  • Creating a web browser app using the Lit framework
  • Implementing type-checking in your code during builds
  • Creating shared libraries for use across multiple apps
  • Running a fast development server that updates as you make changes

Set up the project

Create the basic structure for your library and web app packages.
1

Create the folder structure

Run these commands from the Bazel module root (the directory containing MODULE.bazel). This is especially important if you are continuing from the previous lesson, which left your terminal in packages/hello.
2

Initialize the lib and web packages

Navigate to each directory and run this command to create a package.json file in each directory.
3

Change the lib package name

To avoid conflicts with other packages called lib on npm and keep the package recognizable to developers in the repository, scope the package to an organization called ours.
This tells pnpm to change the package name to @ours/lib in the package.json file.
4

Add dependencies

  1. Add Zod to the lib package to represent the data schemas and validate data at runtime.
  1. Add Lit and your library package @ours/lib to the web app.
pnpm records both registry packages and the local workspace:* dependency in its lockfile. Later, rules_js translates that one lockfile into Bazel targets, preserving the same package boundaries instead of creating a second dependency model.
5

Fill in the library code

  1. Create an src directory in your lib directory
  2. Create a TypeScript file index.ts in your src directory
  3. Paste this in your index.ts file to create a simple data model
  1. From the Bazel module root, run this to mark index.ts as the main entry point of the lib package.
6

Fill in the application code

  1. Create an src directory in your web directory
  2. Create a TypeScript file hello.ts in your src directory
  3. Add this component to your hello.ts file. This component displays on the web page
  1. Create an index.html file to display the component.

Run the app without Bazel

Before integrating Bazel, verify the application works with standard JavaScript tooling. This ensures your code is correct and allows team members to work without Bazel if preferred.
1

Add Vite development server

Navigate to packages/web and install Vite:
2

Run the development server

Start the development server:
The development server starts and prints the address it is listening on. The version number will match whatever pnpm add installed:
Open the Local address in your browser. The page shows Hello World!, rendered by the <hello-element> component.Press Ctrl+C in the terminal to stop Vite before continuing.
Your workspace has hoist=false in .npmrc, which keeps each package’s dependencies isolated instead of sharing them at the root. Some npm packages assume hoisting and break under this setting; if one does, declare the missing dependency with packageExtensions in pnpm-workspace.yaml. Bazel is stricter still, but that strictness is what lets it build packages in parallel without installing every dependency on every machine.

Run the app with Bazel

Now that the application works with standard tooling, integrate Bazel to enable better build performance and scalability for larger projects.
1

Configure TypeScript

Create a TypeScript configuration to ensure proper type-checking across your monorepo. While Vite and your editor tolerate a missing tsconfig.json, it’s better to have explicit TypeScript configuration.In a monorepo, using a single tsconfig.json at the root is easier to manage than having multiple files distributed throughout the repository.Return to the Bazel module root, then create the configuration:
The generated tsconfig.json sets "module": "nodenext" and "verbatimModuleSyntax": true. Under those settings TypeScript decides whether a file is an ES module by looking at the nearest package.json, so mark both packages as ES modules. Without this step, tsc under Bazel fails with TS1295: ECMAScript imports and exports cannot be written in a CommonJS file.
Vite did not need this because it transpiles without type-checking; tsc does.
2

Choose a transpiler

ts_project splits type-checking from transpiling, and it refuses to guess which tool should produce the .js outputs. The starter’s own sample targets pick SWC explicitly, but the targets Gazelle generates in the next step carry no transpiler attribute. Give them a default so the build does not stop with Required Transpiler Selection:Add this line to .bazelrc immediately before its final try-import statement so per-user settings can still override it:
Targets that set transpiler = swc explicitly keep using SWC. See the TypeScript guide for the trade-offs.
3

Generate BUILD files

For most JavaScript and TypeScript projects — especially those using tsconfig outDir or rootDir — configure Gazelle to only update existing BUILD files rather than creating new ones throughout the directory tree.
  1. Add two Gazelle directives to the root BUILD.bazel:
generation_mode update_only tells Gazelle to update existing BUILD files but not create new ones automatically. Without this, Gazelle will generate BUILD files alongside any source files it finds, which can cause unexpected output paths when tsconfig outDir/rootDir remaps paths.js_tsconfig_package_deps enabled makes Gazelle add each package.json to the ts_config target that covers it. tsc only sees files Bazel declares as inputs, so without this it never finds the "type": "module" you set above and falls back to CommonJS.
The update_only directive is particularly important when your tsconfig.json uses outDir or rootDir to manipulate output paths. Gazelle won’t account for these path remappings.
  1. Manually create BUILD files, normally alongside package.json or tsconfig.json files
  1. Run Gazelle to populate those BUILD files with the correct rules:
Gazelle writes a ts_project for each package and a ts_config that forwards to the root tsconfig.json while carrying the local package.json. Because the web app consumes @ours/lib as a workspace package, Gazelle also writes a js_library named pkg to represent that package. packages/lib/BUILD now looks like this:
The ts_project owns compilation and type-checking. The js_library is the JavaScript-facing package boundary: it bundles package.json with the outputs of :lib so other rules_js targets can consume @ours/lib through the same dependency graph. It performs no compilation itself.
  1. Run this command to verify that Bazel can successfully resolve your dependency graph and compile all targets within the web package:
4

Set up Vite as a Bazel binary

Import the vite tool as a Bazel binary to make it runnable by Bazel.To understand how this works, examine Vite’s package.json:
rules_js translates this package.json to a package_json.bzl file whose bin object contains macros for Vite’s command-line entry points. This avoids hard-coding a path inside pnpm’s installation layout.Use buildozer (a command-line tool for modifying BUILD files) to configure the Vite binary:
Breaking this down:
  1. @npm is the external workspace declared in the MODULE.bazel file in npm_translate_lock
  2. //packages/web mirrors the path where the package exists in your pnpm workspace.
  3. :vite/package_json.bzl references the translated vite/package.json file
  4. bin.vite_binary selects Vite’s generated js_binary macro, with a _binary suffix to indicate that the resulting target is suitable for bazel run.
Verify that it works:
You should see Vite’s help output. The version number will match whatever pnpm add installed:
5

Declare the development server target

The web/BUILD file needs a development server target. js_run_devserver copies the files listed in data into a scratch directory and runs the tool there, so think about what Vite needs to see:
  • index.html and src/hello.ts. Vite transpiles TypeScript itself and index.html points at the .ts file, so serve the sources rather than the .js outputs of the ts_project.
  • package.json and :node_modules, so Vite can resolve lit and @ours/lib.
The args line matters. Unlike pnpm dev, the devserver does not start in packages/web; it starts at the root of the scratch directory, which mirrors the repository layout. Passing packages/web tells Vite which directory is the project root.@ours/lib needs the same treatment. Its package.json says "main": "./src/index.ts", but the pkg target Gazelle wrote for the library only carries package.json plus the compiled .js. Add the source file, and mark it # keep so Gazelle leaves it alone on the next run:
From the Bazel module root, navigate to packages/web, update its package.json script, and run it:
Bazel builds the graph, then Vite starts in the scratch directory and serves the same page you saw earlier. Press Ctrl+C after checking the page so you can enable watch mode in the next step.
6

Enable watch mode

bazel run builds once and then leaves the development server running. It won’t automatically rebuild Bazel outputs when you change source files.To enable watch mode, replace bazel with ibazel, a file watcher that reruns Bazel on every change. The starter puts it on your PATH through bazel_env, alongside pnpm and buildozer. Still in packages/web, run:
Now the development server stays running. When a declared source changes, iBazel rebuilds the target and uses the js_run_devserver notification protocol to keep the server process alive while its sandbox is synchronized. Vite then applies its normal browser reload or hot-module update.