Routing and middleware
Design the route tree
Group resource routes, parameters, methods, and fallbacks into an inspectable API shape.
Hono matches routes by HTTP method and path in registration order. Group related paths into one router so the API shape stays visible.
Our bookmarks router owns list, create, read, update, and delete under /bookmarks:
import { Hono } from 'hono'
const bookmarks = new Hono()
bookmarks.get('/', (c) => c.json([]))
bookmarks.post('/', async (c) => {
const body = await c.req.json()
return c.json({ id: '1', ...body }, 201)
})
bookmarks.get('/:id', (c) => {
return c.json({ id: c.req.param('id'), title: 'Hono docs', url: 'https://hono.dev' })
})
bookmarks.put('/:id', async (c) => c.json(await c.req.json()))
bookmarks.delete('/:id', (c) => c.body(null, 204))
const app = new Hono()
app.route('/bookmarks', bookmarks)
app.get('/health', (c) => c.json({ ok: true }))
app.route() mounts the sub-app at a prefix. Handlers inside inherit /bookmarks without repeating the string.
Verify the tree with curl:
curl -s http://localhost:3000/bookmarks
curl -s -X POST http://localhost:3000/bookmarks \
-H 'content-type: application/json' \
-d '{"title":"Hono docs","url":"https://hono.dev"}'
curl -s http://localhost:3000/bookmarks/bk_01
You should see [], then 201 with a bookmark object, then 200 for the id route. A request to /bookmark (missing the s) returns 404 {"error":"Route not found"} when notFound is configured.
Registration order matters. A wildcard registered before a literal path steals matches:
app.get('/bookmarks/*', () => { /* catches everything */ })
app.get('/bookmarks/export', () => { /* never reached */ })
Put specific paths first. Catch-alls and fallbacks go last.
A realistic failure: you mount the router at /api/v1/bookmarks but tests call app.request('/bookmarks'). Vitest shows 404 while curl against the running server on the prefixed path works. Fix the test path or document the base prefix in one shared constant both app and tests import.
Wrong-method requests need explicit tests. DELETE /bookmarks without an id should 404, not hit the list handler.
Export a BOOKMARKS_BASE constant from the app module when you mount under /api/v1/bookmarks. Tests and OpenAPI docs import the same string so path drift shows up in CI instead of production.
When you split routers into files, the route table in README stays the map of truth. One handler per row, one file per resource.
Vitest for unknown paths:
expect((await app.request('/bookmarks/unknown/nope')).status).toBe(404)
That should hit notFound, not a param route with a weird handler.
Method mismatch tests belong in the same file: POST /bookmarks/bk_01 should not silently hit GET /bookmarks/:id.
Try this on your own project: draw the route table for one resource, refactor into a grouped sub-app, and re-run the same curl commands.
Lesson completed