Skip to main content

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 app

Then, 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 app

If 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 app

If 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 correctly

For more detailed information, please see the RPC page.

HEAD Request Best Practices

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 generation
    Avoid expensive operations in GET handlers if you expect many HEAD requests: Use middleware to detect HEAD and skip body generation
    Avoid expensive body work in GET handlers when expecting many HEAD requests HEAD still runs the GET handler, so unused body generation wastes computation
    Avoid expensive body work in GET handlers when expecting many HEAD requests
    GET handlers
  • Cache headers work identically: HEAD responses respect the same caching rules as GET
    Cache headers work identically: HEAD responses respect the same caching rules as GET
    HEAD responses respect the same caching rules as GET Because HEAD is served through GET, identical cache headers apply
    HEAD responses respect the same caching rules as GET
    caching rules
  • Middleware compatibility: Most middleware works with HEAD, but body-processing middleware (like compression) automatically skips HEAD requests
    Middleware compatibility: Most middleware works with HEAD, but body-processing middleware (like compression) automatically skips HEAD requests
    Body-processing middleware like compression automatically skips HEAD requests Skipping avoids compressing a body that HEAD responses will not carry
    Body-processing middleware like compression automatically skips HEAD requests
    Middleware 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 responses
    The automatic HEAD conversion ensures consistent headers between GET and HEAD responses
    Automatic HEAD conversion keeps headers consistent between GET and HEAD Both methods run the same handler, so header values match
    Automatic HEAD conversion keeps headers consistent between GET and HEAD
    HEAD 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-independent
    HEAD behavior is consistent across Cloudflare Workers, Deno, Bun and Node.js
    HEAD behavior
  • If you need completely different logic for HEAD vs GET, consider using different endpoints rather than trying to override the framework's HEAD handling
    If you need completely different logic for HEAD vs GET, consider using different endpoints rather than trying to override the framework's HEAD handling
    For entirely different HEAD logic, use separate endpoints rather than overriding The framework converts HEAD before routing, so dedicated HEAD handlers are never called
    For entirely different HEAD logic, use separate endpoints rather than overriding
    HEAD logic