Hono foundations
Return clear responses
Use text, JSON, redirects, bodies, headers, and status codes as one intentional response contract.
Hono helpers create responses. They do not decide which status or body shape your API contract requires. Pick the outcome first, then pick the helper.
Creating a bookmark returns JSON, status 201, and a Location header:
app.post('/bookmarks', async (c) => {
const body = await c.req.json()
const bookmark = { id: '42', title: body.title, url: body.url }
return c.json(bookmark, 201, {
Location: `/bookmarks/${bookmark.id}`
})
})
Deleting one returns 204 with no invented payload:
app.delete('/bookmarks/:id', (c) => {
const id = c.req.param('id')
// ... remove bookmark ...
return c.body(null, 204)
})
A missing bookmark gets 404, not 200 with { error: null }:
app.get('/bookmarks/:id', (c) => {
const bookmark = findBookmark(c.req.param('id'))
if (!bookmark) {
return c.json({ error: 'Not found' }, 404)
}
return c.json(bookmark)
})
Redirects use c.redirect('/login', 302) when a browser client needs to move. Plain text health checks use c.text('ok'). Match the representation to the client you document, not to whichever helper you typed first.
The shortcut I see most often is returning { ok: true } for every branch because c.json() is convenient. Clients cannot tell success from failure, and your tests cannot assert meaningful status codes.
Write a response table before you code. List success, invalid input, missing resource, conflict, and server failure. Each row gets a status, a body shape, and any required headers.
| Outcome | Status | Body |
|---|---|---|
| Created | 201 | bookmark + Location |
| Invalid JSON | 400 | { error, fields } |
| Not found | 404 | { error } |
| Deleted | 204 | empty |
Hand that table to someone else. If they can predict the API without reading your handlers, the contract is clear. Your Vitest suite should mirror the same rows.
Try this on your own project: pick one resource and define all five outcomes before writing the route handlers.
Lesson completed