When starting a new project, choosing the right runtime can make a big difference in both development speed and production performance. Bun is a modern JavaScript runtime built for performance and simplicity. It’s designed to be fast, batteries-included, and developer-friendly — perfect for quickly spinning up minimal applications without the overhead of traditional Node.js setups.
To get started, we’ll begin with a minimal app. The goal is to get something running quickly — an MVP that we can iterate on later.
Using Bun is straightforward. You can start the development server directly with the command bun --bun dev. This launches your app in the Bun runtime, allowing you to take advantage of its built-in speed and modern tooling.
For the server, TanStack provides an example, production-ready entry point here which we recommend using. However, for the purposes of this blog post we’ll start with a minimal example and add the necessary optimisations as we go.
// server.ts
import { Glob } from "bun";
const CONSTANTS = {
SERVER_PATH: "./dist/server/server.js",
CLIENT_PATH: "./dist/client",
};
type ServerModule = {
default: {
fetch: (req: Request) => Promise<Response>;
};
};
const serverModule = (await import(CONSTANTS.SERVER_PATH)) as ServerModule;
async function getStaticRoutes(): Promise<Record<string, () => Response>> {
const paths = await Array.fromAsync(
new Glob(`${CONSTANTS.CLIENT_PATH}/**/*`).scan("."),
);
return Object.fromEntries(
paths.map((path) => {
const file = Bun.file(path);
return [
path.replace(CONSTANTS.CLIENT_PATH, ""),
() => new Response(file, { headers: { "Content-Type": file.type } }),
];
}),
) as Record<string, () => Response>;
}
Bun.serve({
port: process.env.PORT ?? 3000,
routes: {
...(await getStaticRoutes()),
"/*": async (req) => {
return serverModule.default.fetch(req);
},
},
});
Let’s dive into what this code does. We’re starting a Bun server to handle the requests which are either static assets or TanStack Start server routes. To handle static assets we lookup each file from out client directory and generate a handler for each, returning the file content as a response. For server routes we import the server module from the dist/server/server.js file and call the fetch method with the request.
Modify your package.json file to include the following:
{
"scripts": {
"start": "bun server.ts"
}
}
Then you can run your production server with bun run build && bun start.
We can optimise the delivery of static assets by adding a cache-control header to the response.
async function getStaticRoutes(): Promise<Record<string, () => Response>> {
const paths = await Array.fromAsync(
new Glob(`${CONSTANTS.CLIENT_PATH}/**/*`).scan("."),
);
return Object.fromEntries(
paths.map((path) => {
const file = Bun.file(path);
const isBundledAsset = path.includes("assets");
// Cache bundled assets for a year, cache other assets for an hour
const cacheControl = isBundledAsset ? "max-age=31536000" : "max-age=3600";
return [
path.replace(CONSTANTS.CLIENT_PATH, ""),
() => new Response(file, { headers: { "Content-Type": file.type, "Cache-Control": cacheControl } }),
];
}),
) as Record<string, () => Response>;
For bundled assets, those created by the Vite build process, we’ll safely cache them for a year. Any changes to the source code will result in new asset bundles being generated and therefore fresh versions will be served to the client.
For other assets, those from the public directory, we’ll cache them for an hour. This is a safe bet for most assets as they are unlikely to change frequently but still allows for some flexibility in case of changes.
We can further improve performance by enabling Brotli compression to shrink the size of the response body.
import { brotliCompressSync } from "node:zlib";
async (request) => {
const acceptEncoding = request.headers.get("Accept-Encoding") || "";
const acceptsBrotli = acceptEncoding.includes("br");
if (file.size > 5120 && acceptsBrotli) { // 5kb = 5120 bytes
const buffer = await file.arrayBuffer();
const encoded = brotliCompressSync(buffer);
return new Response(encoded, { headers: {
"Content-Type": file.type,
"Cache-Control": cacheControl,
"Content-Encoding": "br",
"Content-Length": buffer.byteLength.toString()
} });
}
return new Response(file, { headers: { "Content-Type": file.type, "Cache-Control": cacheControl } });
}, Vox Open Source
Simple, lightweight customer feedback tool.Hype Open Source
Open-source toolkit for building waitlists.Starter Open Source
TanStack-based starter kit for building web apps at speed.Quadratic Open Source
Feedback widget built for Linear.