Routing
Routes map URLs to your application logic. They define what happens when a user visits /posts, submits a form, or hits an API endpoint. In vNext-style apps, each application owns a Router instance and route files export a registrar function instead of mutating a global registry.
Routing
Routes map URLs to your application logic. They define what happens when a user visits /posts, submits a form, or hits an API endpoint. In vNext-style apps, each application owns a Router instance and route files export a registrar function instead of mutating a global registry.
Defining Routes
Create routes/web.ts and export a registrar that receives the application router:
// 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({ ok: true }))
}
Available methods: router.get, router.post, router.put, router.patch, router.delete, and the generic router.on(method, path, handler).
Pass the registrar to createApp() in src/app.ts:
// src/app.ts
import { createApp } from '@guren/core'
import registerWebRoutes from '@/routes/web'
const app = createApp({
routes: registerWebRoutes,
})
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
Middleware runs before (or after) your route handler. Guren supports three levels of middleware configuration.
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(router: Router): void {
router.aliasMiddleware('auth', requireAuthenticated())
router.aliasMiddleware('admin', requireAdmin())
}
Middleware Groups
Bundle related middleware under a single name:
router.groupMiddleware('web', ['session', 'csrf'])
router.groupMiddleware('api', ['throttle'])
Applying Middleware
Use router.middleware().group() for a set of routes, or .middleware() on a single route:
// Group-level — all routes inside share the middleware
router.middleware('web').group((web) => {
web.get('/', [HomeController, 'index'])
web.get('/about', [PagesController, 'about'])
})
// Nested — combine middleware layers
router.middleware('web').group((web) => {
web.middleware('auth').group((auth) => {
auth.get('/dashboard', [DashboardController, 'index'])
auth.get('/settings', [SettingsController, 'index'])
})
})
// Per-route — for one-off protection
router.get('/admin', [AdminController, 'index']).middleware('auth', 'admin')
Tip
Keep route files clean by using aliases. Import middleware functions once at the top and alias them, then use string names everywhere else.
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:
// Register bindings (top of routes file)
router.bind('post', Post)
// Route uses :post instead of :id
router.get('/posts/:post', [PostsController, 'show'])
// Controller receives the resolved model — no lookup needed
async show() {
const post = this.ctx.get('post') as PostRecord
return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
}
If the record is not found, a 404 is returned automatically.
You can also bind with a custom resolver for slug-based lookups:
router.bind('post', async (value) => Post.where('slug', value).firstOrFail())
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' })
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')
Note
For large apps, split routes into multiple registrars (routes/api.ts, routes/admin.ts) and compose them from src/app.ts.
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.
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 |
bind |
Route model binding map |
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.
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 |
See the OpenAPI guide section in the CLI reference for generating the spec document.
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.
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.