Frontend development with HTML imports

Run HTML, TypeScript, and CSS directly on Bun's fullstack dev server, and ship correct production builds

Last updated on

When to use it — and when not to

ScenarioRecommendation
New prototypes, internal tools, small SPAsUse Bun's HTML imports directly, zero config
API and frontend in one processMount .html in Bun.serve routes
Existing Vite/framework project relying on its plugin ecosystemStay on your toolchain — see Ecosystem decisions
SSR, streaming rendering, server componentsUse a framework (Next, TanStack Start, etc.), not this page's shape

Run HTML directly

bun ./index.html

Bun parses the HTML and bundles the referenced scripts, stylesheets, and assets automatically. TypeScript and JSX work by default, and tsconfig.json is respected.

  • SPA mode: a single .html becomes the fallback for all routes, so client-side routers handle paths like /users/123.
  • MPA mode: pass multiple files or a glob (./**/*.html) and Bun creates a route per file based on the common prefix.

Enable hot reload in development (Fast Refresh for React):

bun --hot ./index.html

--console forwards browser console output to the terminal, and Chrome DevTools' Automatic Workspace Folders lets you save edits from the browser.

Same process as the API

server.ts
import index from './index.html';

Bun.serve({
  routes: {
    '/': index,
    '/api/health': () => Response.json({ ok: true }),
  },
  development: true, // dev mode: HMR and richer errors
});

Plugins and environment variables

bun add -d bun-plugin-tailwind
bunfig.toml
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "PUBLIC_*"

env = "PUBLIC_*" inlines only matching-prefixed variables into the browser bundle — that's a boundary, not a formality: no variable without the PUBLIC_ prefix should appear in the frontend bundle, and renaming a server secret to gain the prefix is a leak, not a workaround.

Production builds

bun build ./index.html --minify --outdir=dist --env=PUBLIC_*

The [serve.static] env setting only applies to the dev server; production builds must pass --env=PUBLIC_* explicitly (or the Bun.build env option), otherwise process.env.PUBLIC_* is not replaced. Two details: replacement only matches literal process.env.FOO references, and unset variables survive as-is — the browser can then throw ReferenceError: process is not defined. Grep the output for process.env after building to confirm replacement is complete.

Note: plugins are only available via bunfig.toml (dev server) or the Bun.build API — the bun build CLI takes no plugin flag. The output is plain static files for any CDN or static host; --compile --target=browser can produce a single self-contained HTML file.

SPA fallback is dev-server behavior only

The all-routes fallback of bun ./index.html does not travel with the static output. Production hosting must rewrite unmatched paths to /index.html itself, or deep-link refreshes return 404. The syntax differs per CDN/static host — configure it per platform docs and verify with a deep link.

Boundaries

  • This capability replaces the "esbuild + static server + hand-rolled HMR" combination, not the Vite ecosystem; inventory the Vite plugins you actually use before migrating.
  • The dev server and production build share the bundler, but proxies, cache headers, and compression remain your deployment layer's job.
  • Browser bundles must never contain server secrets; enforce the PUBLIC_ prefix discipline plus code review.

Acceptance

  1. Editing a React component under --hot refreshes with state preserved;
  2. The bun build output works fully on a static server with no Bun present;
  3. Grepping the bundle finds no server variables outside the PUBLIC_ prefix and no leftover process.env;
  4. The hosting rewrite rule works: deep-link refreshes return index.html, not 404.

Official references: HTML imports and the dev server, Fullstack development, Bundler.