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.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.Initialize the lib and web packages
Navigate to each directory and run this command to create a
package.json file in each directory.Change the lib package name
To avoid conflicts with other packages called This tells pnpm to change the package name to
lib on npm and keep the package recognizable to developers in the repository, scope the package to an organization called ours.@ours/lib in the package.json file.Add dependencies
- Add Zod to the
libpackage to represent the data schemas and validate data at runtime.
- Add Lit and your library package
@ours/libto thewebapp.
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.Fill in the library code
- Create an
srcdirectory in yourlibdirectory - Create a TypeScript file
index.tsin yoursrcdirectory - Paste this in your
index.tsfile to create a simple data model
- From the Bazel module root, run this to mark
index.tsas the main entry point of thelibpackage.
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.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 Open the
pnpm add installed: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.Configure TypeScript
Create a TypeScript configuration to ensure proper type-checking across your monorepo. While Vite and your editor tolerate a missing The generated Vite did not need this because it transpiles without type-checking;
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: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.tsc does.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:transpiler = swc explicitly keep using SWC. See the TypeScript guide for the trade-offs.Generate BUILD files
For most JavaScript and TypeScript projects — especially those using Gazelle writes a The
tsconfig outDir or rootDir — configure Gazelle to only update existing BUILD files rather than creating new ones throughout the directory tree.- 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.- Manually create
BUILDfiles, normally alongsidepackage.jsonortsconfig.jsonfiles
- Run Gazelle to populate those
BUILDfiles with the correct rules:
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: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.- Run this command to verify that Bazel can successfully resolve your dependency graph and compile all targets within the web package:
Set up Vite as a Bazel binary
Import the Breaking this down:You should see Vite’s help output. The version number will match whatever
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:@npmis the external workspace declared in theMODULE.bazelfile innpm_translate_lock//packages/webmirrors the path where the package exists in your pnpm workspace.:vite/package_json.bzlreferences the translatedvite/package.jsonfilebin.vite_binaryselects Vite’s generatedjs_binarymacro, with a_binarysuffix to indicate that the resulting target is suitable forbazel run.
pnpm add installed:Declare the development server target
The The From the Bazel module root, navigate to 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.
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.htmlandsrc/hello.ts. Vite transpiles TypeScript itself andindex.htmlpoints at the.tsfile, so serve the sources rather than the.jsoutputs of thets_project.package.jsonand:node_modules, so Vite can resolvelitand@ours/lib.
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:packages/web, update its package.json script, and run it: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: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.
