Skip to content

Router Deep Dive ​

bunway's router uses Express-style ergonomics while staying true to Bun's Fetch APIs. This page explains the lifecycle so you can compose middleware, sub-routers, and custom responses with confidence.

Coming from Express?

If you've used Express routing, you already know bunway routing. The patterns are identical.

Anatomy of a request ​

  1. Match – incoming requests match routes by HTTP method + path (supporting :params).
  2. Pipeline – global middleware → route-specific middleware/handlers.
  3. Execution – each handler receives (req, res, next). Call next() to continue the chain.
  4. Finalization – the router chooses the final Response (explicit return or default 200) and merges header bags (e.g., CORS) before returning.

Registering routes ​

Define routes using familiar HTTP verb helpers. Each handler receives the Express-compatible (req, res, next) signature:

ts
const app = bunway();

app.get("/health", (req, res) => res.text("OK"));
app.post("/users", (req, res) => res.created(req.body));
app.patch("/users/:id", async (req, res) => {
  const id = req.param("id");
  const updates = req.body;
  return res.json(await updateUser(id, updates));
});

Multiple handlers ​

Chain middleware the same way you would in Express:

ts
app.get("/users/:id", authMiddleware, loadUser, (req, res) => {
  res.json(req.locals.user);
});

Each handler can perform work, populate req.locals, and call next() to continue:

ts
const authMiddleware = (req, res, next) => {
  const token = req.get("Authorization");
  if (!token) return res.unauthorized();
  req.locals.user = verifyToken(token);
  next();
};

Chainable routes ​

Define multiple HTTP methods on the same path without repeating yourself. This is identical to Express's app.route() pattern:

ts
app
  .route("/users")
  .get((req, res) => res.json(allUsers))
  .post((req, res) => res.created(createUser(req.body)));

app
  .route("/users/:id")
  .get((req, res) => res.json(findUser(req.params.id)))
  .put((req, res) => res.json(updateUser(req.params.id, req.body)))
  .delete((req, res) => res.noContent());

Before (repetitive) ​

ts
app.get("/users", listUsers);
app.post("/users", createUser);
app.get("/users/:id", showUser);
app.put("/users/:id", updateUser);
app.patch("/users/:id", patchUser);
app.delete("/users/:id", deleteUser);

After (chainable) ​

ts
app
  .route("/users")
  .get(listUsers)
  .post(createUser);

app
  .route("/users/:id")
  .get(showUser)
  .put(updateUser)
  .patch(patchUser)
  .delete(deleteUser);

All HTTP methods ​

Chainable routes support all HTTP verbs:

ts
app
  .route("/test")
  .get(handler)
  .post(handler)
  .put(handler)
  .patch(handler)
  .delete(handler)
  .options(handler)
  .head(handler)
  .all(handler); // Responds to any method

Middleware in chains ​

Add middleware to individual methods:

ts
app
  .route("/admin/users")
  .get(requireAuth, listUsers)
  .post(requireAuth, requireAdmin, createUser);

Works with routers ​

Chainable routes work on both app and Router instances:

ts
import { Router } from "bunway";

const api = new Router();
api
  .route("/posts")
  .get(listPosts)
  .post(authMiddleware, createPost);

app.use("/api", api);
// Routes: GET /api/posts, POST /api/posts

Route groups ​

router.group() is a concise way to mount a set of routes under a shared prefix with optional per-group middleware. It creates an implicit sub-router, automatically enables mergeParams, and mounts it for you.

ts
import { Router } from "bunway";

const api = new Router();

api.group("/users", (r) => {
  r.get("/", listUsers);
  r.post("/", createUser);
  r.get("/:id", showUser);
});

app.use("/api", api);
// Routes: GET /api/users, POST /api/users, GET /api/users/:id

Group middleware ​

Pass a { middleware } option object before the callback to apply middleware to every route in the group:

ts
api.group("/admin", { middleware: [requireAuth, requireAdmin] }, (r) => {
  r.get("/stats", getStats);
  r.delete("/users/:id", deleteUser);
});
// requireAuth + requireAdmin run before all /admin/* handlers

Parameterized group prefix ​

Group prefixes support route parameters:

ts
api.group("/orgs/:orgId/teams/:teamId", (r) => {
  r.get("/members", (req, res) => {
    res.json({ orgId: req.params.orgId, teamId: req.params.teamId });
  });
});

Middleware ordering ​

Global middleware runs in the order registered, followed by route-specific middleware:

ts
app.use(cors()); // global
app.use(json()); // global
app.use(loggingMiddleware); // global

