Bun or Vite
- Category: Engine
Point0 ships two bundler paths. By default it runs on pure Bun — no Vite
involved. Add a viteConfig to the engine and it switches to Vite for both
dev and build. Nothing else in your app changes: the same points, the same
index.server.ts and app.client.tsx, the same CLI.
The snippets below are from examples/basic and examples/vite, which use a
single client key and index.html. (The production boilerplate uses a
clients: [] array and index.client.html instead — same mechanics, different
key/filename names.)
// examples/basic — pure Bun: no viteConfig anywhere
export const engine = Engine.create({
file: import.meta.url,
ssr: true,
client: {
bunPlugins: ['bun-plugin-tailwind'], // bundler plugins are Bun plugins
// ...
},
})// examples/vite — same app, with Vite: one extra key flips the bundler
export const engine = Engine.create({
file: import.meta.url,
ssr: true,
viteConfig: ({ plugins, side }) => ({
plugins: [...plugins, react(/* ... */), tailwindcss()],
}),
client: {
// no bunPlugins here — tailwind moves into viteConfig.plugins
// ...
},
})The presence of viteConfig is the switch. There is no useVite or
mode: 'bun' | 'vite' flag — a viteConfig that resolves means Vite, its
absence means Bun. The rest of this page shows what each path gives you, how
they differ, and how to move between them.
Bun is the default
Both examples/basic and the production boilerplate ship pure Bun. Bun is the recommended path — nothing extra to install, dev and HMR work out of the box. Vite is an opt-in for projects that already lean on the Vite plugin ecosystem.
Vite is a devDependency of @point0/engine, not a production peer
dependency, and Point0 imports it lazily (await import('vite')) only on the
Vite path. A Bun-only app never loads Vite at all — the Bun server build even
force-externalizes vite and esbuild, so a pure-Bun app builds and runs with
neither installed.
Babel runs under Bun too
This is the headline reason a Bun-only Point0 app is not "just Bun.build": the
Point0 compiler runs Babel inside its own transform, the same way under both
bundlers. Bun's native bundler has no Babel stage on its own — Point0 supplies
one.
// works identically on Bun and on Vite — the compiler runs babel internally
export const engine = Engine.create({
client: {
compiler: { babel: ['babel-plugin-react-compiler'] },
// ...
},
})The compiler is offered in three plugin formats — a Bun plugin, a Vite plugin,
and a Babel plugin — over one shared codebase, so they behave the same. Your
compiler.babel plugins are threaded into that shared transform regardless of
the bundler. babel-plugin-react-compiler is the common case and both example
apps enable it. See compiler for the full transform and
point0 compile for inspecting the output.
What differs between the two paths
Most of the engine config is identical across the Bun and Vite examples. Only a handful of keys are bundler-specific.
| Concern | Bun path | Vite path |
|---|---|---|
| Switch key | (no viteConfig) | viteConfig |
| Bundler plugins | bunPlugins (Bun plugins) | viteConfig.plugins (Vite plugins) |
| Build tuning | bunBuildConfig | viteConfig (build.rolldownOptions, …) |
| Tailwind | bun-plugin-tailwind in bunPlugins | @tailwindcss/vite in viteConfig.plugins |
preload | preventLoadBunPlugins off | preventLoadBunPlugins: true |
| Server reload in dev | import-graph restart; --hot for point hot-swap | Vite HMR (re-runs the entry); --hot is a no-op |
bunBuildConfig has no effect under Vite, and viteConfig has no effect under
Bun — each path reads only its own knob.
bunPlugins ↔ viteConfig.plugins
Bundler plugins live in different places. Under Bun, they are Bun plugins
passed as string names (the native dev server resolves them by name in a
generated bunfig.toml):
client: {
bunPlugins: ['bun-plugin-tailwind'], // for the native dev server, only string entries work (object/function plugins are accepted by the type but error in dev — see Gotchas)
}Under Vite, they are Vite plugins inside the viteConfig callback. The
callback receives plugins — Point0's own compiler Vite plugin is already in
that array — and you spread it in wherever you want, then add your own:
viteConfig: ({ plugins, side }) => ({
resolve: { tsconfigPaths: true },
plugins: [
...plugins, // point0 compiler vite plugin is already here
react({ include: /\.(jsx|js|mdx|md|tsx|ts)$/ }),
tailwindcss(),
side === 'client'
? analyzer({ analyzerMode: 'static', openAnalyzer: false })
: null,
],
})Everything else Vite needs — root, define, build.rolldownOptions — is
injected automatically from the rest of your engine config; the callback only
adds what you want on top. The callback gets
{ command, side, mode, scope, plugins }, so you can branch per side (the
example above only runs the bundle analyzer on the client).
The dev and build architecture
The two paths run different machinery underneath, though the CLI is the same.
Client dev server. Bun spawns a child process that serves index.html as a
Bun HTMLBundle via Bun.serve. Vite runs a real Vite dev server wrapped by a
thin Bun.serve that calls transformIndexHtml and lets Vite own HMR.
Server in dev. On the Bun path the server runs as a plain bun run
subprocess that Point0's own file/import-graph watcher restarts on change
(SIGKILL + respawn) — it is not bun --watch. On the Vite path the server runs
in-process through Vite's SSR dev server, which re-runs the entry on change.
Build. Bun builds the client with Bun.build (target browser) and the
server with Bun.build (target bun). Vite builds both with vite build (SSR
mode for the server). Either way the output layout is the same — dist/client
plus dist/server — but the server entry filename differs: Bun emits
dist/server/index.server.js (named by source basename), while Vite emits
dist/server/main.js (rollup names by the entry key). Use each example's
start script rather than hardcoding one path: examples/basic runs
bun run ./dist/server/index.server.js, examples/vite runs
bun run ./dist/server/main.js. See build and deploy.
How to switch from Bun to Vite
Three files change. The production boilerplate keeps the Vite block commented out right next to the Bun config, with the spots to edit marked.
1. Add viteConfig to the engine and move bundler plugins into it. Drop
client.bunPlugins; add the Vite equivalents (@tailwindcss/vite for tailwind,
@vitejs/plugin-react for React Fast Refresh):
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
viteConfig: ({ plugins }) => {
// tailwind packages must be externalized under Vite
const external = ['bun', '@tailwindcss/vite', '@tailwindcss/oxide', '@tailwindcss/node', 'tailwindcss']
return {
plugins: [...plugins, react(/* ... */), tailwindcss()],
optimizeDeps: { exclude: external },
ssr: { external },
}
},2. Set preventLoadBunPlugins in preload. Under Vite the Bun plugins must
not load — Vite provides the transform through its own plugin:
// preload.ts
// Bun:
await engine.preload({ nodeEnvFallback: 'development' })
// Vite:
await engine.preload({
nodeEnvFallback: 'development',
preventLoadBunPlugins: true,
})
// Bundler-agnostic form (works either way):
await engine.preload({
nodeEnvFallback: 'development',
preventLoadBunPlugins: !!engine.server.viteConfig,
})3. Adjust index.html. Bun resolves a relative script path and picks up CSS
imported from JS. Vite wants an absolute script path and an explicit stylesheet
link:
<!-- Bun -->
<script type="module" src="./index.client.tsx"></script>
<!-- Vite: absolute path, and add the stylesheet link in <head> -->
<link rel="stylesheet" href="/styles/index.css" />
<script type="module" src="/index.client.tsx"></script>The runtime files — index.server.ts, index.client.tsx, app.client.tsx —
stay byte-for-byte identical. That is the portability promise: swap the bundler
by editing a couple of wiring files; the app's behavior does not change.
Gotchas
- Vite already hot-reloads the server;
--hotis a Bun-only extra. Under Vite the server runs in-process and Vite's HMR re-runs the entry on every save — server reload just works, with no flag. What--hotadds is a Bun-native point hot-swap (swap an edited point's module without restarting the process); that mechanism is Bun-only, so on the Vite path the flag is a no-op (Vite's own HMR is doing the reloading instead). Both examples shippoint0 dev --hot. See dev. - Bun dev plugins must be string entries. The native dev server resolves
them by name in a generated
bunfig.toml; function or object plugins error there. UseviteConfig.pluginsfor non-string plugins. - Tailwind may need externalizing under Vite. The boilerplate externalizes
the tailwind packages (
optimizeDeps.exclude+ssr.external, the snippet above); the minimalexamples/viteapp omits it. Add them if the tailwind packages fail to resolve or get pre-bundled under Vite. - Don't forget
index.htmlwhen switching. A wrong script path or a missing stylesheet link is the usual "blank page after switching" cause. vite.config.tsis not used by the engine. When present, it's a thin view for external tooling (vitest, IDE Vite integration) that callsengine.getViteConfig(env).engine.dev()/engine.build()readviteConfigfrom the engine directly and never load that file.devWatchGlobis almost never needed. In dev the server watcher already walks the entry's deep-import graph on its own and restarts on any change in it — you don't list your files.server.devWatchGlobonly adds extra globs on top of that auto-detected set (for files the import walk can't see), so most apps leave it unset. Theexamples/viteapp sets['**/*.{ts,tsx,mdx}', '!generated/point0/meta.ts']mainly to exclude a generated file from the watch and avoid a regen→restart loop; treat it as a niche tweak, not a switching step.- Dev server source maps are handled per path; both remap to your source.
Bun does not apply source maps to runtime stack traces itself, so on the Bun
path Point0 installs
source-map-supportin dev to remap error stacks back to the original.ts/.tsx. On the Vite path it doesn't — Vite remaps SSR stack traces with its ownssrFixStacktrace. The install is dev-only and a no-op once built.
Reference
The viteConfig option
viteConfig accepts three forms, on the general options and per side
(server.viteConfig / client.viteConfig, each falling back to the general
one):
| Form | Type | Use |
|---|---|---|
| Callback | (opts) => UserConfig | Promise<UserConfig> | the common case — receives plugins |
| Object | a vite UserConfig | a static config |
| String | a path to a vite.config.ts | point at an external config file |
The callback's argument carries
{ command: 'serve' \| 'build', side: 'client' \| 'server', mode, scope, plugins },
where plugins already includes Point0's compiler Vite plugin.
Bundler-specific config keys
| Key | Path | Lives on |
|---|---|---|
viteConfig | Vite | general, server, client |
bunPlugins | Bun | general, server, client |
bunBuildConfig | Bun | general, server, client |
devWatchGlob | both | server (extra dev-watch globs, on top of the auto-detected import graph) |
compiler.babel | both | client / server compiler |
Full engine option coverage is on engine-config; the dev and build internals are on dev and build; the compiler and its Babel stage on compiler.
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️