> ## Documentation Index
> Fetch the complete documentation index at: https://site.aspect.build/llms.txt
> Use this file to discover all available pages before exploring further.

# TypeScript and Web

> Build a TypeScript web app in a Bazel monorepo using the Lit framework, sharing libraries across packages and running a fast watch-mode dev server.

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](https://lit.dev/)
* 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.

<Steps>
  <Step title="Create the folder structure" titleSize="h3">
    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`.

    ```bash theme={null}
    mkdir -p packages/lib packages/web 
    ```
  </Step>

  <Step title="Initialize the lib and web packages" titleSize="h3">
    Navigate to each directory and run this command to create a `package.json` file in each directory.

    ```bash theme={null}
    cd packages/lib
    pnpm init
    cd ../web
    pnpm init
    cd ../..
    ```
  </Step>

  <Step title="Change the lib package name" titleSize="h3">
    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`.

    ```bash theme={null}
    cd packages/lib
    pnpm pkg set name="@ours/lib" 
    cd ../..
    ```

    This tells pnpm to change the package name to `@ours/lib` in the `package.json` file.
  </Step>

  <Step title="Add dependencies" titleSize="h3">
    1. Add [Zod](https://zod.dev/) to the `lib` package to represent the data schemas and validate data at runtime.

    ```bash theme={null}
    cd packages/lib
    pnpm add zod
    ```

    2. Add [Lit](https://lit.dev/) and your library package `@ours/lib` to the `web` app.

    ```bash theme={null}
    cd ../web
    pnpm add lit
    pnpm add @ours/lib --workspace
    cd ../..
    ```

    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.
  </Step>

  <Step title="Fill in the library code" titleSize="h3">
    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

    ```typescript theme={null}
    import { z } from "zod";
    export const NameSchema = z.string().min(1);

    export function validateName(input: unknown) {
      return NameSchema.safeParse(input);
    }
    ```

    4. From the Bazel module root, run this to mark `index.ts` as the main entry point of the `lib` package.

    ```bash theme={null}
    cd packages/lib
    pnpm pkg set main="./src/index.ts"
    cd ../..
    ```
  </Step>

  <Step title="Fill in the application code" titleSize="h3">
    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

    ```typescript theme={null}
    import { LitElement, html } from "lit";
    import { validateName } from "@ours/lib";

    export class HelloElement extends LitElement {
      render() {
        const result = validateName("World");
        return html`<p>Hello ${result.success ? result.data : "stranger"}!</p>`;
      }
    }

    customElements.define("hello-element", HelloElement);
    ```

    4. Create an `index.html` file to display the component.

    ```html theme={null}
    <!DOCTYPE html>
    <html>
      <body>
        <hello-element></hello-element>
        <script type="module" src="./src/hello.ts"></script>
      </body>
    </html>
    ```
  </Step>
</Steps>

## 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.

<Steps>
  <Step title="Add Vite development server" titleSize="h3">
    Navigate to `packages/web` and install Vite:

    ```bash theme={null}
    cd packages/web
    pnpm add -D vite
    pnpm pkg set scripts.dev="vite"
    ```
  </Step>

  <Step title="Run the development server" titleSize="h3">
    Start the development server:

    ```bash theme={null}
    pnpm dev
    ```

    The development server starts and prints the address it is listening on. The version number will match whatever `pnpm add` installed:

    ```
      VITE v8.x.x  ready in 619 ms

      ➜  Local:   http://localhost:5173/
      ➜  Network: use --host to expose
    ```

    Open the `Local` address in your browser. The page shows `Hello World!`, rendered by the `<hello-element>` component.

    Press <kbd>Ctrl</kbd>+<kbd>C</kbd> in the terminal to stop Vite before continuing.
  </Step>
</Steps>

<Info>
  Your workspace has <code>hoist=false</code> in <code>.npmrc</code>, 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 <a href="https://pnpm.io/settings#packageextensions"><code>packageExtensions</code></a> in <code>pnpm-workspace.yaml</code>. Bazel is stricter still, but that strictness is what lets it build packages in parallel without installing every dependency on every machine.
</Info>

## 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.

<Steps>
  <Step title="Configure TypeScript" titleSize="h3">
    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:

    ```bash theme={null}
    pnpm exec tsc --init
    ```

    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`.

    ```bash theme={null}
    cd packages/lib && pnpm pkg set type=module
    cd ../web && pnpm pkg set type=module
    cd ../..
    ```

    Vite did not need this because it transpiles without type-checking; `tsc` does.
  </Step>

  <Step title="Choose a transpiler" titleSize="h3">
    `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:

    ```text theme={null}
    common --@aspect_rules_ts//ts:default_to_tsc_transpiler
    ```

    Targets that set `transpiler = swc` explicitly keep using SWC. See the [TypeScript guide](/docs/bazel/javascript/typescript#transpiler) for the trade-offs.
  </Step>

  <Step title="Generate BUILD files" titleSize="h3">
    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`:

    ```python theme={null}
    # gazelle:generation_mode update_only
    # gazelle:js_tsconfig_package_deps enabled
    ```

    `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.

    <Warning>
      The <code>update\_only</code> directive is particularly important when your <code>tsconfig.json</code> uses <code>outDir</code> or <code>rootDir</code> to manipulate output paths. Gazelle won't account for these path remappings.
    </Warning>

    2. Manually create `BUILD` files, normally alongside `package.json` or `tsconfig.json` files

    ```bash theme={null}
    touch packages/lib/BUILD
    touch packages/web/BUILD
    ```

    3. Run Gazelle to populate those `BUILD` files with the correct rules:

    ```bash theme={null}
    aspect gazelle
    ```

    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:

    ```python theme={null}
    load("@aspect_rules_js//js:defs.bzl", "js_library")
    load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project")
    load("@npm//:defs.bzl", "npm_link_all_packages")

    npm_link_all_packages(name = "node_modules")

    ts_project(
        name = "lib",
        srcs = ["src/index.ts"],
        declaration = True,
        declaration_map = True,
        preserve_jsx = False,
        source_map = True,
        tsconfig = ":tsconfig",
        deps = [":node_modules/zod"],
    )

    js_library(
        name = "pkg",
        srcs = ["package.json"],
        visibility = ["//:__pkg__"],
        deps = [":lib"],
    )

    ts_config(
        name = "tsconfig",
        src = "//:tsconfig",
        visibility = [":__subpackages__"],
        deps = [":package.json"],
    )
    ```

    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.

    4. Run this command to verify that Bazel can successfully resolve your dependency graph and compile all targets within the web package:

    ```bash theme={null}
    bazel build //packages/web/...
    ```
  </Step>

  <Step title="Set up Vite as a Bazel binary" titleSize="h3">
    Import the `vite` tool as a Bazel binary to make it runnable by Bazel.

    To understand how this works, examine Vite's `package.json`:

    ```json theme={null}
    {
      "name": "vite",
      "type": "module",
      "license": "MIT",
      "author": "Evan You",
      "description": "Native-ESM powered web dev build tool",
      "bin": {
        "vite": "bin/vite.js"
      }
    }
    ```

    `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:

    ```bash theme={null}
    buildozer 'new_load @npm//packages/web:vite/package_json.bzl bin' //packages/web:__pkg__
    buildozer 'new bin.vite_binary vite' //packages/web:__pkg__
    ```

    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:

    ```bash theme={null}
    bazel run //packages/web:vite -- --help
    ```

    You should see Vite's help output. The version number will match whatever `pnpm add` installed:

    ```
    vite/8.x.x

    Usage:
      $ vite [root]
    ```
  </Step>

  <Step title="Declare the development server target" titleSize="h3">
    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`.

    ```bash theme={null}
    buildozer 'new_load @aspect_rules_js//js:defs.bzl js_run_devserver' //packages/web:__pkg__
    buildozer 'new js_run_devserver dev' //packages/web:__pkg__
    buildozer 'set tool "vite"' //packages/web:dev
    buildozer 'add data index.html package.json src/hello.ts :node_modules' //packages/web:dev
    buildozer 'set args "packages/web"' //packages/web:dev
    ```

    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:

    ```bash theme={null}
    buildozer 'add srcs src/index.ts' //packages/lib:pkg
    buildozer 'comment srcs src/index.ts keep' //packages/lib:pkg
    ```

    From the Bazel module root, navigate to `packages/web`, update its `package.json` script, and run it:

    ```bash theme={null}
    cd packages/web
    pnpm pkg set scripts.dev="bazel run //packages/web:dev"
    pnpm dev
    ```

    Bazel builds the graph, then Vite starts in the scratch directory and serves the same page you saw earlier. Press <kbd>Ctrl</kbd>+<kbd>C</kbd> after checking the page so you can enable watch mode in the next step.
  </Step>

  <Step title="Enable watch mode" titleSize="h3">
    `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:

    ```bash theme={null}
    pnpm pkg set scripts.dev="ibazel run //packages/web:dev"
    pnpm dev
    ```

    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.
  </Step>
</Steps>
