--- name: client-side-js description: Use when a val needs to ship JavaScript that runs in the browser — React apps, vanilla DOM scripts, canvas/games, htmx/Alpine, or any client-side module beyond a single inline snippet. Explains how Val Town serves transpiled .ts/.tsx/.jsx modules with no build step, how the browser resolves their imports, and how to load third-party deps. triggers: [client, browser, frontend, script, module, esm, bundle, build, hydrate, react-dom, mount, interactive, canvas, alpine, htmx, serveFile, type=module] --- # Client-side JavaScript Val Town has **no build step and no bundler**. A client-side module is just a file in your val that you serve over HTTP; Val Town transpiles it per request. You point a ` ``` The path you serve at and the file's location are up to you. A common shortcut is a wildcard that serves a whole directory of modules and assets: ```ts app.get("/client/**/*", (c) => serveFile(c.req.path)); ``` `serveFile` defaults to the current val. If you call it from a non-entrypoint file and paths don't resolve, pass `import.meta.url` as the second argument. ## Default: versioned, immutably cached modules `serveImmutableFile` makes your val's frontend faster by letting browsers cache files immutably; publishing bumps the val's version, which invalidates automatically. Measured: repeat visits **665ms → 157ms with zero asset requests**. ```ts import { immutableFileUrl, serveImmutableFile } from "https://esm.town/v/std/utils/index.ts"; app.get("/__immutable/*", (c) => serveImmutableFile(c.req.path)); ``` In the never-cached HTML shell, stamp the entry module: `immutableFileUrl("/frontend/index.tsx")` → `/__immutable/42/frontend/index.tsx` (42 = the val's current version). Relative imports resolve under the same prefix, so only the entry needs stamping — one route and one stamped URL cover the whole client graph. - Old-version URLs 404 after a publish (like Next.js build assets); a reload picks up the new version. - Retrofitting an existing val without touching its shell? Also point its old file route at `serveImmutableFile` — bare paths then 302 into versioned space, at one redirect per page view. ### Alternative: serve directly from esm.town Every val file already has a public esm.town URL that transpiles on demand, so you can skip `serveFile` and point a script straight at it: ```html ``` `serveFile` is usually preferred because the module is served same-origin from a path you control, and you don't have to hardcode your own val URL. ## How imports resolve in the browser The transpiler does not bundle or rewrite imports — it only strips types and JSX. So every import in a client module must be something the **browser** can fetch as a URL: - **Local imports need explicit extensions.** `import { x } from "./util.ts"` resolves to `/util.ts` (or relative to the served path) and must be served too — by the same route or a wildcard. Omitting the extension (`./util`) 404s. - **Third-party deps need full ESM URLs.** Bare specifiers like `import React from "react"` don't resolve in the browser. Import from a CDN such as esm.sh, with versions pinned: ```ts import { createRoot } from "https://esm.sh/react-dom@18.2.0/client"; ``` An [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) in the HTML is an option if you want bare specifiers in client code. The same model works for any client code — React, vanilla DOM scripts, a canvas game loop, Alpine, htmx. Only the imports differ; for a plain `.ts` module with no dependencies there's nothing to load from a CDN at all. ## React specifics Pin all React-family imports to the same version (18.2.0) and pass `?deps=react@18.2.0,react-dom@18.2.0` on libraries that depend on React. Mismatched copies cause `Cannot read properties of null (reading 'useState')`. See the `react-ui` skill for JSX and styling conventions. ## What not to do - **No app logic in inline `