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.serveprocess 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.serveroute 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.servea BOOLEANdevelopment: falsein 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 throwsjsxDEV is not a functionand never mounts — an HTML 200 and a healthy health route hide it, because the crash is client-side after load. Gatedevelopmenton 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:
curlon 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-tailwindinbunfig.tomlunder[serve.static]plugins. Why: the serve and bundler lane reads that list where a barebun buildinvocation does not, so Tailwind at-rules pass through uncompiled unless the plugin is registered for the serve. - Declare Tailwind
@sourceglobs relative to the serve CWD — the app's ownsrcand eachnode_modules/<lib>/distwhose 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
publicPathequal to the base path, not from on-the-flyBun.serveHTML 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 toBun.buildexplicitly (the bare build ignoresbunfig [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
#:schemaonbunfig.tomlwhen the TOML linter's bundled bun schema rejects a valid bun-documented key such asserve.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
trustedDependenciesevery dep whose install links a binary through a postinstall —bun-plugin-tailwind(anddprint) pull thebunnpm package whose postinstall linksnode_modules/.bin/bun. Why: bun skips an untrusted dep's postinstall, so the.bin/bunshim is never linked and the next invocation diesENOEXEC— 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.workerSrcpluscMapUrl/standardFontDataUrl/wasmUrl) at the served paths. Why: each is referenced by a module-relativenew 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
developmentexport condition pointing at its typedsrc, and setcustomConditions: ["development"]in the consumertsconfig. Why: a gate that typed-lints without building the library'sdistresolves its import to an absent.d.tsand raises ano-unsafe-*wall; thedevelopmentcondition resolves to typed source so the lint sees real types, while published consumers still resolvedist. - Build the workspace library before
bun test, becausebun testignorescustomConditions. Why: the test runtime resolves the default export (dist) rather than thedevelopmentsource, 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 buildinvocation 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 nodevelopmentcondition can resolve to an untyped entry and flagno-unsafe-callat its call site — import the symbol from the package's own typed subpath (the one whosetypesresolve) rather than its main, or add the symbol to a subpath export the package types.