Routes live in routes/web.ts. The default export receives the Router and
registers routes on it. The HTTP kernel later compiles them onto Hono.
import type { Router } from "@shaferllc/keel/core";
import { json, text, param } from "@shaferllc/keel/core";
import { HomeController } from "../app/Controllers/HomeController.js";
export default function routes(router: Router): void {
router.get("/", [HomeController, "index"]); // controller
router.get("/health", json({ status: "ok" })); // static response
router.get("/hi/:name", () => text(`Hi ${param("name")}`)); // dynamic
}
HTTP verbs
router.get(path, handler);
router.post(path, handler);
router.put(path, handler);
router.patch(path, handler);
router.delete(path, handler);
Each returns the router, so calls chain.
Three kinds of handler
Controller actions — a [Controller, method] tuple, resolved from the
container with dependency injection:
router.get("/users/:id", [UserController, "show"]);
Static responses — pass a ready-made response directly, no closure:
router.get("/health", json({ status: "ok" }));
router.get("/robots.txt", text("User-agent: *\nAllow: /"));
Closures — a function that runs per request. Use this whenever the response depends on the request (route params, query, body), because those must be read at request time:
router.get("/users/:id", () => json({ id: param("id") }));
Rule of thumb: response is the same every time → pass it directly. Response depends on the request → wrap it in
() =>.
Reading the request
The request accessor (or the standalone shortcuts) read the current request —
no c needed:
request.param("id"); // route parameter
request.query("q"); // query string
request.header("authorization");
await request.json(); // parse a JSON body
// standalone equivalents
param("id"); query("q"); header("authorization"); await body();
request also exposes request.method, request.path, request.url,
request.status, and request.raw (the underlying web Request).
Writing the response
Build responses with the standalone helpers or the response accessor — they're
the same thing:
json({ ok: true }); // JSON response
text("hello"); // plain text
html("<h1>Hi</h1>"); // HTML
redirect("/login"); // redirect
response.json({ ok: true });
response.status(201).json(created); // set status, chainable
response.header("x-total", "42").json(rows);
Returning a string from a handler is shorthand — Keel wraps it as HTML.
The full helper set
Read (request.* or standalone) |
Write (response.* or standalone) |
|---|---|
param(name) · query(name) · header(name) |
json(data, status?) · text() · html() |
body<T>() (parse JSON body) |
redirect(location, status?) |
request.method · .path · .status · .raw |
response.status(code) · response.header(k, v) |
All of these are powered by async-context storage the HTTP kernel enables for
every request, so they only work inside a request. You can always still take c
explicitly — both styles work.
Named routes & URL generation
Name a route, then build its URL by name — no hardcoded paths:
router.get("/users/:id", [UserController, "show"]).name("users.show");
router.url("users.show", { id: 42 }); // "/users/42"
Building URLs from names — plain and tamper-proof signed URLs — is its own
topic. See the URL builder for router.url(),
router.signedUrl(), and router.hasValidSignature().
Route groups
Share a prefix, middleware, and/or name prefix across many routes:
router
.group(() => {
router.get("/status", json({ up: true })).name("status");
router.get("/me", [MeController, "show"]).name("me");
})
.prefix("/api") // -> /api/status, /api/me
.middleware([auth]) // runs before each route in the group
.as("api"); // -> names "api.status", "api.me"
Groups nest — inner prefixes and middleware compose with the outer group's.
Resource routes
Generate RESTful routes for a controller in one line:
router.resource("posts", PostController);
| Verb | Path | Action |
|---|---|---|
| GET | /posts |
index |
| GET | /posts/create |
create |
| POST | /posts |
store |
| GET | /posts/:id |
show |
| GET | /posts/:id/edit |
edit |
| PUT/PATCH | /posts/:id |
update |
| DELETE | /posts/:id |
destroy |
Trim the set with .only([...]), .except([...]), or .apiOnly() (drops the
HTML-form create/edit actions).
Param constraints
Constrain a parameter with a regex, a matcher, or a { match } object —
non-matching requests fall through to a 404:
router.get("/users/:id", [UserController, "show"]).where("id", /\d+/);
// built-in matchers
router.get("/u/:id", handler).where("id", router.matchers.number());
router.get("/a/:id", handler).where("id", router.matchers.uuid());
router.get("/s/:slug", handler).where("slug", router.matchers.slug());
// a global constraint applied to every matching :id
router.where("id", router.matchers.number());
Groups take constraints too: group(...).where("id", router.matchers.uuid()).
Per-route middleware
router.get("/dashboard", [DashboardController, "index"]).middleware([auth]);
Brisk routes: redirects, views & Inertia
on() is a shortcut for routes with no controller:
router.on("/old").redirect("/new"); // path/URL redirect
router.on("/ext").redirectToPath("https://x.com"); // alias of redirect
router.on("/posts").redirectToRoute("articles.index", {}, { qs: { page: 1 } });
router.on("/about").render(AboutPage, { title: "About" }); // render a view
router.on("/dashboard").renderInertia("Dashboard", { user }); // Inertia page
See Inertia for the full Inertia adapter.
Domain & subdomain routing
Bind routes (or a group) to a host pattern. :segments capture subdomain
params, readable with request.subdomain():
router
.group(() => {
router.get("/", () => json({ tenant: request.subdomain("tenant") }));
})
.domain(":tenant.example.com");
router.get("/", [BlogController, "index"]).domain("blog.example.com");
Requests are dispatched by their Host header; non-matching hosts fall through
to your default (undomained) routes.
Route config
Attach arbitrary metadata to a route (or a whole group) with .config(), then
read it in the handler or route middleware via request.route.config — for
per-route flags like an auth scope, a rate tier, or a layout choice:
router.get("/admin", [Admin, "index"]).config({ scope: "admin", rateTier: "high" });
router
.group(() => {
router.get("/billing", [Billing, "index"]); // inherits { area: "billing" }
router.get("/billing/export", [Billing, "export"]).config({ heavy: true });
})
.config({ area: "billing" }); // a route's own config wins on conflict
// in a guard middleware attached to the route/group:
if (request.route?.config.scope === "admin") await authorize("access-admin");
Group config is merged into every route in the group, with a route's own keys winning. Route config is available to route/group middleware and the handler (not global middleware, which runs before route matching).
The current route
request.route exposes the matched route, and request.routeIs() checks it:
request.route; // { name, pattern, methods, config }
request.routeIs("posts.show"); // boolean
More verbs
router.any("/webhook", [HookController, "handle"]); // every verb
router.route(["GET", "POST"], "/search", handler); // a specific set
Route model binding
A :post in the path can arrive as a Post, not a string:
import { bindModel, boundModel } from "@shaferllc/keel/core";
bindModel("post", Post); // once, in a provider
router.get("/posts/:post", (c) => {
const post = boundModel(Post); // already fetched. Not a string, not null.
return c.json(post);
});
The row is looked up before your handler runs, and a miss is a 404 there and
then. That's the whole value: the handler never sees a null, so it never has to
remember to check for one — "forgot the 404" stops being a bug you can write.
Compare what you'd otherwise type in every handler:
router.get("/posts/:id", async (c) => {
const post = await Post.find(c.req.param("id"));
if (!post) throw new NotFoundException(); // ...every time, forever
return c.json(post);
});
By another column
When the URL isn't the id:
bindModel("post", Post, { key: "slug" }); // /posts/hello-world
scope — this is security, not a filter
bindModel("post", Post, {
scope: (query, c) => query.where("authorId", currentUserId(c)),
});
A row outside the scope is a 404, not a 403 and not a filtered list — so it
cannot be reached by guessing its id. That's the difference between row-level
security and decoration. /posts/2 doesn't 403 (which would confirm the row
exists); it simply isn't there.
The scope gets the request, so it can depend on who's asking.
Middleware sees the model
Binding runs before route middleware, so a policy can read the model rather than re-fetching it:
const mustOwn: MiddlewareHandler = async (c, next) => {
if (boundModel(Post).authorId !== currentUserId(c)) throw new ForbiddenException();
await next();
};
router.get("/posts/:post/edit", edit).middleware(mustOwn);
Anything that isn't a model
bindRoute("tenant", (slug) => tenants.get(slug)); // undefined ⇒ 404
router.get("/t/:tenant", () => {
const tenant = boundValue<Tenant>("tenant");
});
Notes
- An unbound param is untouched — still just a string via
c.req.param(). - Two params bound to the same model? Say which:
boundModel(Post, "original"). Guessing would be worse than asking. missing()substitutes a value instead of 404ing, if you'd rather.- Only routes with parameters pay for any of this.
Inspecting routes
npm run keel routes
GET / HomeController@index
GET /health Static
GET /users/:id Closure
Adding more route files
bootstrap/app.ts loads routes/web.ts. To split routes (e.g. an api.ts),
import and call it there:
import registerWebRoutes from "../routes/web.js";
import registerApiRoutes from "../routes/api.js";
registerWebRoutes(app.make(Router));
registerApiRoutes(app.make(Router));
API reference
Registration methods live on Router. Each verb method hands back a Route you
chain to name, guard, or constrain it; group(), resource(), and on() hand
back RouteGroup, RouteResource, and a brisk-route matcher respectively. You
never construct these classes — the framework builds the Router and passes it
to your routes file, and the rest come back from its methods.
URL generation (url, signedUrl, hasValidSignature) also lives on Router
but is documented separately in the URL builder.
matchers
matchers: { number(): RegExp; uuid(): RegExp; slug(): RegExp; alpha(): RegExp }
The built-in parameter matchers, also reachable as router.matchers. Each
returns a fresh (un-anchored) RegExp to hand to .where().
import { matchers } from "@shaferllc/keel/core";
router.get("/u/:id", handler).where("id", matchers.number());
Notes: number → \d+, uuid → a canonical UUID, slug →
a-z0-9 words joined by -, alpha → letters only. They are plain regexes, so
you can also pass your own /.../ or a { match } object.
Router
The route registrar. Injected into your routes file; resolve it elsewhere with
app.make(Router).
get(path, handler) · post · put · patch · delete
get(path: string, handler: RouteHandler): Route
Registers a route for the one HTTP verb and returns the Route for chaining.
router.get("/users/:id", [UserController, "show"]);
router.post("/users", [UserController, "store"]);
router.delete("/users/:id", [UserController, "destroy"]);
Notes: post, put, patch, and delete share the identical signature.
handler is a closure, a [Controller, "method"] tuple, or a ready-made
Response — see RouteHandler. Paths are normalized (a
trailing slash is trimmed; "/" stays "/").
any(path, handler)
any(path: string, handler: RouteHandler): Route
Registers the route for every HTTP verb (GET POST PUT PATCH DELETE OPTIONS HEAD).
router.any("/webhook", [HookController, "handle"]);
route(methods, path, handler)
route(methods: Method[], path: string, handler: RouteHandler): Route
Registers the route for a specific set of verbs.
router.route(["GET", "POST"], "/search", handler);
Notes: Method is the uppercase verb union — pass them exactly ("GET", not
"get").
on(path)
on(path: string): RouteMatcher
Opens a brisk-route matcher for controller-less routes (redirects, views,
Inertia pages). See RouteMatcher.
router.on("/old").redirect("/new");
router.on("/about").render(AboutPage, { title: "About" });
group(callback)
group(callback: () => void): RouteGroup
Runs callback (which registers routes on the router) and returns a
RouteGroup wrapping exactly the routes it added, so you can
apply a shared prefix / middleware / name prefix to them.
router
.group(() => {
router.get("/status", json({ up: true })).name("status");
router.get("/me", [MeController, "show"]).name("me");
})
.prefix("/api")
.middleware([auth])
.as("api");
Notes: the grouping (prefix, middleware, name prefix) is applied after
registration by the returned RouteGroup — the callback itself sees no prefix.
Nest by calling .prefix() on the inner group before the outer group's; the
outer prefix is prepended, so /api + /v1/... composes correctly.
resource(name, controller)
resource(name: string, controller: ControllerRef): RouteResource
Registers the seven RESTful routes (index create store show edit update destroy) for controller and returns a RouteResource to
trim or rename them.
router.resource("posts", PostController);
router.resource("posts.comments", CommentController); // nested
Notes: each route is auto-named ${name}.${action}. A dotted name nests
resources — "posts.comments" yields /posts/:post_id/comments/:id. The
controller may be a class or a lazy () => import(...) loader.
where(param, matcher)
where(param: string, matcher: Matcher): this
Registers a global parameter constraint, applied at all() time to every
route whose path contains :param and that doesn't already constrain it.
router.where("id", matchers.number());
Notes: per-route and group .where() win over a global one. Returns the
router for chaining.
named(map)
named(map: Record<string, MiddlewareHandler>): this
Registers named middleware you can later reference by string in .middleware()
/ .use().
router.named({ auth, admin });
router.get("/panel", handler).use("auth");
Notes: merges into any previously named middleware. Referencing an
unregistered name throws at resolve time (see resolveMiddleware).
resolveMiddleware(ref)
resolveMiddleware(ref: MiddlewareRef): MiddlewareHandler
Resolves a middleware reference — a handler passes through; a string is looked up
in the named() registry.
const mw = router.resolveMiddleware("auth");
Notes: throws No named middleware [name]… if a string isn't registered.
Mostly used by the HTTP kernel; handy in tests.
all()
all(): RouteDefinition[]
Returns every live route definition, after folding in global where()
constraints and dropping routes trimmed to zero methods (by only/except).
for (const r of router.all()) console.log(r.methods, r.path, r.name);
Notes: this is the list the HTTP kernel compiles onto Hono. Trimmed resource
actions are excluded here, but url() can still find them by name.
resolve(handler)
resolve(handler: RouteHandler): HandlerFn
Turns a RouteHandler into a callable (c: Ctx) => …, resolving controller
tuples through the container and lazy loaders.
const fn = router.resolve([UserController, "show"]);
Notes: a bare [Controller] tuple calls the controller's handle method. A
Response handler is cloned per call. Throws if the named controller method
doesn't exist. Called by the kernel; you rarely call it directly.
Route
Returned by every verb method (get/post/…). Chain to name, guard, or
constrain a single route. Exposes a readonly def: RouteDefinition.
name(name) · as(name)
name(name: string): this
Names the route for URL generation. as() is an alias.
router.get("/users/:id", handler).name("users.show");
router.get("/users/:id", handler).as("users.show");
middleware(mw) · use(mw)
middleware(mw: MiddlewareRef | MiddlewareRef[]): this
Attaches middleware that runs only for this route, after any group middleware.
use() is an alias.
router.get("/dashboard", handler).middleware([auth]);
router.get("/admin", handler).use(["auth", "admin"]);
Notes: accepts a single ref or an array; appends (order preserved). A string
ref is resolved against named().
where(param, matcher)
where(param: string, matcher: Matcher): this
Constrains a route parameter; non-matching requests fall through to a 404.
router.get("/users/:id", handler).where("id", /\d+/);
domain(pattern)
domain(pattern: string): this
Binds the route to a host pattern; :segments capture subdomain params.
router.get("/", [BlogController, "index"]).domain("blog.example.com");
RouteGroup
Returned by group(). Its fluent methods apply across every route the group
callback registered. All return this.
prefix(prefix)
prefix(prefix: string): this
Prepends a path prefix to every route in the group.
router.group(() => { /* … */ }).prefix("/api");
Notes: leading/trailing slashes are normalized. Applying to the group's root
route ("/") yields just the prefix.
middleware(mw) · use(mw)
middleware(mw: MiddlewareRef | MiddlewareRef[]): this
Prepends middleware to every route in the group, so group middleware runs before
each route's own. use() is an alias.
router.group(() => { /* … */ }).middleware([auth]);
where(param, matcher)
where(param: string, matcher: Matcher): this
Constrains a parameter across the group, skipping routes that already constrain it themselves.
router.group(() => { /* … */ }).where("id", matchers.uuid());
as(namePrefix)
as(namePrefix: string): this
Prefixes the name of every already-named route in the group.
router.group(() => { /* named routes */ }).as("api"); // status -> api.status
Notes: routes without a name() are left untouched — name them inside the
callback for as() to reach them.
domain(pattern)
domain(pattern: string): this
Binds every route in the group to a host pattern.
router.group(() => { /* … */ }).domain(":tenant.example.com");
RouteResource
Returned by resource(). Chain to trim, rename, or guard the generated actions.
All return this.
only(actions) · except(actions)
only(actions: string[]): this
except(actions: string[]): this
Keep only the listed actions, or drop the listed actions.
router.resource("posts", PostController).only(["index", "show"]);
router.resource("posts", PostController).except(["destroy"]);
Notes: trimming empties a route's methods; all() then filters it out. The
route name still exists, so url() can resolve it even when it won't be served.
apiOnly()
apiOnly(): this
Drops the HTML-form actions (create, edit) — the shorthand for
.except(["create", "edit"]).
router.resource("posts", PostController).apiOnly();
as(name)
as(name: string): this
Renames the route-name prefix for every action.
router.resource("posts", PostController).as("articles"); // articles.index, …
params(map)
params(map: Record<string, string>): this
Renames route parameters. Maps a resource segment to a new param name.
router.resource("posts", PostController).params({ posts: "post" }); // :id -> :post
Notes: for the resource's own segment the underlying param is :id; for a
parent segment in a nested resource it's :{singular}_id. Only the first
matching occurrence in each path is renamed.
use(actions, mw)
use(actions: string[] | "*", mw: MiddlewareRef | MiddlewareRef[]): this
Attaches middleware to specific actions, or to all with "*".
router.resource("posts", PostController)
.use(["store", "update", "destroy"], "auth")
.use("*", logRequests);
RouteMatcher
Returned by router.on(path) — a builder for controller-less GET routes. Each
method registers the route and returns the underlying Route.
redirect(to, status?) · redirectToPath(to, status?)
redirect(to: string, status?: number): Route
Registers a route that redirects to a path or URL (default status 302).
redirectToPath is an alias.
router.on("/old").redirect("/new");
router.on("/ext").redirectToPath("https://example.com", 301);
redirectToRoute(name, params?, options?)
redirectToRoute(name: string, params?: Record<string, string | number>, options?: { qs?: Record<string, string | number>; status?: number }): Route
Registers a route that redirects to a named route, resolving its URL (and optional query string).
router.on("/posts").redirectToRoute("articles.index", {}, { qs: { page: 1 } });
render(component, props?)
render(component: (props?: any) => unknown, props?: any): Route
Registers a route that renders a view component directly.
router.on("/about").render(AboutPage, { title: "About" });
renderInertia(component, props?)
renderInertia(component: string, props?: Record<string, unknown>): Route
Registers a route that renders an Inertia page component by name. See Inertia.
router.on("/dashboard").renderInertia("Dashboard", { user });
Interfaces & types
Ctx
type Ctx = Context (Hono's request context)
The context handed to every handler and middleware. Every closure handler
receives it, though the request/response accessors mean you rarely read from
it directly.
router.get("/", (c: Ctx) => c.text("hi"));
RouteHandler
type RouteHandler = HandlerFn | ControllerAction | Response
What you pass as the second argument to a verb method. One of three shapes:
HandlerFn—(c: Ctx) => Response | string | Promise<Response | string>; returning a bare string is wrapped as HTML.ControllerAction—[Controller](callshandle) or[Controller, "method"]; the controller may be a lazy() => import(...).Response— a ready-made response, cloned per request.
router.get("/a", (c) => c.json({ ok: true })); // HandlerFn
router.get("/b", [UserController, "show"]); // ControllerAction
router.get("/c", json({ up: true })); // Response
Matcher
type Matcher = RegExp | string | { match: RegExp }
A route-parameter constraint accepted by .where(). A regex, a regex-source
string, or a { match } wrapper (the shape the built-in matchers conform to).
const a: Matcher = /\d+/;
const b: Matcher = "\\d+";
const c: Matcher = { match: /[a-z]+/ };
Method
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD"
The HTTP verbs. Passed to route(); also the type of RouteDefinition.methods.
router.route(["GET", "POST"] as Method[], "/search", handler);
MiddlewareRef
type MiddlewareRef = MiddlewareHandler | string
A middleware handler, or the name of one registered with router.named().
Accepted by every .middleware() / .use().
router.get("/a", handler).use("auth"); // named
router.get("/b", handler).use(rateLimiterMw); // handler
RouteDefinition
interface RouteDefinition {
methods: Method[];
path: string;
handler: RouteHandler;
name?: string;
middleware: MiddlewareRef[];
wheres: Record<string, string>;
domain?: string;
}
The compiled record for one route — what all() returns and the kernel reads.
You inspect these (e.g. to print a route table); you don't build them by hand.
wheres holds each param's regex source string, keyed by param name.
for (const def of router.all()) {
console.log(def.methods.join("|"), def.path, def.name ?? "");
}
UrlOptionsandSignedUrlOptionsare exported from this module too, but they belong to URL generation — see the URL builder.