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
- Match – incoming requests match routes by HTTP method + path (supporting
:params). - Pipeline – global middleware → route-specific middleware/handlers.
- Execution – each handler receives
(req, res, next). Callnext()to continue the chain. - 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:
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:
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:
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:
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)
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)
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:
app
.route("/test")
.get(handler)
.post(handler)
.put(handler)
.patch(handler)
.delete(handler)
.options(handler)
.head(handler)
.all(handler); // Responds to any methodMiddleware in chains
Add middleware to individual methods:
app
.route("/admin/users")
.get(requireAuth, listUsers)
.post(requireAuth, requireAdmin, createUser);Works with routers
Chainable routes work on both app and Router instances:
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/postsRoute 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.
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/:idGroup middleware
Pass a { middleware } option object before the callback to apply middleware to every route in the group:
api.group("/admin", { middleware: [requireAuth, requireAdmin] }, (r) => {
r.get("/stats", getStats);
r.delete("/users/:id", deleteUser);
});
// requireAuth + requireAdmin run before all /admin/* handlersParameterized group prefix
Group prefixes support route parameters:
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:
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
Responseto short-circuit the pipeline (e.g., auth failures).
Sub-routers
Group related endpoints into sub-routers for better organization:
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:
const admin = new Router();
admin.use(requireAdmin);
admin.get("/stats", getStats);
api.use("/admin", admin);
// Full path: /api/admin/statsmergeParams
By default, child routers do not have access to route parameters defined in the parent. Pass mergeParams: true to inherit parent params:
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:
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
HttpErrorfor explicit status/body/headers. - Throw/return
Responsefor fully custom responses. - Use
errorHandler()middleware for logging/mapping. - Unhandled errors fall back to a safe 500 JSON payload.
import { HttpError } from "bunway";
app.get("/secret", () => {
throw new HttpError(403, "Forbidden");
});404 behaviour
Unmatched routes return:
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:
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:
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
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:
import { logger } from "bunway";
app.use(logger("dev"));Admin-only sub-router
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
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()orreq.original.bodyfor 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.