Routing
Routes map a method and a URL to the code that answers it: a controller action or an inline handler. Each application owns one Router. A route file exports a registrar, a function that receives that router and registers routes on it, and src/app.ts passes the registrar to createApp().
Routing
Routes map a method and a URL to the code that answers it: a controller action or an inline handler. Each application owns one Router. A route file exports a registrar, a function that receives that router and registers routes on it, and src/app.ts passes the registrar to createApp().
The second half of the page covers route contracts: the schemas and metadata a route carries, which drive validation, model binding, codegen, OpenAPI and agent tools.
Defining Routes
A scaffolded app's routes/web.ts already exports a registrar, registerWebRoutes. Add routes inside it:
// routes/web.ts
import { Router } from '@guren/core'
import PostsController from '@/app/Http/Controllers/PostsController'
export function registerWebRoutes(router: Router): void {
// Controller tuple — the most common pattern
router.get('/posts', [PostsController, 'index'])
router.post('/posts', [PostsController, 'store'])
router.put('/posts/:id', [PostsController, 'update'])
router.delete('/posts/:id', [PostsController, 'destroy'])
// Inline handler — great for lightweight endpoints
router.get('/health', (ctx) => ctx.json({ status: 'ok' }))
}
Available methods: router.get, router.post, router.put, router.patch, router.delete, router.query (see The QUERY Method), and the generic router.on(method, path, handler).
src/app.ts passes the registrar to createApp(). The scaffold's file passes more options than this; only routes matters here:
// src/app.ts
import { createApp } from '@guren/core'
import { registerWebRoutes } from '@/routes/web'
const app = createApp({
routes: registerWebRoutes,
})
export default app
Route Parameters
Dynamic segments use :param syntax:
router.get('/posts/:id', [PostsController, 'show'])
router.get('/users/:userId/posts/:postId', [PostsController, 'showForUser'])
Read them in the controller:
const id = this.ctx.req.param('id')
const userId = this.ctx.req.param('userId')
Hono's pattern syntax applies:
| Pattern | Matches |
|---|---|
/posts/:id? |
/posts and /posts/42 (optional segment) |
/items/:id{[0-9]+} |
/items/42, not /items/abc (regex constraint) |
/files/:path{.+} |
/files/a/b/c (several segments) |
Avoid /:slug*. It is not a Hono wildcard: it registers a one-segment parameter literally named slug*, so /files/x/y returns 404.
note
For large apps, split routes into multiple registrars (routes/api.ts, routes/admin.ts) and compose them from src/app.ts.
Route Groups
Group routes under a shared prefix to avoid repetition:
export function registerWebRoutes(router: Router): void {
router.group('/posts', (posts) => {
posts.get('/', [PostsController, 'index']) // GET /posts
posts.get('/:id', [PostsController, 'show']) // GET /posts/:id
posts.post('/', [PostsController, 'store']) // POST /posts
})
}
Groups nest naturally. Prefixes combine automatically:
router.group('/admin', (admin) => {
admin.group('/users', (users) => {
users.get('/', [AdminUsersController, 'index']) // GET /admin/users
})
})
Named Routes
Give routes a name, then generate URLs by name instead of hardcoding paths:
router.get('/posts/:id', [PostsController, 'show']).name('posts.show')
// Later, generate the URL
const url = router.route('posts.show', { id: 42 })
// => '/posts/42'
This keeps your code resilient to path changes. If you rename /posts to /articles, only the route definition changes.
Middleware
Attach middleware to a route with .middleware(), or to a set of routes with router.middleware().group(). Names you register with aliasMiddleware() can be used in place of functions. The Middleware guide covers groups, execution order and the built-in middleware.
Registering Aliases
Give middleware functions short names so you can reference them as strings:
import { Router, requireAuthenticated } from '@guren/core'
import { requireAdmin } from '@/app/Http/middleware/admin'
export function registerWebRoutes(baseRouter: Router): void {
const router = baseRouter
.aliasMiddleware('auth', requireAuthenticated())
.aliasMiddleware('admin', requireAdmin())
router.get('/admin', [AdminController, 'index']).middleware('auth', 'admin')
router.middleware('auth').group((auth) => {
auth.get('/dashboard', [DashboardController, 'index'])
auth.get('/settings', [SettingsController, 'index'])
})
}
rule
aliasMiddleware() returns a new Router type carrying the alias name it just registered. Call it without capturing the result and the name never reaches the type, so a later .middleware('auth') fails to compile. Always chain and assign, as above.
The type travels across functions too. A registrar typed Router<'auth' | 'guest'> only accepts a router on which both names were aliased and the result captured, so hand it the captured router rather than the baseRouter it came from. The entry registrar itself takes plain Router: it is the one that registers the aliases, and createApp({ routes }) hands it a router carrying none.
Route Contracts
Pass an options object as the second argument to attach Zod schemas and metadata to a route. The framework uses these schemas for request validation, codegen, and OpenAPI document generation. Write them with the zod 4 API (import { z } from 'zod'). Schemas authored with the zod v3 API are refused by the structural tools with a warning (see Validation).
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(1),
body: z.string().min(1),
})
const PostIdParams = z.object({
id: z.coerce.number().int().positive(),
})
router.post('/posts', {
body: CreatePostSchema,
name: 'posts.store',
}, [PostsController, 'store'])
router.get('/posts/:id', {
params: PostIdParams,
name: 'posts.show',
}, [PostsController, 'show'])
Available contract fields:
| Field | Purpose |
|---|---|
name |
Route name for URL generation and codegen |
params |
Zod schema for path parameters |
query |
Zod schema for query string parameters |
body |
Zod schema for the request body |
output |
Zod schema for the response body — validated on 2xx answers only, so error responses and redirects pass through as the application wrote them |
resource |
Resource class response hint — types the API client without a schema |
bind |
Route model binding map — { id: Post } (primary key) or { slug: [Post, 'slug'] } (another column) |
middlewares |
Array of middleware handlers |
note
Repeated query keys reach the query schema as arrays (?tag=a&tag=b → { tag: ['a', 'b'] }), while a key that appears once stays a string. See Array-Style Query Parameters.
Reading Validated Input
The params, query and body schemas are checked before the handler runs, for a controller action as much as for an inline handler. A request that fails one gets a 422 and the action never runs; on an Inertia request the errors are flashed back to the form, keyed by field path as validateBody() keys them.
The action reads what the schemas parsed with this.validated(), passing its own route name:
import type { UserRecord } from '@/app/Models/User'
export default class PostsController extends Controller {
async store() {
const { body } = this.validated('posts.store')
const user = await this.auth.userOrFail<UserRecord>()
const post = await Post.create(body, { set: { authorId: user.id } })
return this.redirect(`/posts/${post.id}`)
}
}
- Values arrive after coercion, defaults and transforms, so a
z.coerce.number()param is anumber. A segment the route declares no schema for isundefined. - Once
guren codegenhas generated the route types, the route name is checked at compile time and the result is typed from the contract. A name other than the route being served throws at runtime. - An action mounted on several routes, such as a PUT and a PATCH, passes every name:
this.validated(['posts.update', 'posts.patch']). - The contract runs before the action, so an invalid body gets its 422 before a check inside the action (such as
this.auth.userOrFail()) runs. When an unauthenticated request must get a 401 first, put the check in route middleware.
A route that declares no contract still validates with validateBody(), validateQuery() and validateParams() (see Validation).
Resource Response Hints
Routes that answer with API Resources already have a response type: the one codegen extracts from the Resource class into .guren/data.gen.ts. Writing an output schema for such a route would restate that shape in Zod and leave two copies to drift. Declare the Resource itself instead:
import { PostResource } from '@/app/Http/Resources/PostResource'
router.get('/api/posts', {
name: 'posts.index',
resource: { data: [PostResource] },
}, [PostsController, 'index'])
The hint mirrors the JSON the controller builds: a bare class (resource: PostResource) for a single resource, a one-element array (resource: [PostResource]) for a collection, and a plain object for an envelope, where { data: [PostResource] } matches this.json({ data: PostResource.collection(posts) }). Nesting works to any depth.
guren codegen resolves each class against app/Http/Resources (at the project root and inside every modules/<name>/) and types the generated API client's json() with the assembled shape ({ data: Data.Post[] } here).
output schema |
resource hint |
|
|---|---|---|
| Checked at request time | Yes, on 2xx responses | No |
| Types the API client | Yes | Yes |
| Appears in the OpenAPI document | Yes | No (openapi:generate warns) |
| When both are set | Wins | Ignored |
If the hint names a Resource class codegen cannot find, codegen warns and leaves the response untyped.
note
Every leaf of the hint must be a Resource class. An envelope that mixes Resources with plain typed objects (a paginated response's meta and links, for example) cannot be expressed yet; bind an output schema for those routes instead.
Agent Tools
A named route can be exposed to AI agents as an MCP tool by declaring agent metadata on it. Everything about the tool (its input schema, its output schema, its authorization) is derived from the contracts above; nothing is restated.
// Fluent
router
.post('/posts', { body: CreatePostSchema, output: PostResponseSchema }, [PostsController, 'store'])
.name('posts.store')
.agent({ description: 'Create a blog post as the authenticated user.' })
// Or as a contract key
router.post('/posts', {
name: 'posts.store',
body: CreatePostSchema,
agent: { description: 'Create a blog post as the authenticated user.' },
}, [PostsController, 'store'])
resource() takes the same metadata per action, and an action not listed is not exposed:
router.resource('/posts', PostsController, {
agent: {
index: { description: 'List posts.' },
show: { description: 'Fetch one post by id.' },
},
})
Exposure is opt-in per route, the tool name is the route name used verbatim, and a route with no .name() cannot become a tool. Declaring agent in the route options and chaining .agent() throws — declare it once.
bunx guren tool:list shows what an agent would see. For the metadata fields, the input/output derivation rules and the MCP endpoint, see the Agent Interface guide; for token scopes, approvals and the audit trail, see Control and Audit Agent Tool Access.
Route Model Binding
Without model binding, every controller method starts with the same boilerplate:
// Before: manual lookup in every method
async show() {
const post = await Post.findOrFail(this.ctx.req.param('id'))
return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
}
With model binding, Guren resolves the model automatically. Declare the binding with the bind field of the route's options (see Route Contracts) and read the record in the controller with this.model():
// routes/web.ts — :id is looked up with Post.findOrFail(id)
router.get('/posts/:id', { bind: { id: Post }, name: 'posts.show' }, [PostsController, 'show'])
// Controller receives the resolved model — no lookup needed
async show() {
const post = this.model(Post) // typed as PostRecord
return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
}
If the record is not found, a 404 is returned automatically.
Binding by another column
The class alone always looks up by primary key. To resolve a slug (or any other unique column), bind a [Model, column] tuple. The router calls Post.findOrFail(value, 'slug') and this.model(Post) returns the same record:
router.get('/posts/:slug', { bind: { slug: [Post, 'slug'] }, name: 'posts.show' }, [PostsController, 'show'])
async show() {
const post = this.model(Post) // resolved by slug
// ...
}
The column name is a plain string: a misspelled column fails the query rather than returning 404, so keep it aligned with your schema. When the same parameter is bound both on the router (below) and on the route, the route's own bind wins and the record is looked up once.
Router-level bindings
router.bind(param, ...) binds a parameter name once for every controller-action route on that router whose path contains it. It accepts the same model forms as the bind option (Post or [Post, 'slug']), plus a custom resolver function:
router.bind('post', Post) // by primary key
router.bind('post', [Post, 'slug']) // by slug
router.bind('post', async (value) => Post.where('slug', value).firstOrFail()) // custom resolver
router.get('/posts/:post', [PostsController, 'show'])
Router-level bindings reach the controller as positional arguments after the context, in path-parameter order. A model binding (Post or [Post, 'slug']) is also available through this.model(Post); a custom resolver's value is only available positionally, since there is no model class to look it up by:
import type { Context } from '@guren/core'
async show(_ctx: Context, post: PostRecord) {
return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
}
note
Bound values are not stored on the Hono context — this.ctx.get('post') returns undefined. Use this.model(Post) or the positional argument. Bindings resolve for controller-action routes only; an inline handler receives Hono's (ctx, next) and has to look the record up itself.
Resource Routes
router.resource() generates a full set of RESTful routes from a single line:
router.resource('/posts', PostsController)
This registers:
| Method | Path | Action | Name |
|---|---|---|---|
| GET | /posts |
index |
posts.index |
| GET | /posts/create |
create |
posts.create |
| POST | /posts |
store |
posts.store |
| GET | /posts/:id |
show |
posts.show |
| GET | /posts/:id/edit |
edit |
posts.edit |
| PUT | /posts/:id |
update |
posts.update |
| DELETE | /posts/:id |
destroy |
posts.destroy |
Only methods that exist on the controller are registered. Scope the routes with options:
// API-only — skip create/edit (those are for HTML forms)
router.resource('/posts', PostsController, {
only: ['index', 'show', 'store', 'update', 'destroy'],
})
// Custom parameter name
router.resource('/posts', PostsController, { param: 'post' })
The QUERY Method
router.query() registers a route for the HTTP QUERY method (RFC 10008): safe and idempotent like GET, but the request carries a body like POST. Use it for search and filter endpoints whose criteria are too complex for a URL:
import { z } from 'zod'
router.query('/posts/search', {
name: 'posts.search',
body: z.object({ keywords: z.array(z.string()), limit: z.number().default(20) }),
}, [PostsController, 'search'])
Things to know before reaching for it:
- Handlers must not mutate state. QUERY is a safe method, so Guren's CSRF protection skips it. That is sound because browsers cannot send QUERY cross-site without a CORS preflight, but only while the handler really is read-only. To require CSRF tokens anyway, add
'QUERY'to the CSRF middleware'smethodsoption. - Call it with
fetchor the generated API client (client.request('posts.search', { body })). HTML forms and Inertia form helpers cannot send QUERY. - Check your deployment path. Guren's fetch-based adapters (Bun, the Cloudflare Workers and Vercel plugins) do not block QUERY, but verify that your platform's ingress accepts it: some proxies and CDNs reject methods outside the classic set. Notably CloudFront, which the Lambda plugin's asset distribution puts in front of your app, does not forward QUERY. Intermediary caching of QUERY responses is also not widely implemented yet.
- OpenAPI 3.1 cannot express QUERY, so
guren openapi:generateskips QUERY routes with a warning. - To advertise support to clients, set the
Accept-Queryresponse header yourself, e.g.ctx.header('Accept-Query', 'application/json')on the resource's GET handler.
OpenAPI Document Generation
Install the optional @guren/openapi package and generate a spec from your route definitions:
bun add @guren/openapi
bunx guren openapi:generate
This reads your routes file, extracts Zod schemas and OpenAPI metadata from route contracts, and writes an OpenAPI 3.1 JSON document to .guren/openapi.gen.json.
OpenAPI Metadata
Route contracts also accept lightweight OpenAPI annotations. These are stored on the route definition and used by the optional @guren/openapi plugin to generate an OpenAPI 3.1 document.
router.post('/posts', {
body: CreatePostSchema,
output: PostResponseSchema,
name: 'posts.store',
summary: 'Create a post',
description: 'Creates a new blog post.',
tags: ['Posts'],
}, [PostsController, 'store'])
router.get('/posts/:id', {
params: PostIdParams,
name: 'posts.show',
summary: 'Get a post',
tags: ['Posts'],
deprecated: false,
}, [PostsController, 'show'])
Available OpenAPI fields:
| Field | Type | Purpose |
|---|---|---|
summary |
string |
Short description shown in docs UI |
description |
string |
Detailed explanation of the endpoint |
tags |
string[] |
Group endpoints in the docs UI |
operationId |
string |
Override the auto-generated operation ID |
deprecated |
boolean |
Mark endpoint as deprecated |
CLI Options
# Custom title and version
bunx guren openapi:generate --title "Blog API" --version "1.0.0"
# Custom output path
bunx guren openapi:generate --out docs/openapi.json
# Include a server URL
bunx guren openapi:generate --server "https://api.example.com"
# Overwrite existing file
bunx guren openapi:generate --force
Mounting Docs at Runtime
You can also serve the OpenAPI spec and an interactive docs UI directly from your application:
import { createApp } from '@guren/core'
import { mountOpenApiDocs } from '@guren/openapi'
const app = createApp({ routes: registerWebRoutes })
mountOpenApiDocs(app, {
title: 'Blog API',
version: '1.0.0',
})
This mounts two endpoints:
| Path | Description |
|---|---|
/openapi.json |
The generated OpenAPI 3.1 JSON document |
/docs |
Interactive API documentation UI (Scalar) |
Customize the paths with jsonPath and docsPath options:
mountOpenApiDocs(app, {
title: 'Blog API',
version: '1.0.0',
jsonPath: '/api/openapi.json',
docsPath: '/api/docs',
})
When mounted on an Application instance, route definitions are read from the router automatically. For a plain Hono instance, pass definitions explicitly.
The servers option accepts a function as well as a list. A mounted document is generated per request and the function is called each time, so it can advertise an address the process does not know when it mounts the docs. With PORT=0 the operating system assigns the port, which therefore exists only once listen() has returned it — a fixed list would leave the document, and every client generated from it, pointing at an address nothing is listening on:
mountOpenApiDocs(app, {
title: 'Blog API',
version: '1.0.0',
servers: () => [app.address?.url ?? 'http://localhost:3000'],
})
await app.listen({ port: 0 })
app.address is where listen() bound this app, and undefined until it has. Because the function runs per request, it reads the address after listen() has set it. A plain Hono instance has no Application to ask, so return the address however that app knows it.