Hono is a web framework with an Express-shaped API built entirely on Web Standards. The same app.ts runs on Node.js, Bun, Deno, Cloudflare Workers, AWS Lambda and Fastly Compute without a rewrite, because the framework never invented its own request object - it uses the Request and Response your browser already has.
That portability is the pitch. This guide covers what it costs, what it buys, and how to build a real API with it: routing, middleware, typed validation, the RPC client, deployment per runtime, and the places where an unopinionated framework leaves you holding decisions Express would have made for you.
What Hono is
Hono means flame in Japanese. It was created by Yusuke Wada, is MIT-licensed, and has zero runtime dependencies. The core is a router and a Context object; everything else - middleware, adapters, helpers, JSX - is a separate entry point that only enters your bundle when you import it.
| In the box | Import | Notes |
|---|---|---|
| Router and app | hono | Five router implementations, three presets |
| Middleware | hono/cors, hono/jwt, hono/logger, … | Auth, CORS, CSRF, ETag, compression, caching, timing |
| Validation | hono/validator | Plus adapters for Zod, Valibot, TypeBox, ArkType |
| RPC client | hono/client | Typed client derived from your route definitions |
| Testing | hono/testing | app.request() and a typed testClient |
| JSX | hono/jsx | Server-side rendering with no React dependency |
| Streaming | hono/streaming | Server-Sent Events and streamed responses |
| Static generation | hono/ssg | toSSG() renders routes to files |
| Runtime adapters | hono/bun, hono/aws-lambda, hono/vercel, … | Platform-specific helpers |
hono/tiny preset is under 14 kB minified.The framework deliberately stops there. There is no ORM, no dependency injection container, no prescribed folder structure, no CLI that scaffolds a service layer. Whether that is a feature or a gap is the central question of the whole evaluation, and this article comes back to it.
Why Web Standards is the whole point
Express predates the Fetch API. Its req and res are Node.js streams wrapped in framework conventions, which is why an Express app cannot run on Cloudflare Workers at all - there is no Node.js http module there to wrap.
Hono handlers receive a Context and return a Response. Both are the standard objects. A handler is, at bottom, a function from Request to Response, which is exactly the contract every modern JavaScript runtime already implements.
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hono!'))
export default appexport default app is all Cloudflare Workers and Bun need.The Context object
c is the single argument you work with. It reads the request, builds the response, and carries typed values between middleware and handlers.
app.get('/books/:id', async (c) => {
const id = c.req.param('id') // typed from the route pattern
const format = c.req.query('format') // string | undefined
const auth = c.req.header('Authorization')
const body = await c.req.json() // standard Request body helpers
c.header('X-Total-Count', '1')
c.status(200)
return c.json({ id, format }) // or c.text, c.html, c.redirect, c.body
})c.env reaches platform bindings - Cloudflare KV, D1, environment variables.One codebase, several runtimes
The portability claim holds because only the entry point changes. Route files, middleware and business logic are identical across every target below.
// Cloudflare Workers
export default app
// Bun
export default { port: 3000, fetch: app.fetch }
// Deno
Deno.serve(app.fetch)
// Node.js - needs the adapter
import { serve } from '@hono/node-server'
serve({ fetch: app.fetch, port: 3000 })
// AWS Lambda
import { handle } from 'hono/aws-lambda'
export const handler = handle(app)@hono/node-server is the only adapter that is a separate package, because Node.js is the only target without native Fetch semantics at the server level.Routing
Route registration looks like Express and infers path parameters into the handler's types, which Express never did.
app.get('/users', (c) => c.json({ users: [] }))
app.post('/users', async (c) => c.json(await c.req.json(), 201))
app.get('/users/:id', (c) => c.json({ id: c.req.param('id') }))
app.get('/posts/:date{[0-9]+}/:title', (c) => c.json(c.req.param()))
app.get('/files/*', (c) => c.text('wildcard'))
app.on(['PUT', 'PATCH'], '/users/:id', (c) => c.json({ updated: true }))GET route answers HEAD requests automatically, with the same headers and no body.Five routers, three presets
Hono ships several router implementations and picks between them at startup. SmartRouter benchmarks the registered routes and settles on the fastest option that supports your patterns. The default pairs RegExpRouter with TrieRouter, which is the right choice for almost everyone.
| Preset | Router | Use it when |
|---|---|---|
hono | SmartRouter over RegExpRouter and TrieRouter | The default. Long-lived processes and Workers. |
hono/quick | SmartRouter over LinearRouter and TrieRouter | Environments that re-initialise on every request, where registration cost dominates. |
hono/tiny | PatternRouter | Hard bundle-size limits. Smallest build, slower matching. |
Hono class is identical across presets - only the router differs, so switching is a one-line import change.This is the sort of knob most teams should never touch. It exists because Hono targets environments with genuinely different cost models: a Cloudflare Worker that may cold-start on any request has different priorities from a Node.js process that registers its routes once and runs for a week.
Middleware
Middleware is an onion. Everything before await next() runs on the way in, everything after runs on the way out, and registration order is execution order.
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { secureHeaders } from 'hono/secure-headers'
app.use(logger()) // all routes
app.use(secureHeaders())
app.use('/api/*', cors({ origin: 'https://app.example.com' }))
app.post('/admin/*', bearerAuth({ token: process.env.ADMIN_TOKEN! }))| Import | Purpose |
|---|---|
hono/cors | Cross-origin headers, with explicit origin allowlists |
hono/csrf | CSRF protection for form submissions |
hono/jwt | JWT signing and verification across HS, RS, PS, ES and EdDSA families |
hono/bearer-auth, hono/basic-auth | Token and basic authentication |
hono/secure-headers | Security response headers |
hono/logger, hono/timing, hono/request-id | Observability primitives |
hono/etag, hono/cache, hono/compress | Caching and transfer efficiency |
hono/body-limit, hono/timeout | Request-size and duration guards |
hono/method-not-allowed | Correct 405 responses - added in 4.13.0 |
Writing your own
createMiddleware from hono/factory gives you a typed middleware whose variables flow into every downstream handler.
import { createMiddleware } from 'hono/factory'
import { HTTPException } from 'hono/http-exception'
type AuthEnv = { Variables: { user: { id: string; role: 'admin' | 'member' } } }
export const authenticate = createMiddleware<AuthEnv>(async (c, next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) throw new HTTPException(401, { message: 'Authentication required.' })
const user = await verifyToken(token)
if (!user) throw new HTTPException(401, { message: 'Invalid token.' })
c.set('user', user)
await next()
})
app.get('/me', authenticate, (c) => c.json(c.var.user)) // c.var.user is typedValidation and end-to-end types
This is where Hono pulls ahead of Express by a wide margin, and where its one genuinely surprising gotcha lives.
Validators
zValidator parses a request target - json, form, query, param, header or cookie - and hands the parsed value to your handler as a fully typed object via c.req.valid(). Equivalent adapters exist for Valibot, TypeBox and ArkType, and @hono/standard-validator covers any library implementing Standard Schema.
import * as z from 'zod'
import { zValidator } from '@hono/zod-validator'
const noteInput = z.object({
title: z.string().trim().min(1).max(120),
body: z.string().max(10_000),
})
app.post('/notes', zValidator('json', noteInput), (c) => {
const input = c.req.valid('json') // { title: string; body: string }
return c.json({ data: createNote(input) }, 201)
})import type { ValidationTargets } from 'hono'
import { HTTPException } from 'hono/http-exception'
import { zValidator as zv } from '@hono/zod-validator'
import type * as z from 'zod'
export const validate = <T extends z.ZodType, Target extends keyof ValidationTargets>(
target: Target,
schema: T
) =>
zv(target, schema, (result) => {
if (!result.success) {
throw new HTTPException(422, { message: 'Request validation failed.' })
}
})onError handler then formats every failure the same way.The gotcha worth knowing before you hit it
Validators must be registered inside the route definition, not through app.use. Attach one with app.use and the types do not flow: c.req.valid() loses its shape, and you get a confusing TypeScript error about never.
// Types are lost - c.req.valid('json') is not usable
app.use('/notes', zValidator('json', noteInput))
app.post('/notes', (c) => c.json(c.req.valid('json')))
// Types flow correctly
app.post('/notes', zValidator('json', noteInput), (c) =>
c.json(c.req.valid('json'))
)The RPC client
Export your app's type and hc builds a client from it. No code generation, no build step, no schema duplicated between server and frontend. Rename a route or change a response shape and the consumer fails to compile.
const routes = app
.get('/notes', (c) => c.json({ data: listNotes() }))
.post('/notes', zValidator('json', noteInput), (c) =>
c.json({ data: createNote(c.req.valid('json')) }, 201)
)
export type AppType = typeof routes
export default appapp.get(...) on separate statements gives you an app type without those routes in it.import { hc } from 'hono/client'
import type { AppType } from '../server/src/app'
const client = hc<AppType>('https://api.example.com')
const res = await client.notes.$post({
json: { title: 'Standup', body: 'Notes' }, // checked against the server schema
})
if (res.ok) {
const { data } = await res.json() // typed from the handler's return
}A complete REST API
Small enough to read, complete enough to deploy. It runs on Bun below; the only file that changes for another runtime is the entry point.
Layout
notes-api/
├── package.json
├── tsconfig.json
├── .env
├── src/
│ ├── index.ts # runtime entry point - the only portable-unsafe file
│ ├── app.ts # middleware, mounting, error handling
│ ├── config.ts # validated environment
│ ├── lib/validate.ts # the validator wrapper from above
│ ├── routes/notes.ts # one router per resource
│ └── domain/notes.ts # business logic, no Hono import
└── test/
└── notes.test.tsdomain/ importing nothing from Hono is the discipline that keeps the framework replaceable.{
"name": "notes-api",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/index.ts",
"start": "bun src/index.ts",
"test": "bun test",
"check-types": "tsc --noEmit"
},
"dependencies": {
"hono": "^4.13.0",
"@hono/zod-validator": "^0.7.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.9.2"
}
}Environment
import * as z from 'zod'
const schema = z.object({
PORT: z.coerce.number().int().positive().default(3000),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
ALLOWED_ORIGIN: z.string().url(),
})
const parsed = schema.safeParse(process.env)
if (!parsed.success) {
console.error('Invalid environment:', z.treeifyError(parsed.error))
process.exit(1)
}
export const config = parsed.dataDomain logic
export type NoteInput = { title: string; body: string }
export type Note = NoteInput & { id: string; createdAt: string }
const notes = new Map<string, Note>()
export function listNotes(limit: number): Note[] {
return [...notes.values()]
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, limit)
}
export function getNote(id: string): Note | undefined {
return notes.get(id)
}
export function createNote(input: NoteInput): Note {
const note: Note = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
...input,
}
notes.set(note.id, note)
return note
}crypto.randomUUID() is a Web Standard, so this file runs unchanged on a Worker.Routes
import { Hono } from 'hono'
import { HTTPException } from 'hono/http-exception'
import * as z from 'zod'
import { validate } from '../lib/validate'
import { createNote, getNote, listNotes } from '../domain/notes'
const listQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
})
const noteInput = z.object({
title: z.string().trim().min(1).max(120),
body: z.string().max(10_000),
})
export const notes = new Hono()
.get('/', validate('query', listQuery), (c) => {
const { limit } = c.req.valid('query')
return c.json({ data: listNotes(limit) })
})
.post('/', validate('json', noteInput), (c) => {
const note = createNote(c.req.valid('json'))
return c.json({ data: note }, 201)
})
.get('/:id', (c) => {
const note = getNote(c.req.param('id'))
if (!note) throw new HTTPException(404, { message: 'Note not found.' })
return c.json({ data: note })
})Assembly and error handling
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { requestId } from 'hono/request-id'
import type { RequestIdVariables } from 'hono/request-id'
import { HTTPException } from 'hono/http-exception'
import { config } from './config'
import { notes } from './routes/notes'
const app = new Hono<{ Variables: RequestIdVariables }>()
app.use(requestId())
app.use(logger())
app.use(secureHeaders())
app.use('/api/*', cors({ origin: config.ALLOWED_ORIGIN, credentials: true }))
const routes = app
.get('/health', (c) => c.json({ status: 'ok' }))
.route('/api/notes', notes)
app.notFound((c) =>
c.json({ error: { status: 404, message: 'Route not found.' } }, 404)
)
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({ error: { status: err.status, message: err.message } }, err.status)
}
console.error({ requestId: c.get('requestId'), err })
return c.json({ error: { status: 500, message: 'Unexpected server error.' } }, 500)
})
export type AppType = typeof routes
export default apponError is the single place unexpected errors become responses - nothing else needs a try/catch for the generic case.import app from './app'
import { config } from './config'
export default {
port: config.PORT,
fetch: app.fetch,
}serve({ fetch: app.fetch }) on Node.js, or delete it entirely on Cloudflare Workers.Testing
No supertest, no listening port, no test server lifecycle. app.request() takes a path or a Request and returns the Response, running the full middleware chain in-process.
import { describe, expect, test } from 'bun:test'
import app from '../src/app'
describe('notes', () => {
test('rejects an empty title with 422', async () => {
const res = await app.request('/api/notes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: '', body: 'x' }),
})
expect(res.status).toBe(422)
})
test('creates and reads back a note', async () => {
const created = await app.request('/api/notes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: 'Standup', body: 'Notes' }),
})
expect(created.status).toBe(201)
const { data } = await created.json()
const fetched = await app.request(`/api/notes/${data.id}`)
expect(fetched.status).toBe(200)
})
})For contract-level tests, testClient gives you the RPC client pointed at the app in memory - the request itself is then type-checked against the route definition.
import { testClient } from 'hono/testing'
import { expect, test } from 'bun:test'
import app from '../src/app'
test('list endpoint accepts a limit', async () => {
const client = testClient(app)
const res = await client.api.notes.$get({ query: { limit: '5' } })
expect(res.status).toBe(200)
})testClient needs chained route definitions for the same reason the RPC client does.Structuring a larger application
Hono's documentation is unusually direct about this: do not build Rails-style controllers. Handlers written in a separate file and passed by reference lose path parameter inference, which throws away the type safety you came for.
The recommended shape is one Hono instance per resource, mounted with app.route(). Business logic lives in plain modules that know nothing about the framework - the domain/ directory in the example above.
// Loses path parameter inference
app.get('/books/:id', bookController.show)
// Keeps it
app.get('/books/:id', (c) => bookService.show(c.req.param('id')))If you genuinely want handlers defined elsewhere, createFactory().createHandlers() from hono/factory preserves the types. createApp() from the same factory keeps you from repeating your Env type in every file.
import { createFactory } from 'hono/factory'
const factory = createFactory<{ Variables: { user: User } }>()
export const handlers = factory.createHandlers(authenticate, (c) =>
c.json(c.var.user)
)
app.get('/profile', ...handlers)OpenAPI and generated documentation
@hono/zod-openapi reuses the schemas you already wrote for validation to produce an OpenAPI document, and @hono/swagger-ui serves it. One schema drives runtime validation, handler types, the RPC client and the published spec - which removes the most common source of documentation drift.
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'
import { swaggerUI } from '@hono/swagger-ui'
const app = new OpenAPIHono()
const NoteSchema = z
.object({
id: z.string().openapi({ example: '9b1d…' }),
title: z.string(),
})
.openapi('Note')
app.openapi(
createRoute({
method: 'get',
path: '/notes/{id}',
request: { params: z.object({ id: z.string() }) },
responses: {
200: {
content: { 'application/json': { schema: NoteSchema } },
description: 'A single note',
},
},
}),
(c) => c.json(getNote(c.req.valid('param').id))
)
app.doc('/openapi.json', { openapi: '3.0.0', info: { title: 'Notes', version: '1' } })
app.get('/docs', swaggerUI({ url: '/openapi.json' }))OpenAPIHono is a drop-in replacement for Hono - existing routes keep working alongside documented ones.Beyond APIs
Hono is primarily a backend framework, but three built-ins extend past that and are worth knowing exist.
- JSX rendering via
hono/jsx- server-rendered HTML with no React dependency and no build step. Good for admin panels, emails and small pages; not a replacement for a frontend framework. - Static generation via
toSSG()inhono/ssg, which walks your routes and writes files. Since 4.12 it also emits redirect pages with canonical and noindex tags. - Streaming via
hono/streaming- Server-Sent Events and streamed responses, which is what you want for LLM token streams and progress feeds.
There is also HonoX, a meta-framework layering file-based routing and client islands on top of Hono. It is a separate project at an earlier stage of maturity than Hono itself; treat it as a different adoption decision rather than a feature of the framework.
Where Hono falls short
It decides nothing for you
No project structure, no dependency injection, no data layer, no configuration convention. On a two-person team that is freedom. On a fifteen-person team it means every service invents its own shape unless someone writes the conventions down and enforces them in review. Budget for that document.
RPC types get expensive at scale
The RPC client infers from the entire app type. As route counts grow, the TypeScript language server slows down - editor autocomplete lags, tsc takes longer. This is inherent to the approach rather than a bug. Splitting the app into sub-apps and exporting narrower types per area keeps it manageable, but it is a real cost that appears around the point the codebase gets interesting.
A smaller ecosystem
Express has fifteen years of middleware for every third-party service, and fifteen years of answered questions about them. Hono's middleware collection is good and growing, but you will occasionally write an integration yourself that would have been an npm install on Express.
The validator ordering trap
The app.use versus per-route validator distinction is documented, and it still catches people, because the failure is a TypeScript error about never rather than anything that names the actual cause. It is worth putting in your team's onboarding notes.
Version cadence
Minor releases land often. That is healthy for a project this active, and it means pinning versions and reading release notes is not optional. There have been security fixes in middleware - one in CORS header handling in the 4.10.x line - so staying current is part of the deal.
Not a full-stack framework
If you want file-based routing, layouts, data loading conventions and a client hydration story out of the box, Hono is not that and does not claim to be. It is a routing and middleware layer. HonoX aims at the gap and is not yet as settled as the core.
Hono against the alternatives
| Framework | Runtime reach | Type story | Pick it when |
|---|---|---|---|
| Hono | Node, Bun, Deno, Workers, Lambda, Fastly | Inferred params, typed validation, RPC client | You want one routing layer that survives a change of platform |
| Express | Node.js only | Types are bolted on afterwards | Ecosystem familiarity outweighs everything else |
| Fastify | Node.js only | JSON Schema with type providers | Maximum Node.js throughput and a mature plugin system |
| Elysia | Bun-first | Excellent inference, Bun-specific | You have committed to Bun and want the fastest option on it |
| Next.js route handlers | Node and edge, inside Next.js | Types tied to the framework | The API exists to serve one Next.js frontend |
| tRPC | Runtime-agnostic client layer | Best-in-class end-to-end types | You control both ends and do not need a public REST surface |
The clearest case for Hono over Express is not speed. It is that the same code runs on a VPS today and a Worker next quarter, and that the types are real rather than added later by a @types package.
Performance, honestly
Hono is fast, and the reasons are architectural rather than magical: RegExpRouter matches against one compiled regular expression instead of looping over routes, the framework has zero dependencies, and there is no abstraction layer between the handler's return value and the runtime's Response.
The 4.13.0 release notes report up to 1.25x improvement on common routes in the project's own benchmarks/fetch suite, and roughly 20% faster route registration plus first match after a RegExpRouter rewrite. Those are the maintainers' numbers on their hardware and their workload.
Security
- Set
cors()to an explicit origin allowlist. Never combine a wildcard origin withcredentials: true. - Validate every request target you read -
json,query,param,header,cookie- and return your own error envelope rather than the validator's raw output. - Add
secureHeaders()early in the chain, andbodyLimit()on any route accepting uploads. - Enforce authorisation inside handlers on the server. Route grouping is organisation, not access control.
- Keep secrets in
c.envon Workers and in the process environment elsewhere; validate them at boot with the config schema. - Track Hono releases. Middleware has had security fixes, and the framework is the layer sitting in front of everything else you wrote.
When to use it, and when not to
Reach for Hono when
- You are building an HTTP API and want typed request validation without a code generation step.
- You deploy to the edge, or think you might - Workers, Lambda@Edge, Deno Deploy, Fastly.
- You want one framework across several services running on different runtimes.
- Bundle size or cold start matters, which on a Worker it always does.
- Your frontend and backend live in one repo and the RPC client can replace a hand-written API layer.
- You want an API and a small amount of server-rendered HTML from the same process.
Choose something else when
- You depend on Express middleware with no Hono equivalent and no appetite to port it.
- Your team wants a framework that dictates structure - Hono will not, and a large team without conventions will drift.
- You need a full-stack framework with file-based routing and hydration today; that is Next.js, Remix or SvelteKit territory.
- You are on Bun exclusively and want the fastest possible option, where Elysia is a fair competitor.
- Pure Node.js throughput is the deciding metric, where Fastify generally leads.
- Your service is a large existing Express application and nothing about it currently hurts.
Verdict
Hono is a good framework and an unusually honest one. It does routing, middleware and typed validation, does them well, and does not pretend to solve architecture. The Web Standards foundation is not a marketing line - it is the reason the same code runs in six places, and it will keep being true as new runtimes appear, because they all implement the same Request and Response.
The type story is the strongest practical argument. Path parameters inferred from the route, request bodies typed by the schema that validates them, and a client derived from the server with no build step - that closes a category of bug that Express projects simply live with.
What you give up is opinion. Hono hands you a routing layer and expects you to bring the rest: structure, data access, conventions, error taxonomy. For a competent team that is exactly right. For a team that wanted the framework to make those calls, it is work that will otherwise happen inconsistently across services.
Choose Hono for portability and types. If you choose it for benchmark numbers, you have picked the least interesting thing about it.
It is production-ready now, used at scale by teams building on Workers, Lambda and Bun, and the release cadence is active without being unstable. Start with one service, keep your domain logic free of framework imports, and you will find out quickly whether the trade suits how your team works.
Sources
- Hono documentation - routing, middleware, validation, helpers and adapters
- honojs/hono releases - version history and release notes
- Best practices - controllers,
app.route()and the factory helper - Validation guide - targets, per-route registration and validator adapters
- Hono Stacks - how validation, RPC and the client fit together
- Routers and presets - router selection and trade-offs
- Testing helper -
app.request()andtestClient - @hono/node-server - the Node.js adapter



