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 Context wrapping Request and return Response (or helpers like c.json()). No proprietary req/res objects tied to Node’s http module.
  • 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.fetch entrypoint runs behind Workers’ export default app, Bun’s Bun.serve({ fetch: app.fetch }), or @hono/node-server on 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()), raw Request.
  • c.json(), c.text(), c.html() — typed response helpers setting Content-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) from wrangler.toml.
  • c.executionCtx — Workers waitUntil() 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.

  1. App structure — root Hono mounts /api JSON routes and /status HTML via JSX. Shared middleware: logger, secureHeaders, compress on GET responses.
  2. Read pathsGET /api/vessels queries D1 with a prepared statement; results cached in KV for 60 seconds keyed by route + query hash to absorb traffic spikes.
  3. Write pathsPOST /api/vessels/:imo/position protected by bearerAuth middleware comparing against c.env.INGEST_KEY; body validated with Zod (IMO, lat, lon, timestamp).
  4. 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.
  5. Local dev — Bun serves app.fetch with a SQLite file mirroring D1 schema; tests use app.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 edgeNode JSON APIs need schema-first validation and Pino loggingLegacy codebase and middleware ecosystem are Express-native
Bundle size and cold start time are hard constraintsThroughput on a single Node process is the bottleneckTeam prioritizes lowest framework learning curve
Same routes must run on Deno, Bun, and WorkersPlugin encapsulation and OpenAPI-from-schema are centralUnusual middleware (Socket.io stacks) already integrated
JSX status pages and JSON share one WorkerLong-lived WebSockets on one Node host dominatePrototyping speed beats multi-runtime portability
Storage is D1, KV, or HTTP-backed at the edgeNative pg pools and CPU-heavy jobs stay on serverEdge deployment is not on the roadmap

Common pitfalls

  • Assuming Node APIs on Workersfs.readFile and native pg fail 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.env typing — generate Worker types from wrangler types so 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.fetch from a factory; keep runtime entry files (Worker, Bun, Node) thin.
  • Apply logger, secureHeaders, and path-scoped cors before route handlers.
  • Validate JSON, query, and params with @hono/zod-validator on every public mutating route.
  • Type c.env via 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/Response with helpers, per-request state, and Worker bindings.
  • Middleware chains like Express but composes as fetch handlers 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