Skip to Content
guidesBest Practices

Last Updated: 3/9/2026


Best Practices

Hono is flexible and unopinionated, but following these patterns will help you build maintainable, type-safe applications.

Avoid “Controllers” When Possible

Don’t create Ruby on Rails-style controllers that separate route definitions from handlers.

The Problem with Controllers

Separating handlers loses type inference:

// ❌ Don't do this const booksList = (c: Context) => { return c.json('list books') } app.get('/books', booksList)

Path parameters can’t be inferred:

// ❌ Type inference doesn't work const bookPermalink = (c: Context) => { const id = c.req.param('id') // Can't infer the path param type return c.json(`get ${id}`) } app.get('/books/:id', bookPermalink)

The Solution: Inline Handlers

Define handlers inline to preserve type inference:

// ✅ Do this instead app.get('/books/:id', (c) => { const id = c.req.param('id') // Type is correctly inferred as string return c.json(`get ${id}`) })

Using factory.createHandlers() for Reusable Logic

If you need to extract handler logic, use factory.createHandlers() from hono/factory to maintain type safety:

import { createFactory } from 'hono/factory' import { logger } from 'hono/logger' const factory = createFactory() // Create middleware with proper types const middleware = factory.createMiddleware(async (c, next) => { c.set('foo', 'bar') await next() }) // Create handlers that preserve types const handlers = factory.createHandlers(logger(), middleware, (c) => { return c.json(c.var.foo) // Type-safe access to 'foo' }) app.get('/api', ...handlers)

This approach maintains type inference while allowing code reuse.

Building Larger Applications

For larger applications, organize routes into separate files and use app.route() to compose them.

File Structure

Organize by feature or resource:

src/ ├── index.ts # Main app ├── authors.ts # Authors routes └── books.ts # Books routes

Route Modules

Create separate Hono instances for each resource:

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

Composing Routes

Mount route modules on the main app:

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

This creates these endpoints:

  • GET /authors → list authors
  • POST /authors → create author
  • GET /authors/:id → get author
  • GET /books → list books
  • POST /books → create book
  • GET /books/:id → get book

Enabling RPC Type Inference

When using RPC features, chain route definitions to preserve types:

authors.ts:

import { Hono } from 'hono' // Chain methods to preserve types 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 // Export the type

client.ts:

import type { AppType } from './authors' import { hc } from 'hono/client' // Fully typed client const client = hc<AppType>('http://localhost:8787') const res = await client.index.$get() // Type-safe!

For complete RPC setup in larger apps, see Using RPC with Larger Applications.

Environment Variables and Bindings

Define types for environment variables and platform bindings:

type Bindings = { DATABASE_URL: string API_KEY: string MY_KV: KVNamespace } type Variables = { user: User } const app = new Hono<{ Bindings: Bindings Variables: Variables }>() app.use('/api/*', async (c, next) => { // Typed access to env const apiKey = c.env.API_KEY const data = await c.env.MY_KV.get('key') await next() })

Middleware Organization

Apply middleware in the correct order:

import { logger } from 'hono/logger' import { cors } from 'hono/cors' import { secureHeaders } from 'hono/secure-headers' const app = new Hono() // 1. Global middleware (runs for all routes) app.use('*', logger()) app.use('*', secureHeaders()) // 2. Route-specific middleware app.use('/api/*', cors()) // 3. Route handlers app.get('/api/posts', (c) => c.json({ posts: [] }))

Middleware executes in registration order, so place global middleware first.

Error Handling Patterns

Set up centralized error handling:

import { HTTPException } from 'hono/http-exception' app.onError((err, c) => { if (err instanceof HTTPException) { // Handle HTTP exceptions return err.getResponse() } // Log unexpected errors console.error(err) return c.json( { error: 'Internal Server Error' }, 500 ) })

Throw HTTP exceptions in handlers:

import { HTTPException } from 'hono/http-exception' app.get('/posts/:id', async (c) => { const id = c.req.param('id') const post = await getPost(id) if (!post) { throw new HTTPException(404, { message: 'Post not found' }) } return c.json({ post }) })

Validation Best Practices

Always validate input at route boundaries:

import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const postSchema = z.object({ title: z.string().min(1).max(100), body: z.string().min(1), published: z.boolean().default(false), }) app.post( '/posts', zValidator('json', postSchema), async (c) => { const data = c.req.valid('json') // Fully typed and validated // ... create post return c.json({ message: 'Created' }, 201) } )

See Validation for more details.

Testing Strategy

Write tests for your routes using app.request():

import { describe, test, expect } from 'vitest' describe('Books API', () => { test('GET /books returns list', async () => { const res = await app.request('/books') expect(res.status).toBe(200) const data = await res.json() expect(Array.isArray(data)).toBe(true) }) test('POST /books creates book', async () => { const res = await app.request('/books', { method: 'POST', body: JSON.stringify({ title: 'Test Book' }), headers: { 'Content-Type': 'application/json' }, }) expect(res.status).toBe(201) }) })

See Testing Applications for comprehensive testing patterns.

What’s Next