Guide
Hono fundamentals explained
A status API must answer from Singapore in under 30 ms while the same codebase
runs integration tests on a developer laptop in Portland. Traditional
Express servers
assume a long-lived Node process; Cloudflare Workers cap CPU time and forbid most
Node built-ins. Hono targets exactly this gap: an ultralight web
framework (~14 KB) built entirely on the Web Standards
Request/Response and fetch APIs, so one router
definition deploys to Workers, Deno,
Bun, and Node (via
adapter packages) without rewriting handlers. Middleware chains resemble Express, but
under the hood every route is a composable fetch handler — the
same primitive browsers and edge runtimes already speak. This guide covers Hono
routing and context, middleware, validation with
Zod, multi-runtime
adapters, JSX server rendering, a Harbor Fleet edge status API worked example, a
framework decision table, common pitfalls, and a production checklist.
What Hono is and why it exists
Hono (Japanese for “flame”) is a small, fast HTTP framework created by Yosuke Furukawa. Its design constraints differ from Fastify or NestJS:
- Web Standards first — handlers receive a
ContextwrappingRequestand returnResponse(or helpers likec.json()). No proprietary req/res objects tied to Node’shttpmodule. - Edge-native — no dependency on Node
fs,net, or blocking APIs; ideal for Cloudflare Workers, Vercel Edge Functions, and Deno Deploy. - Ultralight bundle — tree-shakeable; cold starts on Workers stay small compared to pulling Express plus polyfills.
- Multi-runtime — the same
app.fetchentrypoint runs behind Workers’export default app, Bun’sBun.serve({ fetch: app.fetch }), or@hono/node-serveron traditional VPS hosts.
Hono is not an ORM, not a job queue, and not a replacement for Drizzle or PostgreSQL connection pools on the edge — you still choose storage (D1, Hyperdrive, external HTTP APIs) per deployment. What it gives you is a consistent routing and middleware layer that travels with your runtime choice.
When Hono is the right default
- Global low-latency APIs — auth gateways, geo-routing, A/B config, public status pages.
- Monorepos targeting edge + Node — share route modules between a Worker and a Bun fallback server.
- Lightweight BFF layers — aggregate upstream JSON without shipping a full Fastify stack to the edge.
- JSX/HTML micro-sites — server-rendered pages from the same Worker that serves JSON.
Prefer Fastify or NestJS when you need deep Node integration (native pg
pools, long-lived WebSocket servers, heavy CPU on one machine) or JSON Schema-driven
OpenAPI as the primary contract layer. Prefer
tRPC when your
entire product is TypeScript monorepo end-to-end and you want RPC inference without
HTTP route boilerplate.
Creating an app and defining routes
A minimal Hono app exports a fetch handler:
import { Hono } from 'hono'
const app = new Hono()
app.get('/health', (c) => c.json({ status: 'ok' }))
app.get('/users/:id', (c) => {
const id = c.req.param('id')
return c.json({ id, name: 'Harbor pilot' })
})
export default app
Route parameters use :name syntax; optional segments and regex patterns
are supported. Group related routes with app.route():
const fleet = new Hono()
fleet.get('/vessels', (c) => c.json([]))
fleet.get('/vessels/:imo', (c) => c.json({ imo: c.req.param('imo') }))
app.route('/api/fleet', fleet)
The Context object
c (Context) is the handler’s hub:
c.req— query (c.req.query('page')), headers, JSON body (await c.req.json()), rawRequest.c.json(),c.text(),c.html()— typed response helpers settingContent-Type.c.set()/c.get()— per-request variables set by upstream middleware (user id, trace id).c.env— on Cloudflare Workers, bindings (KV, D1, secrets) fromwrangler.toml.c.executionCtx— WorkerswaitUntil()for background tasks after the response is sent.
Handlers may be sync or async. Return a Response directly for full
control (streaming, custom status). Use app.onError() and
app.notFound() for centralized 404 and 500 formatting.
Middleware
Middleware in Hono follows the Express mental model: functions run before the
handler, call await next() to continue, and can short-circuit with a
response. Apply globally or per path:
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
app.use('*', logger())
app.use('/api/*', cors({ origin: 'https://harbor.example' }))
app.use('*', secureHeaders())
Official middleware packages (imported from hono/*) cover common needs:
- cors — configurable cross-origin rules for browser clients.
- compress — gzip/brotli response compression where the runtime supports it.
- jwt — verify Bearer tokens and attach payload to context.
- bearerAuth — simple static token comparison for internal APIs.
- timeout — abort handlers that exceed a deadline (critical on Workers).
- etag — conditional GET caching for cacheable JSON or HTML.
Custom auth middleware typically reads a header, validates, sets
c.set('userId', sub), then calls next(). If validation
fails, return c.json({ error: 'unauthorized' }, 401) without calling
next(). Order matters: logger and security headers first; auth on
protected path prefixes only (app.use('/api/admin/*', adminAuth)).
Validation with Zod
Runtime validation belongs in middleware, not scattered through handlers. The
@hono/zod-validator package pairs cleanly with Zod schemas:
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const vesselSchema = z.object({
imo: z.string().regex(/^\d{7}$/),
name: z.string().min(1).max(120),
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
})
app.post(
'/api/fleet/vessels',
zValidator('json', vesselSchema),
(c) => {
const body = c.req.valid('json')
return c.json({ created: true, imo: body.imo }, 201)
}
)
Invalid bodies return 400 with structured Zod errors before your handler
runs. Validate query strings and route params the same way ('query',
'param'). Combine with
TypeScript
so c.req.valid('json') infers the parsed type. For OpenAPI-first teams,
@hono/zod-openapi generates specs from Zod definitions — useful
when external integrators need documented contracts.
Multi-runtime deployment
Hono’s core value is writing once and choosing runtime at deploy time:
Cloudflare Workers
// src/index.ts
import app from './app'
export default {
fetch: app.fetch,
}
Bindings arrive on c.env. Use D1 for SQLite at the edge, KV for
read-heavy caches, R2 for object storage. Respect CPU limits: offload heavy work
via Queues or call a regional Node service.
Bun
export default {
port: 3000,
fetch: app.fetch,
}
Bun auto-starts when the default export exposes port and
fetch. Pair with bun:sqlite for local parity with D1
schemas where practical.
Node.js
import { serve } from '@hono/node-server'
serve({ fetch: app.fetch, port: 3000 })
Use the Node adapter when you need native drivers (PostgreSQL, Redis) on a VPS or Kubernetes pod. Same route modules; swap only the entry file and env config.
Deno
Deno Deploy and deno serve accept app.fetch directly.
Import Hono from npm:hono or JSR per your Deno version policy.
JSX server rendering
Hono includes a JSX runtime for HTML responses without React on the server:
/** @jsx jsx */
/** @jsxImportSource hono/jsx */
import { Hono } from 'hono'
const app = new Hono()
app.get('/status', (c) =>
c.html(
<html>
<body>
<h1>Fleet status</h1>
<p>All systems operational</p>
</body>
</html>
)
)
JSX on Workers suits status pages, email-preview tools, and lightweight admin UIs
where shipping a SPA is overkill. For rich client interactivity, serve JSON from
Hono and mount a Vite/React front end separately. Streaming HTML via
streamSSE or streamText supports progressive rendering on
runtimes that allow it.
Testing and the RPC client
Test Hono apps without binding ports using app.request():
const res = await app.request('/api/fleet/vessels/1234567')
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({ imo: '1234567' })
Pass Request init for POST bodies and headers. On Workers, use
wrangler dev and Miniflare for integration tests against D1/KV
bindings. The hono/client helper generates a type-safe fetch client
from your route types when using Hono’s RPC mode — a lighter alternative
to tRPC for Hono-native stacks.
Worked example: Harbor Fleet edge status API
Harbor Fleet operates container ships worldwide. Operations wanted a public status API with sub-50 ms latency globally, backed by a D1 database synced from the shore-side ERP every five minutes. Requirements: read-heavy JSON, API key for write paths, HTML status page for non-technical stakeholders, and one codebase tested locally on Bun then deployed to Cloudflare Workers.
- App structure — root
Honomounts/apiJSON routes and/statusHTML via JSX. Shared middleware:logger,secureHeaders,compresson GET responses. - Read paths —
GET /api/vesselsqueries D1 with a prepared statement; results cached in KV for 60 seconds keyed by route + query hash to absorb traffic spikes. - Write paths —
POST /api/vessels/:imo/positionprotected bybearerAuthmiddleware comparing againstc.env.INGEST_KEY; body validated with Zod (IMO, lat, lon, timestamp). - Background sync — Cron Trigger invokes a separate Worker module that pulls ERP CSV, upserts D1, and purges KV prefixes; long work uses
waitUntil()so cron HTTP returns quickly. - Local dev — Bun serves
app.fetchwith a SQLite file mirroring D1 schema; tests useapp.request()with fixture API keys.
Production deploys through wrangler deploy with staged environments
(staging bindings point at test D1). Node fallback on a single VPS runs the same
route modules via @hono/node-server for disaster recovery if the
Worker platform degrades — only the entry adapter and c.env
wiring differ, extracted into a small bindings.ts factory.
Framework decision table
| Choose Hono when… | Prefer Fastify when… | Prefer Express when… |
|---|---|---|
| Primary deploy target is Cloudflare Workers or edge | Node JSON APIs need schema-first validation and Pino logging | Legacy codebase and middleware ecosystem are Express-native |
| Bundle size and cold start time are hard constraints | Throughput on a single Node process is the bottleneck | Team prioritizes lowest framework learning curve |
| Same routes must run on Deno, Bun, and Workers | Plugin encapsulation and OpenAPI-from-schema are central | Unusual middleware (Socket.io stacks) already integrated |
| JSX status pages and JSON share one Worker | Long-lived WebSockets on one Node host dominate | Prototyping speed beats multi-runtime portability |
| Storage is D1, KV, or HTTP-backed at the edge | Native pg pools and CPU-heavy jobs stay on server | Edge deployment is not on the roadmap |
Common pitfalls
- Assuming Node APIs on Workers —
fs.readFileand nativepgfail at the edge; use bindings, fetch to upstream services, or Hyperdrive. - CPU-heavy work in request path — Workers terminate slow handlers; move aggregation to Cron, Queues, or regional Node.
- Ignoring
c.envtyping — generate Worker types fromwrangler typesso secrets and bindings are not mistyped strings. - Global mutable state — module-level caches survive across requests on Workers but not across isolates; use KV/D1 for shared truth.
- Middleware order bugs — auth after body parsers that consume the stream; place validators immediately before handlers on the same route.
- Skipping
app.request()tests — edge-only manual testing misses regressions; CI should cover 401, 400, and happy paths. - Over-fetching on cold starts — importing huge dependency trees in the Worker entry bloats startup; lazy-import rare code paths.
- Mixing RPC and REST semantics — pick one client contract per surface; do not expose ad-hoc JSON shapes alongside Hono RPC without versioning.
Practitioner checklist
- Export
app.fetchfrom a factory; keep runtime entry files (Worker, Bun, Node) thin. - Apply
logger,secureHeaders, and path-scopedcorsbefore route handlers. - Validate JSON, query, and params with
@hono/zod-validatoron every public mutating route. - Type
c.envvia Wrangler or a bindings interface shared across runtimes. - Use
app.route()to modularize large APIs; avoid thousand-line single files. - Implement
app.onError()returning safe JSON without stack traces in production. - Write tests with
app.request(); include auth rejection and validation failure cases. - Cache read-heavy GETs in KV with explicit TTL and invalidation on writes.
- Document CPU and subrequest limits for your edge platform; load-test from multiple regions.
- Maintain a Node/Bun fallback entry if the API is business-critical and edge outages are unacceptable.
Key takeaways
- Hono is a Web Standards HTTP framework optimized for edge runtimes and tiny bundles.
- Context wraps
Request/Responsewith helpers, per-request state, and Worker bindings. - Middleware chains like Express but composes as
fetchhandlers under the hood. - Multi-runtime — one router deploys to Workers, Bun, Deno, and Node via adapters.
- Validation with Zod middleware keeps handlers focused on business logic, not parsing.
Related reading
- Fastify fundamentals explained — schema-first Node APIs when edge is not the primary target
- Zod fundamentals explained — validation schemas used with
@hono/zod-validator - Bun fundamentals explained — runtime that pairs naturally with Hono for local dev and VPS deploys
- Drizzle fundamentals explained — type-safe SQL ORM for D1, Postgres, and edge-friendly databases