Best Practices
Model-written
contextgapclaimmethodresultanalysislimitation
Best Practices
Follow best practices rather than writing your Hono app however you like
Hono is very flexible. You can write your app as you like. However, there are best practices that are better to follow.Hono is very flexible. However, there are best practices that are better to follow.Hono is flexible, but some best practices are better to follow Flexibility allows any style, yet the page recommends conventions that avoid type and structure problemsHono is flexible, but some best practices are better to followbest practices
Don't make "Controllers" when possible
Avoid RoR-like Controllers; write handlers directly after path definitions
When possible, you should not create "Ruby on Rails-like Controllers".When possible, you should not create "Ruby on Rails-like Controllers".You should not create Ruby on Rails-like Controllers when possible The following units explain that such controllers break path parameter type inferenceYou should not create Ruby on Rails-like Controllers when possibleRails-like Controllers
// 🙁
// A RoR-like Controller
const booksList = (c: Context) => {
return c.json('list books')
}
app.get('/books', booksList)The issue is related to types. For example, the path parameter cannot be inferred in the Controller without writing complex generics.The issue is related to types. For example, the path parameter cannot be inferred in the Controller without writing complex generics.Path parameters cannot be inferred in a Controller without complex generics The context type alone lacks the route path, so c.req.param returns an untyped valuePath parameters cannot be inferred in a Controller without complex genericsPath parameters
// 🙁
// A RoR-like Controller
const bookPermalink = (c: Context) => {
const id = c.req.param('id') // Can't infer the path param
return c.json(`get ${id}`)
}Therefore, you don't need to create RoR-like controllers and should write handlers directly after path definitions.Therefore, you don't need to create RoR-like controllers and should write handlers directly after path definitions.Write handlers directly after path definitions instead of RoR-like controllers Inline handlers let TypeScript infer the path param from the route string automaticallyWrite handlers directly after path definitions instead of RoR-like controllershandlers
// 😃
app.get('/books/:id', (c) => {
const id = c.req.param('id') // Can infer the path param
return c.json(`get ${id}`)
})factory.createHandlers() in hono/factory
Use factory.createHandlers() from hono/factory for correct type inference
If you still want to create a RoR-like Controller, use factory.createHandlers() in hono/factory. If you use this, type inference will work correctly.If you still want to create a RoR-like Controller, use factory.createHandlers() in hono/factory. If you use this, type inference will work correctly.factory.createHandlers() in hono/factory preserves type inference for controllers It carries route context through middleware and handlers so path parameters stay typedfactory.createHandlers() in hono/factory preserves type inference for controllersfactory.createHandlers()
import { createFactory } from 'hono/factory'
import { logger } from 'hono/logger'
// ...
// 😃
const factory = createFactory()
const middleware = factory.createMiddleware(async (c, next) => {
c.set('foo', 'bar')
await next()
})
const handlers = factory.createHandlers(logger(), middleware, (c) => {
return c.json(c.var.foo)
})
app.get('/api', ...handlers)Building a larger application
Use app.route() to split a larger application into mounted files
Use app.route() to build a larger application without creating "Ruby on Rails-like Controllers".Use app.route() to build a larger application without creating "Ruby on Rails-like Controllers".Use app.route() to build a larger application without RoR-like Controllers Mounting sub-apps keeps each file's handlers inline so path types still inferUse app.route() to build a larger application without RoR-like Controllersapp.route()
If your application has /authors and /books endpoints and you wish to separate files from index.ts, create authors.ts and books.ts.If your application has /authors and /books endpoints and you wish to separate files from index.ts, create authors.ts and books.ts.Separate /authors and /books into authors.ts and books.ts files Each file exports its own Hono instance holding the routes for that resourceSeparate /authors and /books into authors.ts and books.ts filesendpoints
// authors.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.json('list authors'))
app.post('/', (c) => c.json('create an author', 201))
app.get('/:id', (c) => c.json(`get ${c.req.param('id')}`))
export default app// books.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.json('list books'))
app.post('/', (c) => c.json('create a book', 201))
app.get('/:id', (c) => c.json(`get ${c.req.param('id')}`))
export default appThen, import them and mount on the paths /authors and /books with app.route().Then, import them and mount on the paths /authors and /books with app.route().Import the sub-apps and mount them with app.route() on their paths index.ts stays small while each resource keeps its own route definitionsImport the sub-apps and mount them with app.route() on their pathsapp.route()
// index.ts
import { Hono } from 'hono'
import authors from './authors'
import books from './books'
const app = new Hono()
// 😃
app.route('/authors', authors)
app.route('/books', books)
export default appIf you want to use RPC features
Chain routes and export AppType so hc gets correct RPC types
The code above works well for normal use cases.
However, if you want to use the RPC feature, you can get the correct type by chaining as follows.The code above works well for normal use cases. However, if you want to use the RPC feature, you can get the correct type by chaining as follows.Chain routes to get correct RPC types from the exported AppType Chaining preserves the full route schema in typeof app, which RPC clients needChain routes to get correct RPC types from the exported AppTypeRPC types
// authors.ts
import { Hono } from 'hono'
const app = new Hono()
.get('/', (c) => c.json('list authors'))
.post('/', (c) => c.json('create an author', 201))
.get('/:id', (c) => c.json(`get ${c.req.param('id')}`))
export default app
export type AppType = typeof appIf you pass the type of the app to hc, it will get the correct type.If you pass the type of the app to hc, it will get the correct type.Passing AppType to hc makes the client correctly typed The client reads the exported route schema to infer request and response typesPassing AppType to hc makes the client correctly typedhc
import type { AppType } from './authors'
import { hc } from 'hono/client'
// 😃
const client = hc<AppType>('http://localhost') // Typed correctlyFor more detailed information, please see the RPC page.
Understanding Hono's HEAD Handling
Hono converts HEAD to GET and strips the body before route matching
Hono automatically handles HEAD requests by converting them to GET requests and stripping the response body. This behavior is built into the framework's dispatch layer and happens before route matching occurs.Hono automatically handles HEAD requests by converting them to GET requests and stripping the response body. This behavior is built into the framework's dispatch layer and happens before route matching occurs.Hono converts HEAD requests to GET and strips the response body This happens in the dispatch layer before route matching, so route handlers see GETHono converts HEAD requests to GET and strips the response bodyHEAD requests
✅ Do: Use GET Routes for HEAD Requests
// GOOD: This GET route automatically handles HEAD requests
app.get('/api/users', async (c) => {
const users = await getUsers()
c.header('X-Total-Count', users.length.toString())
return c.json(users)
})
// HEAD /api/users will return:
// - Same headers as GET (including X-Total-Count)
// - Status 200
// - No body (null)✅ Do: Use Middleware for HEAD-Specific Logic
// GOOD: Use middleware when HEAD needs different behavior
app.use('/api/resource', async (c, next) => {
await next()
// Add HEAD-specific headers after the handler
if (c.req.method === 'HEAD') {
c.header('X-HEAD-Processed', 'true')
// Don't compute expensive body content for HEAD
c.res = new Response(null, c.res)
}
})❌ Don't: Try to Create Dedicated HEAD Handlers
// BAD: This won't work as expected
app.head('/api/users', (c) => {
// This handler will NEVER be called
c.header('X-Custom', 'value')
return c.text('ignored')
})
// BAD: Using on() also won't work
app.on('HEAD', '/api/users', (c) => {
// Still converted to GET before route matching
})Performance Considerations
Skip expensive body work for HEAD and rely on automatic skipping
- Avoid expensive operations in GET handlers if you expect many HEAD requests: Use middleware to detect HEAD and skip body generationAvoid expensive operations in GET handlers if you expect many HEAD requests: Use middleware to detect HEAD and skip body generationAvoid expensive body work in GET handlers when expecting many HEAD requests HEAD still runs the GET handler, so unused body generation wastes computationAvoid expensive body work in GET handlers when expecting many HEAD requestsGET handlers
- Cache headers work identically: HEAD responses respect the same caching rules as GETCache headers work identically: HEAD responses respect the same caching rules as GETHEAD responses respect the same caching rules as GET Because HEAD is served through GET, identical cache headers applyHEAD responses respect the same caching rules as GETcaching rules
- Middleware compatibility: Most middleware works with HEAD, but body-processing middleware (like compression) automatically skips HEAD requestsMiddleware compatibility: Most middleware works with HEAD, but body-processing middleware (like compression) automatically skips HEAD requestsBody-processing middleware like compression automatically skips HEAD requests Skipping avoids compressing a body that HEAD responses will not carryBody-processing middleware like compression automatically skips HEAD requestsMiddleware compatibility
Testing HEAD Requests
// Always test both GET and HEAD responses
it('handles HEAD requests correctly', async () => {
const getRes = await app.request('/api/users')
const headRes = await app.request('/api/users', { method: 'HEAD' })
expect(headRes.status).toBe(getRes.status)
expect(headRes.headers.get('X-Total-Count')).toBe(
getRes.headers.get('X-Total-Count')
)
expect(headRes.body).toBe(null)
})Notes
Automatic HEAD conversion keeps headers consistent across all runtimes
- The automatic HEAD conversion ensures consistent headers between GET and HEAD responsesThe automatic HEAD conversion ensures consistent headers between GET and HEAD responsesAutomatic HEAD conversion keeps headers consistent between GET and HEAD Both methods run the same handler, so header values matchAutomatic HEAD conversion keeps headers consistent between GET and HEADHEAD conversion
- This behavior is consistent across all Hono runtimes (Cloudflare Workers, Deno, Bun, Node.js)This behavior is consistent across all Hono runtimes (Cloudflare Workers, Deno, Bun, Node.js)HEAD behavior is consistent across Cloudflare Workers, Deno, Bun and Node.js The dispatch layer implementing the conversion is runtime-independentHEAD behavior is consistent across Cloudflare Workers, Deno, Bun and Node.jsHEAD behavior
- If you need completely different logic for HEAD vs GET, consider using different endpoints rather than trying to override the framework's HEAD handlingIf you need completely different logic for HEAD vs GET, consider using different endpoints rather than trying to override the framework's HEAD handlingFor entirely different HEAD logic, use separate endpoints rather than overriding The framework converts HEAD before routing, so dedicated HEAD handlers are never calledFor entirely different HEAD logic, use separate endpoints rather than overridingHEAD logic