app.get("/secure", authMiddleware, (req, res) => {
  res.ok(req.locals.user);
});
  • Global middleware runs before route-specific middleware.
  • req.isBodyParsed() lets you skip redundant parsing.
  • Middleware can return Response to short-circuit the pipeline (e.g., auth failures).

Sub-routers ​

Group related endpoints into sub-routers for better organization:

ts
import { Router } from "bunway";

const api = new Router();
api.get("/users", listUsers);
api.get("/users/:id", showUser);

app.use("/api", api);

Sub-routers inherit parent middleware and can register their own router.use() handlers.

Sub-router inheritance

Middleware registered on the parent app runs before sub-router handlers. Add router-specific middleware inside the router for scoped behaviour.

Nested routers ​

Routers can be nested multiple levels deep:

ts
const admin = new Router();
admin.use(requireAdmin);
admin.get("/stats", getStats);

api.use("/admin", admin);
// Full path: /api/admin/stats

mergeParams ​

By default, child routers do not have access to route parameters defined in the parent. Pass mergeParams: true to inherit parent params:

ts
import { Router } from "bunway";

const userRouter = new Router({ mergeParams: true });

userRouter.get("/posts", (req, res) => {
  // req.params.userId is available from the parent mount
  res.json({ userId: req.params.userId });
});

app.use("/users/:userId", userRouter);
// GET /users/123/posts → { "userId": "123" }

Child params take precedence when the parent and child define the same parameter name.

Deep nesting works as expected — each router in the chain must have mergeParams: true to pass params through:

ts
const orgRouter = new Router({ mergeParams: true });
const teamRouter = new Router({ mergeParams: true });

teamRouter.get("/members", (req, res) => {
  res.json({ orgId: req.params.orgId, teamId: req.params.teamId });
});

orgRouter.use("/teams/:teamId", teamRouter);
app.use("/orgs/:orgId", orgRouter);
// GET /orgs/abc/teams/xyz/members → { "orgId": "abc", "teamId": "xyz" }

Returning native responses

Handlers can always return Response objects straight from Fetch APIs—bunWay will still merge any middleware headers during finalization.

Error handling ​

  • Throw HttpError for explicit status/body/headers.
  • Throw/return Response for fully custom responses.
  • Use errorHandler() middleware for logging/mapping.
  • Unhandled errors fall back to a safe 500 JSON payload.
ts
import { HttpError } from "bunway";

app.get("/secret", () => {
  throw new HttpError(403, "Forbidden");
});

404 behaviour ​

Unmatched routes return:

json
HTTP/1.1 404 Not Found
Content-Type: application/json
{"error":"Not Found","message":"Cannot GET /path"}

When a path exists but the method doesn't match, the router returns 405 Method Not Allowed with an Allow header listing the accepted methods:

json
HTTP/1.1 405 Method Not Allowed
Content-Type: application/json
Allow: GET, POST
{"error":"Method Not Allowed","message":"DELETE is not allowed for /path","allowedMethods":"GET, POST"}

Customize by adding a catch-all route at the end:

ts
app.use((req, res) => res.status(404).json({ error: "Route not found" }));

Catch-all

Be sure to register catch-all handlers last—bunway processes middleware in order, so earlier routes can short-circuit the response.

Recipes ​

Request logger ​

ts
app.use(async (req, res, next) => {
  const start = performance.now();
  await next();
  const ms = (performance.now() - start).toFixed(1);
  console.log(`${req.method} ${req.path} → ${res.statusCode} (${ms}ms)`);
});

Or use the built-in logger:

ts
import { logger } from "bunway";
app.use(logger("dev"));

Admin-only sub-router ​

ts
import { Router, HttpError } from "bunway";

const admin = new Router();

admin.use((req, res, next) => {
  if (req.get("Authorization") !== "super-secret") {
    throw new HttpError(401, "Admin authorization required");
  }
  next();
});

admin.get("/stats", (req, res) => {
  res.json({ uptime: process.uptime() });
});

app.use("/admin", admin);

Raw body access ​

ts
app.post("/webhook", async (req, res) => {
  const payload = await req.rawText();
  return res.status(200).json({ received: payload });
});

Advanced patterns ​

  • Streaming: work directly with await req.rawBody() or req.original.body for streams.
  • Locals: share data across middleware via req.locals (e.g., req.locals.user = user).
  • Async cleanup: run code after await next() to implement logging, timers, or metrics.

Continue to Middleware Overview or explore type-level details in the API Reference.