Hono: One API Codebase for Workers, Bun, and Node

Photo by LensaCibi via Wikimedia Commons (CC BY-SA 4.0)
Hono is a small, zero-dependency web framework built on Web Standards like the fetch API's Request and Response objects. Unlike Express, which is bound to Node's http module, Hono carries no runtime assumptions, so the same app runs on Cloudflare Workers, Bun, Node, and Deno. As of July 2026 it is at v4.12 and its hono/tiny preset is under 12kB.
Yes. Your Hono app is an object with a fetch method that takes a Request and returns a Response. Each runtime gets a thin adapter — Workers and Bun export the app directly, while Node uses the @hono/node-server package. The application code never changes; only the small entry file per platform does.
Hono infers the types of your routes directly from your server code. You export one TypeScript type (typically typeof your routes) and the hc client reads it, giving the frontend fully typed inputs and outputs. There is no OpenAPI spec and no codegen step, but inference only works if you chain your route handlers into a single unbroken expression.
Hono's default RegExpRouter compiles all registered routes into a single regular expression, so matching an incoming path is close to constant time regardless of route count. This avoids walking a radix tree on every request and, in published benchmarks, is meaningfully faster than the find-my-way router used by Fastify.
Hono is deliberately unopinionated about layers above HTTP. If you want built-in dependency injection, decorators, and a rigid modular architecture, a batteries-included framework like NestJS is a better fit. Also, any route that reaches for platform-specific APIs — a Cloudflare KV binding or a Node stream — stops being portable, so keep that access behind a swappable interface.

Photo by LensaCibi via Wikimedia Commons (CC BY-SA 4.0)
Key Takeaway
Hono is a zero-dependency web framework built on Web Standards. You write one API using the Request and Response objects the platform already provides, then run it unchanged on Cloudflare Workers, Bun, Node, and Deno by swapping a thin adapter. Its typed RPC client gives the frontend full type safety with no codegen.
I have shipped enough Express services to know the tax it quietly charges: it is glued to Node's http module, so the day you want to run the same handlers on an edge runtime, you rewrite. Hono takes the opposite bet. It is built on the Web Standard Request and Response objects — the same primitives you already use in the browser and in a Service Worker — so the framework itself carries no runtime assumptions. As of July 2026 it sits at v4.12, has zero dependencies, and the hono/tiny preset weighs under 12kB.
The practical payoff is portability. I can prototype an API on Bun locally for the fast startup, deploy it to Cloudflare Workers for global low-latency reads, and keep a Node build around for a legacy VPS — all from the same source tree. This post walks through routing, middleware, the runtime adapters, and the typed RPC client, with the trade-offs I actually hit.
Hono's default RegExpRouter compiles every route you register into a single large regular expression. Matching an incoming path is therefore close to constant time regardless of how many routes exist, rather than walking a radix tree per request. In published benchmarks this makes route lookup meaningfully faster than the find-my-way tree that powers Fastify. The routing API itself will feel familiar — method handlers, path params, and wildcards — but the shape of the objects you touch is pure Web Standard.
import { Hono } from 'hono'
const app = new Hono()
app.get('/health', (c) => c.json({ ok: true }))
app.get('/users/:id', (c) => {
const id = c.req.param('id') // typed path param
const verbose = c.req.query('verbose') // query string
return c.json({ id, verbose })
})
app.post('/users', async (c) => {
const body = await c.req.json() // Web Standard Request under the hood
return c.json({ created: body }, 201)
})
export default appMiddleware in Hono is a function that receives the context and a next callback. You do work before calling next, await it, then do work after — the classic onion model, but every piece is portable because none of it reaches for Node globals. The official packages cover the boring-but-essential list: CORS, JWT verification, bearer auth, request logging, compression, ETag, and rate limiting. You mount them per path or globally.
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { jwt } from 'hono/jwt'
const app = new Hono()
app.use('*', logger())
app.use('/api/*', cors({ origin: 'https://www.matthewswong.com' }))
app.use('/api/*', jwt({ secret: process.env.JWT_SECRET! }))
// custom middleware — same signature
app.use('*', async (c, next) => {
const start = Date.now()
await next()
c.header('X-Response-Time', `${Date.now() - start}ms`)
})Order matters and short-circuiting is explicit: if a middleware returns a Response instead of calling next, the chain stops there. That is how auth guards reject a request cleanly — return a 401 and never touch the handler.
This is the part that sold me. Your Hono app is just an object with a fetch method that takes a Request and returns a Response. Each runtime has its own way of feeding requests in, so Hono ships a thin adapter per platform — the app code never changes, only the entry file does. Cloudflare Workers and Bun both speak fetch natively, so you export the app directly. Node needs a bridge from its http module, which the Node adapter provides.
// app.ts — shared by ALL runtimes, never changes
import { Hono } from 'hono'
export const app = new Hono().get('/', (c) => c.text('Hello from anywhere'))
// --- Cloudflare Workers: worker.ts
import { app } from './app'
export default app // Workers calls app.fetch
// --- Bun: bun.ts -> bun run bun.ts
import { app } from './app'
export default { fetch: app.fetch, port: 3000 }
// --- Node: node.ts -> node node.ts
import { serve } from '@hono/node-server'
import { app } from './app'
serve({ fetch: app.fetch, port: 3000 })Portability ends where platform APIs begin. If a handler reaches for a Cloudflare KV binding, a Bun-specific file API, or a Node stream, that route stops being portable. Keep runtime-specific access behind a small interface you can swap, or you will lose the very property you chose Hono for.
Hono's RPC feature shares your route types directly with the frontend. There is no OpenAPI file and no code generation step — you export one TypeScript type from the server and the hc client reads it. The catch, and it is the single most common footgun, is that inference only survives if you chain your handlers into one expression. Break the chain into separate statements and the client loses its types. I use a request validator so the inferred input and output are actually enforced at runtime, not just in the editor.
// server.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const routes = new Hono()
.post(
'/posts',
zValidator('json', z.object({ title: z.string() })),
(c) => {
const { title } = c.req.valid('json')
return c.json({ id: 1, title }, 201)
},
) // <-- keep the chain unbroken so types are inferred
export type AppType = typeof routes
export default routes
// client.ts — frontend, fully typed, no codegen
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc<AppType>('https://api.example.com')
const res = await client.posts.$post({ json: { title: 'Hello' } })
const data = await res.json() // data.title is typed as stringFor a large API, compile the client type ahead of time instead of letting your editor recompute it on every keystroke. The docs recommend wrapping hc in a helper whose return type is fixed with ReturnType, so tsc does the heavy type instantiation once at build time and your IDE stays fast.
I reach for Hono when the workload is API-shaped and I care about deploying to more than one place: edge functions, a Bun service, a small Node container. It is deliberately unopinionated about the layers above HTTP, so if you want dependency injection, decorators, and a rigid module system, a batteries-included framework like NestJS will feel more complete. The table below is how I decide.
| Concern | Hono | Express |
|---|---|---|
| Built on | Web Standards (fetch) | Node http module |
| Runtimes | Workers, Bun, Node, Deno | Node only |
| Dependencies | Zero | Several transitive |
| Typed client | Built-in RPC, no codegen | None |
| Router | Precompiled RegExpRouter | Linear middleware match |
None of this makes Express wrong — it makes Hono a better default for the edge era. If your API might outlive its first runtime, writing it against Web Standards today means you are not rewriting it the day you move. That single property has saved me more time than any micro-benchmark ever will.