Bun has a fast HTTP server built in. Bun.serve takes a routes object with path patterns, per-method handlers and static responses. You do not need express, fastify, koa or node:http, and you should not install them.
The shape
const server = Bun.serve({
port: Number(process.env.PORT ?? 3000),
routes: {
// Static response: built once, served with zero allocation per request.
"/health": new Response("ok"),
// One handler for every method.
"/api/version": () => Response.json({ version: "1.0.0" }),
// Per-method handlers. Params are typed from the path: req.params.id is a string.
"/api/todos/:id": {
GET: (req) => Response.json(getTodo(req.params.id)),
DELETE: (req) => {
deleteTodo(req.params.id);
return new Response(null, { status: 204 });
},
},
"/api/todos": {
GET: () => Response.json(listTodos()),
POST: async (req) => {
const body = await req.json();
return Response.json(createTodo(body), { status: 201 });
},
},
// Wildcards match the rest of the path.
"/api/*": Response.json({ error: "Not found" }, { status: 404 }),
},
// Runs when no route matches. Return a JSON 404 for APIs.
fetch() {
return Response.json({ error: "Not found" }, { status: 404 });
},
// Uncaught errors in handlers end up here.
error(err) {
console.error(err);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
},
});
console.log(`Listening on ${server.url}`);Rules
- Read the port from
process.env.PORTwith a default. Useport: 0in tests to get a free port, then readserver.portorserver.url. - Handlers return a
Response(or a promise of one). UseResponse.json(data, init)for JSON. Don't build JSON strings by hand. - Route precedence is by specificity, not order: exact paths beat
:params, which beat*wildcards. Unmatched methods on a method-object route fall through tofetch. - Validate the body yourself.
await req.json()throws on invalid JSON; catch it and return a 400. - Query strings:
new URL(req.url).searchParams. - Headers:
req.headers.get("authorization"). Set response headers throughnew Response(body, { headers }). - Cookies:
req.cookies.get("session")andreq.cookies.set(...), which appliesSet-Cookieautomatically. - Static files:
new Response(Bun.file("./public/logo.png"))streams the file with the rightContent-Type. Import an HTML file (import home from "./index.html") and pass it as a route value to get bundled frontend assets. - Static directories (Bun 1.4+):
"/static/*": { dir: "./public" }serves a folder with sendfile,ETag/304,Rangeand traversal protection. Don't hand-roll a/static/*handler with a..check. The route opens the directory whenBun.servestarts and keeps it open, so empty a build output folder between rebuilds instead of deleting and recreating it, or the route serves 404s from the deleted folder. - Compiled binaries: files embedded with
bun build --compile --asset <dir>live under/$bunfs/root/<dir name>/.{ dir }routes and bareBun.file()route values can't read/$bunfs/; serve each file asnew Response(Bun.file(path))(static) or from a handler. - Graceful shutdown:
await server.stop()lets in-flight requests finish. - Testing: start the server with
port: 0insidebun:test, call it withfetch(new URL("/path", server.url)), and stop it inafterAll.
Don't
- Don't install express, fastify, koa,
body-parser,cors, ornode-fetch. - Don't import from
node:httporhttp.Bun.serveis the server. - Don't call
app.listen()-style APIs.Bun.servestarts listening as soon as it is called. - Don't wrap static responses in a function; pass the
Responsedirectly so Bun can cache it.
CORS without a package
const CORS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "content-type" };
routes: {
"/api/items": {
OPTIONS: () => new Response(null, { status: 204, headers: CORS }),
GET: () => Response.json(items, { headers: CORS }),
},
}If the project needs middleware chains, validation helpers or a typed RPC client, use Hono on Bun (see the hono-on-bun skill), not express.