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
| Scenario | Recommendation |
|---|---|
| New prototypes, internal tools, small SPAs | Use Bun's HTML imports directly, zero config |
| API and frontend in one process | Mount .html in Bun.serve routes |
| Existing Vite/framework project relying on its plugin ecosystem | Stay on your toolchain — see Ecosystem decisions |
| SSR, streaming rendering, server components | Use a framework (Next, TanStack Start, etc.), not this page's shape |
Run HTML directly
bun ./index.htmlBun 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
.htmlbecomes 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
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[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
- Editing a React component under
--hotrefreshes with state preserved; - The
bun buildoutput works fully on a static server with no Bun present; - Grepping the bundle finds no server variables outside the
PUBLIC_prefix and no leftoverprocess.env; - The hosting rewrite rule works: deep-link refreshes return
index.html, not 404.
Official references: HTML imports and the dev server, Fullstack development, Bundler.