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 β†’ auto body parser β†’ 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, res.last, 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", async (req, res) => res.created(await req.parseBody()));
app.patch("/users/:id", async (req, res) => {
  const id = req.param("id");
  const updates = await req.parseBody();
  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

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"}

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.

Body parser defaults ​

Routers accept body parser defaults via constructor:

ts
const router = new Router({
  bodyParser: {
    json: { limit: 2 * 1024 * 1024 },
    text: { enabled: true },
  },
});

Handlers can override parsing dynamically with req.applyBodyParserOverrides().

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);

Per-request body parser override ​

ts
app.post("/webhook", async (req, res) => {
  req.applyBodyParserOverrides({ text: { enabled: true }, json: { enabled: false } });
  const payload = await req.parseBody();
  return res.ok({ 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.