10LOC
Bunintermediate

Bun.serve() with a routes object: a zero-dependency HTTP server

Published July 31, 2026

export const app = Bun.serve({
  routes: {
    "/api/health": new Response("ok"),
    "/api/users/:id": (req) => Response.json({ id: req.params.id }),
    "/api/users": {
      GET: () => Response.json({ users: ["ada", "grace"] }),
      POST: async (req) => Response.json({ created: true, ...(await req.json()) }, { status: 201 }),
    },
    "/api/*": Response.json({ error: "Not found" }, { status: 404 }),
  },
  fetch: () => new Response("Not found", { status: 404 }),
});

console.log(`Listening on ${app.url}`);

What

Bun.serve()'s routes option (Bun 1.2.3+) matches an incoming request against a map of paths — static, :param, per-HTTP-method, and wildcard — before your fetch handler ever runs, so a small REST API needs no router package.

Why it matters

Before routes existed, every Bun.serve() app hand-rolled its own routing inside a single fetch(req) function: parse the URL, switch on req.method, string-match or regex the pathname, pull params out manually. That's exactly the job express, hono, and similar packages exist to do — and it's also exactly what a route table expresses more directly. Bun now does that matching natively, in the same native code path that makes Bun.serve() fast, with no added dependency, install size, or startup cost.

How it works

  • "/api/health" maps a path directly to a Response value — Bun reuses the same Response object on every request, since it's immutable and this is a static route.
  • "/api/users/:id" is a dynamic route: the matched segment is available as req.params.id inside the handler, with no manual parsing.
  • "/api/users" maps to an object of HTTP methods instead of a single function, so GET and POST on the same path are two separate handlers rather than one if (req.method === ...).
  • "/api/*" is a wildcard fallback for anything under /api/ that didn't match a more specific entry above it — Bun prefers the most specific match, so this only catches the leftovers.
  • The top-level fetch handler is the fallback for everything routes doesn't match at all.

Gotchas

  • Route matching supports exact paths and :param segments, not arbitrary regex — a wildcard prefix like /api/* is the closest thing to a catch-all, not a full regex route.
  • routes requires Bun 1.2.3 or newer. On older versions it's silently ignored, so fetch needs to handle every path itself.
  • server.reload({ routes }) swaps the route table on a running server without downtime — useful for tests or hot-reloading route definitions without restarting the process.

Related

  • Serving a file lazily as a route value ("/favicon.ico": Bun.file("./favicon.ico")) — see the Bun.file streaming post for the general pattern.
  • WebSocket upgrades still go through fetch/server.upgrade(), not routes — routes are HTTP-only.