pm4ai

Bun-only React frontend

Serving a client-only React SPA with Bun, no meta-framework

A client-only React single-page app served by Bun.serve with Bun's own bundler — no meta-framework, no 2nd build tool.

MUST

Runtime and server

  • Serve the frontend from one Bun.serve process that imports the HTML entry and serves the SPA, the base path, and a health route. Why: Bun's bundler and server cover build, hot reload, base-path routing and health in one runtime, so a client-only SPA needs no framework server.
  • Mount every route and asset under the deployment base path through Bun.serve route patterns, single-sourced from one constant. Why: the base path is the deployment contract when the app is hosted beside another app on one domain.

Production JSX

  • Pass Bun.serve a BOOLEAN development: false in production, never an object like { hmr: false }. Why: an object keeps Bun's bundler in development mode and emits the dev JSX runtime (jsxDEV), so the served SPA throws jsxDEV is not a function and never mounts — an HTML 200 and a healthy health route hide it, because the crash is client-side after load. Gate development on the deploy env: NODE_ENV === 'production' ? false : { hmr: true }.
  • Prove the SPA MOUNTS, never that the server answers — a committed headless-browser drive that loads the served SPA and asserts the app rendered (a real element, zero console errors). Why: curl on the HTML proves the server serves, not that React mounted; the production-JSX crash and every other pre-mount throw pass a 200 and fail the browser.

Tailwind and assets

  • Register bun-plugin-tailwind in bunfig.toml under [serve.static] plugins. Why: the serve and bundler lane reads that list where a bare bun build invocation does not, so Tailwind at-rules pass through uncompiled unless the plugin is registered for the serve.
  • Declare Tailwind @source globs relative to the serve CWD — the app's own src and each node_modules/<lib>/dist whose classes are used. Why: bun-plugin-tailwind scans from the process CWD rather than the css file, so a css-file-relative glob misses and those utilities never compile.
  • Build a bundled worker (pdf.js and similar) as its own entry and serve it plus its wasm as static assets, pointing the library's worker and wasm URL at the served paths. Why: a worker referenced by new URL(..., import.meta.url) is a runtime asset the client fetches rather than an inlined import, so the bundler does not auto-emit it.
  • Serve a BASE-PATH app from a PRE-BUILT dist built with publicPath equal to the base path, not from on-the-fly Bun.serve HTML bundling. Why: Bun's on-the-fly HTML route emits its JS/CSS chunks at ROOT (/chunk-HASH.js) and serves them only there — a consumer reverse-proxying only /studio/* to the app never receives those root chunks, so the SPA's own scripts 404 and the page renders blank while the HTML itself is 200. Bun.build({ entrypoints:['./index.html'], publicPath:'/studio/', plugins:[tailwind] }) emits every asset URL under the base path; serve that dist statically under the base path with an index.html fallback, and the assets reach the app through the same /studio/* proxy. Pass the tailwind plugin to Bun.build explicitly (the bare build ignores bunfig [serve.static].plugins). Keep on-the-fly HTML bundling for the dev/HMR path only, where assets at root are fine on localhost.
  • Prove the deployed topology with a real browser driving the app and its API behind ONE origin (a reverse proxy fronting /studio/* → the app and the API path → the backend), not just the app in isolation. Why: the base-path-asset miss, cross-origin vs same-origin auth, and proxy routing are only exercised when the app is reached the way a consumer hosts it; an isolated app drive renders fine and hides all three.
  • Carry a local permissive #:schema on bunfig.toml when the TOML linter's bundled bun schema rejects a valid bun-documented key such as serve.static. Why: the schema lags bun's fullstack config, so the key works at runtime while the stale schema fails the gate, and a documented permissive override keeps both green.
  • List in trustedDependencies every dep whose install links a binary through a postinstall — bun-plugin-tailwind (and dprint) pull the bun npm package whose postinstall links node_modules/.bin/bun. Why: bun skips an untrusted dep's postinstall, so the .bin/bun shim is never linked and the next invocation dies ENOEXEC — a failure that names the shim, never the skipped trust.
  • Serve EVERY pdf.js runtime asset the corpus needs under the base path — the worker AND cMaps, standard_fonts, and wasm — and point the library's config (GlobalWorkerOptions.workerSrc plus cMapUrl/standardFontDataUrl/wasmUrl) at the served paths. Why: each is referenced by a module-relative new URL(..., import.meta.url) the bundler cannot emit, so serving only the worker renders a born-digital page while CJK, standard-font, and image-wasm documents break — the whole asset class is served, not the one that surfaced.

Workspace-library typed-lint before a gate that skips the build

  • Give a workspace library a development export condition pointing at its typed src, and set customConditions: ["development"] in the consumer tsconfig. Why: a gate that typed-lints without building the library's dist resolves its import to an absent .d.ts and raises a no-unsafe-* wall; the development condition resolves to typed source so the lint sees real types, while published consumers still resolve dist.
  • Build the workspace library before bun test, because bun test ignores customConditions. Why: the test runtime resolves the default export (dist) rather than the development source, so an unbuilt library fails the test run even though the typed-lint is green.

NEVER

  • Never add a meta-framework (Next, Remix, Astro) or a 2nd bundler (Vite, webpack). Cost: a client-only SPA uses none of a framework server's SSR or RSC, so it is unused weight and toolchain drift where Bun's own server and bundler already cover serve, build, hot reload, base-path and health.
  • Never use Bun JSX prop punning (<C {x} />). Cost: it is a Bun-transpiler-only extension, so tsc and oxlint reject it at parse time — breaking type-check and the lint gate and erasing types — for a few saved characters.

Pitfall

  • A bare bun build invocation does not read the [serve.static] plugin list, so a Tailwind check run that way reports raw CSS and an invalid at-rule warning while the served app is correct — verify Tailwind through the serve and bundler lane.
  • Setting customConditions: ["development"] changes how a THIRD-PARTY dep's main entry resolves too, so a published package with no development condition can resolve to an untyped entry and flag no-unsafe-call at its call site — import the symbol from the package's own typed subpath (the one whose types resolve) rather than its main, or add the symbol to a subpath export the package types.

On this page