10LOC
Bunintermediate

A static file router that falls back to Bun.file

Published April 9, 2027

import { serve, file } from "bun";

const PUBLIC_DIR = `${import.meta.dir}/public`;

const server = serve({
  routes: {
    "/api/health": new Response("ok"),
  },
  async fetch(req) {
    const pathname = new URL(req.url).pathname.replace(/\.\./g, "");
    const asset = file(`${PUBLIC_DIR}${pathname === "/" ? "/index.html" : pathname}`);

    return (await asset.exists()) ? new Response(asset) : new Response("Not found", { status: 404 });
  },
});

console.log(`Serving ${PUBLIC_DIR} at ${server.url}`);

What

A Bun.serve() app can declare a handful of API routes and let everything unmatched fall through to a fetch handler that resolves the request path against a directory on disk with Bun.file(), serving static assets without a separate static-file-serving package.

Why it matters

Express-style apps typically reach for express.static (or an equivalent middleware) to serve a public directory, layered in front of or alongside API routes. With Bun.serve(), routes handles the known API paths, and the fetch fallback — which already has to exist to catch anything routes doesn't match — can just as easily resolve the leftover path against the filesystem instead of returning a bare 404. Bun.file() streams the result the same way it would from a dedicated static-file route, so there's no meaningful capability gap versus a purpose-built static middleware.

How it works

  • routes handles the API surface first; only requests that don't match anything there reach fetch at all.
  • safePath strips .. sequences from the requested path before it's used to build a disk path — a minimal guard against a request walking outside PUBLIC_DIR via path traversal.
  • A request for / is mapped to /index.html explicitly, since Bun.file("./public/") would point at a directory, not a file.
  • asset.exists() decides between the streamed file and a 404 — the same lazy-file pattern as serving a single static file, just resolved against a variable path instead of a fixed one.

Gotchas

  • The ..-stripping here is intentionally minimal for the example; a production router should resolve the final absolute path and verify it's still inside PUBLIC_DIR (e.g. via path.resolve + startsWith) rather than trusting a single regex replace to catch every traversal trick (encoded slashes, symlinks, etc.).
  • This fallback has no notion of directory listings or index-file resolution beyond the root path — a request for /docs/ won't automatically resolve to /docs/index.html unless that logic is added explicitly.
  • Every unmatched request touches the filesystem via .exists(), even ones that will never match a real file — fine for a small app, but worth caching or rate-limiting in front of if the server is public and 404s are cheap to spam.

Related

  • Passing Bun.file(path) directly as a routes value serves a single known file with no fetch fallback logic at all — reach for that when the set of static files is small and fixed.
  • The Bun.file streaming post covers the single-file version of this same lazy-serving pattern in more depth.