Compiling TanStack Start with Bun
A field report from cutting our Bun-served apps over to single-binary distribution, using bun build --compile to ship a 132MB SSR app instead of a 3GB container
TL;DR
If you're shipping a TanStack Start app to Kubernetes today, your runner image is probably 2-3 GB: Bun + node_modules + the workspace graph + the SSR bundle.
With bun build --compile you can collapse all of that into a single self-contained linux/x64 ELF and ship it on a vanilla alpine:3.19 base. Across seven apps in our monorepo the change went from this:
| App | Before | After | Δ |
|---|---|---|---|
| agentic | 2.8 GB | 355 MB | -87% |
| platform | 2-3 GB | 132 MB | -94% |
| cortex | 2-3 GB | 134 MB | -94% |
| analytics | 2-3 GB | 145 MB | -94% |
Same Docker image → AKS pipeline. No registry changes. The runner stage went from "install Bun + copy node_modules + the SSR bundle" to "COPY one binary".
What follows is the recipe — and, more importantly, the dead-ends. The marketing material for bun build --compile makes it look like a flag. But we learned that it is not just a flag! (as always). It is a pattern, and the pattern is mostly there to work around things Bun's bundler does to vite's ESM SSR output.
What bun build --compile actually does
The CLI flag is shorthand for Bun.build({ compile: { target, outfile } }). Bun takes your entrypoint, traces the dependency graph, bundles every reachable JS/TS module into one chunk, and prepends a Bun runtime so the output is a directly-executable ELF. At runtime, the bundled modules live inside a virtual /$bunfs/ filesystem rooted at /$bunfs/root/<binary>. Which is a good thing, but also an unexpected hassle if you don't account for it.
The marketing version is "compile your script into a binary". The honest version is "Bun bundles your code with its own JS bundler — not vite, not rollup, not tsup — and then concatenates that bundle to the runtime". Everything in this post is downstream of that one fact.
The shape of the solution
A working TanStack Start binary needs four pieces wired together:
vite buildproduces a server-side SSR bundle. We force it into one chunk so Bun never has to re-trace dynamic imports.- A codegen step walks the vite client output directory and writes an "asset manifest" — one
import x from "./assets/foo.js" with { type: "file" }per file, plus a route table mapping URL → embedded path. Bun.build({ compile: ... })consumes a thinserver.tsentrypoint that imports the asset manifest and the SSR bundle. Bun walks both, embeds the asset bytes via thewith { type: "file" }attribute, and bundles everything that's plain JS into the binary.- At boot,
server.tsreads the embedded SSR bundle bytes, writes them to a tmp file, andawait import(tmpPath)s them — yes, really, and yes, this is load-bearing — and startsBun.serve()with the asset manifest as the static-route table.
Here's the full orchestration as it lives in apps/platform/package.json:
{
"scripts": {
"build": "vite build && bun scripts/compile.ts",
"start": "./dist/platform"
}
}
That's the whole production interface. Two commands. The complexity hides in scripts/compile.ts and server.ts.
Step 1 — Make vite emit a single self-contained SSR chunk
The default vite SSR output is a directory of .js files with import { ... } from "./chunk-XYZ.js" between them, plus a list of "externals" you're expected to install at runtime via node_modules/. Both of those defaults fight --compile.
Here's the load-bearing part of apps/platform/vite.config.mts:
export default defineConfig({
// Collapse the SSR build to a single chunk so Bun's bundler sees one
// static import in server.ts and traces the whole graph.
build: {
rollupOptions: {
output: { inlineDynamicImports: true },
},
},
plugins: [
tanstackStart({ srcDirectory: "app" }),
// ...tailwind, dedupe, build-info plugins
],
ssr: {
// Inline EVERYTHING. The runner image ships ZERO node_modules.
noExternal: true,
external: [],
},
});
Two settings, both non-negotiable for --compile:
rollupOptions.output.inlineDynamicImports: true— vite normally splits dynamicimport()calls into separate chunks. Bun's bundler then sees these as runtime-loaded modules and can't trace through them at compile time. Inlining them collapses the entire SSR graph into one file that Bun can fully analyze.ssr.noExternal: truewith emptyexternal: []— by default vite marks anything innode_modulesas external, expecting it to berequire()'d at runtime. Inside a compiled binary, "runtime" means inside/$bunfs/root/...— there is nonode_modules/to fall through to. Every external becomes aCannot find moduleat boot. SettingnoExternal: trueforces vite to inline the whole graph:@scrydon/*workspace packages, LLM SDKs (@anthropic-ai/sdk,openai,@google/genai), database drivers (postgres),better-auth, all of it.
The resulting dist/server/server.js is a ~40 MB single-file ESM module with zero unresolved imports.
Step 2 — Codegen the asset manifest
Bun's --compile flag has two ways to embed non-JS files into the binary:
- The CLI's
--loader file:.svg --loader file:.pngflags. These don't apply to entrypoints — only to imports encountered while tracing. Pass an asset as an additional entrypoint and you hit the default text loader, which emits[name].js, collides on stem, and fails the build. - The
with { type: "file" }import attribute (the documented path), which embeds the bytes verbatim and exposes a string path thatBun.file(path)resolves against the virtual filesystem.
The second one works. So we generate one such import per file in the vite client output:
// apps/platform/scripts/compile.ts
import { Glob } from "bun";
const CLIENT_DIR = path.join(ROOT, "dist", "client");
const MANIFEST_PATH = path.join(ROOT, "dist", "asset-manifest.ts");
const glob = new Glob("**/*");
const relAssets: string[] = [];
for (const rel of glob.scanSync({ cwd: CLIENT_DIR, onlyFiles: true })) {
relAssets.push(rel.split(path.sep).join("/"));
}
const manifestSource = [
"// AUTOGENERATED — do not edit by hand.",
...relAssets.map(
(rel, idx) =>
`import _a${idx.toString(36)} from "./client/${rel}" with { type: "file" };`,
),
"",
"export const EMBEDDED_ASSETS: Record<string, string> = {",
...relAssets.map(
(rel, idx) => ` "/${rel}": _a${idx.toString(36)},`,
),
"};",
].join("\n");
Bun.write(MANIFEST_PATH, manifestSource);
A footgun in passing: Bun.Glob is also Bun-specific, but compile.ts runs at build time under a normal Bun process — not inside the compiled binary. Don't try to call new Bun.Glob(...).scanSync(...) from server.ts at runtime expecting it to enumerate /$bunfs/. It won't.
The codegen happens before Bun.build, so the manifest module exists on disk and import { EMBEDDED_ASSETS } from "./dist/asset-manifest.ts" inside server.ts resolves cleanly. TypeScript will complain about the missing .d.ts — annotate the import:
// @ts-expect-error: generated by scripts/compile.ts; no .d.ts in source tree
import { EMBEDDED_ASSETS } from "./dist/asset-manifest.ts";
Step 3 — Drive Bun.build from a script, not the CLI
The CLI form is fine for hello-world. For anything real you want the programmatic API. From apps/platform/scripts/compile.ts:
const result = await Bun.build({
entrypoints: [ENTRY_TS],
target: "bun",
minify: false, // vite already minified dist/server/server.js
sourcemap: "none", // --compile strips debug symbols anyway
plugins: [nativeStubsPlugin],
compile: {
target: "bun-linux-x64-musl",
outfile: OUTFILE,
},
});
if (!result.success) {
for (const log of result.logs) console.error(log);
process.exit(1);
}
A few things worth calling out:
target: "bun-linux-x64-musl"explicitly. The defaultbuntarget auto-detects from the builder, which is fine on an Alpine builder right now but breaks the moment someone moves the CI base to Debian. Be explicit about the libc.sourcemap: "none". Inline sourcemaps inflate the embedded payload by 30-40% with no debugger benefit —--compilestrips ELF debug symbols, and Bun's stack traces map back through the bundle index without an external map.minify: false. Vite already minifieddist/server/server.js. Running Bun's minifier on top breaks hoisting in some workspace outputs we ran into. Pick one minifier.- No
--bytecode. Bun 1.3.12's bytecode emission rejects top-levelawaitin tsup multi-chunk workspace outputs. If you don't have workspace deps that hit this, try it; we couldn't.
Step 4 — The server.ts dance
This is the part that took the longest to land. The naive form looks like:
// THIS DOES NOT WORK.
import handler from "./dist/server/server.js";
Bun.serve({
routes: {
"/*": (req) => handler.fetch(req),
},
});
Bun traces ./dist/server/server.js, bundles it into the binary, and at runtime you get:
ReferenceError: util2 is not defined
at v4/core/api.js:...
That's Bun 1.3.12's CJS-namespace-rename bug. When two modules in the same bundle both import * as util from "./util.js" and Bun renames one to util2 to disambiguate, the var util2 = ... binding never gets emitted. Zod 4.4.3's v4/core/api.js trips this every time. So does anything else that has parallel namespace imports of the same module under different paths.
The workaround is to never let Bun bundle the SSR chunk. Read it as embedded bytes and dynamic-import it at boot:
// @ts-expect-error: generated by `vite build`
import SSR_BUNDLE_PATH from "./dist/server/server.js" with { type: "file" };
async function loadFrameworkHandler(): Promise<FrameworkHandler> {
// Stream embedded blob → tmp file. `Bun.write(dest, Bun.file(src))`
// is the streaming form — at most one stream buffer in flight.
const tmpDir = await mkdtemp(path.join(tmpdir(), "platform-ssr-"));
const tmpPath = path.join(tmpDir, "server.mjs");
await Bun.write(tmpPath, Bun.file(SSR_BUNDLE_PATH));
// The dynamic import lives outside Bun's bundler's view — the
// ESM evaluates in its own context, with vite's init order intact.
const mod = (await import(tmpPath)) as { default: FrameworkHandler };
return mod.default;
}
Three things going on here:
with { type: "file" }instead of a normal static import. This tells Bun to embed the bytes verbatim and hand back a string path, instead of bundling the module's code into the binary's JS chunk. Bun never traces through it.- Streaming write via
Bun.write(dest, Bun.file(src))— notBun.file(src).bytes()+writeFile(). The 40-60 MB SSR bundle would otherwise materialise as aUint8Arrayin JS heap before being written back to disk. Our analytics pod tripped its 512Mi limit on cold start with the bytes-into-memory form. await import(tmpPath)instead ofawait import("data:text/javascript;base64,..."). We tried the data URL — Bun's resolver rejects SSR-sized blobs withNameTooLong while resolving package. Tmpfile-import works at any size.
If you're thinking "but my SSR chunk is small, can't I just import handler from ... and skip this dance" — try it. If it works, ship it. We have apps where it works (smaller SSR graphs, no zod). But if you're using TanStack Start with @tanstack/react-router, zod, better-auth, and any meaningful number of workspace packages, you'll hit the namespace-rename bug.
Step 5 — Bun.serve() with the asset routes
Once the handler is loaded, the rest is straightforward:
async function buildStaticRoutes() {
const routes: Record<string, (req: Request) => Response> = {};
for (const [route, assetPath] of Object.entries(EMBEDDED_ASSETS)) {
const file = Bun.file(assetPath);
if (!(await file.exists())) continue;
const raw = new Uint8Array(await file.arrayBuffer());
if (raw.byteLength === 0) continue;
routes[route] = preloadedHandler(route, {
raw,
gz: maybeCompress(raw, file.type),
etag: computeEtag(raw),
type: file.type || "application/octet-stream",
});
}
return routes;
}
async function start() {
const handler = await loadFrameworkHandler();
const staticRoutes = await buildStaticRoutes();
Bun.serve({
port: SERVER_PORT,
hostname: SERVER_HOSTNAME,
routes: {
...staticRoutes,
"/api/healthz": () =>
new Response("ok", { headers: { "Cache-Control": "no-store" } }),
"/*": (request) => handler.fetch(request),
},
});
}
Bun.serve's routes table is a v0.6+ feature that does exact-match dispatch before falling through to the wildcard SSR handler. Performance-wise it's measurably better than handling the static-file lookup inside the SSR handler — Bun matches routes natively without JS involvement.
Two things worth getting right at this layer:
Cache-Controlper route family. Vite emits fingerprinted output under/assets/— those getpublic, max-age=31536000, immutable. Files copied frompublic/(favicon, robots.txt) don't have content hashes and getpublic, max-age=300, must-revalidate. Pinning unhashed files asimmutablefor a year is how you ship "the favicon is wrong and we can't fix it".- Health-check route placement. If you also register a wildcard like
/api/marimo/*(we do, in our analytics app), exact-match/api/healthzsometimes gets shadowed by the wildcard depending on registration order. We moved healthz into a top-of-fetchshort-circuit in the analytics app for that reason. Test your readiness probe with the real route table, not a stripped-down one.
Comments ()