Guide
Hono fundamentals explained
A status page request hits a Cloudflare Worker in Singapore. The handler runs
in under five milliseconds: look up the service row in D1, check a KV cache
for the last probe result, return JSON. No cold Node process, no
http.createServer — just a fetch handler built
with Hono, a web framework that speaks the same
Web Standard Request and Response
objects every edge runtime already uses. Hono is deliberately tiny (under
14 KB core) and fast (often faster than raw handlers in benchmarks), yet
it ships routing, middleware, path parameters, validation helpers, and JSX
server rendering. The same app code deploys to Cloudflare Workers, Deno,
Bun,
Deno, and Node
via adapters — one codebase, multiple runtimes. This guide covers the
Hono context model, routing and nested apps, middleware composition, body
parsing and validation, JSX with hono/jsx, deployment patterns
for edge and
serverless
targets, a Harbor Fleet edge status API worked example, a framework decision
table against
Express,
common pitfalls, and a production checklist.
What Hono is (and why Web Standards matter)
Hono (Japanese for “flame”) is a small web
framework created by Yosuke Furukawa. Unlike
Express,
which wraps Node’s IncomingMessage, Hono handlers receive
a Context object (c) that exposes the standard
Request and helpers to build a Response. That
alignment matters because edge runtimes — Cloudflare Workers, Deno
Deploy, Vercel Edge Functions — never adopted Node’s HTTP API.
Code written against fetch runs everywhere; code written against
req.on('data') does not.
Hono’s router uses a trie structure optimized for path matching speed. Middleware and routes share one composition model. TypeScript inference for path parameters and validated JSON bodies is first-class via generics and optional Zod integration. The framework does not include an ORM, template engine, or admin panel — you add those as needed, similar to Express but with a smaller default surface and no legacy Connect baggage.
Runtime placement
- Edge / Workers — primary sweet spot; sub-millisecond routing overhead, global distribution.
- Bun / Deno —
Bun.serveorDeno.servewithapp.fetchas the handler. - Node.js —
@hono/node-serveradapter bridges tohttp.createServer. - Adapters — AWS Lambda, Vercel, Netlify, and others ship as official or community packages.
Your first Hono app
Create an app, register routes, export the 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 User' });
});
app.post('/events', async (c) => {
const body = await c.req.json();
return c.json({ received: body }, 201);
});
export default app;
On Cloudflare Workers, the entry is export default app (Workers
accept a fetch handler). On Node:
import { serve } from '@hono/node-server';
import app from './app.js';
serve({ fetch: app.fetch, port: 3000 });
c.json(), c.text(), c.html(), and
c.body() set Content-Type and status. Return a raw
Response when you need fine-grained control (streaming, custom
headers). Query strings: c.req.query('page') or
c.req.queries('tag') for repeated keys.
Routing, mounting, and route groups
Hono supports all common HTTP verbs. Path parameters use :name
syntax; wildcards use *. Register routes in specificity order
— static paths before parameterized ones on the same prefix.
const api = new Hono();
api.get('/', (c) => c.json({ version: 'v1' }));
api.get('/ships/:shipId', (c) => c.json({ shipId: c.req.param('shipId') }));
app.route('/api/v1', api);
app.route() mounts a sub-application at a prefix, applying its
middleware only to that subtree — the Hono equivalent of Express
Router. For larger APIs, split domains into separate
Hono instances (users.ts, fleet.ts)
and compose in app.ts. Follow
REST naming conventions:
plural resources, correct status codes, consistent error JSON.
Base path and reverse proxies
When deployed behind a path prefix (e.g. /fleet-api), set
new Hono({ strict: false }) or use app.basePath('/fleet-api')
so generated URLs and redirects stay correct. Pair with
nginx or
Traefik
path-based routing at the edge.
Middleware composition
Middleware in Hono is async (c, next) => void. Call
await next() to continue the chain; return a Response
early to short-circuit. Global middleware applies to all routes; route-level
middleware attaches to specific handlers.
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { secureHeaders } from 'hono/secure-headers';
app.use('*', logger());
app.use('*', secureHeaders());
app.use('/api/*', cors({ origin: 'https://app.harbor.example' }));
app.use('/admin/*', async (c, next) => {
const token = c.req.header('Authorization');
if (!token) return c.json({ error: 'unauthorized' }, 401);
c.set('user', await verifyToken(token));
await next();
});
Built-in middleware covers common needs: cors,
compress, etag, jwt,
bearerAuth, timeout, and cache for
Workers KV. Custom middleware stores per-request data with
c.set('key', value) and retrieves it via c.get('key')
— typed when you declare Variables on the
Hono generic.
Error handling
Register app.onError((err, c) => ...) for centralized errors
and app.notFound((c) => ...) for 404s. Unlike Express 4,
async handlers do not need manual catch(next) wrappers —
rejections propagate to onError automatically.
app.onError((err, c) => {
console.error(err);
const status = 'status' in err ? err.status : 500;
return c.json({
error: err.message ?? 'internal_error',
requestId: c.get('requestId'),
}, status);
});
Validation, typing, and request bodies
Hono ships optional validators that parse and type request data. With Zod:
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const ProbeSchema = z.object({
serviceId: z.string().uuid(),
status: z.enum(['up', 'down', 'degraded']),
latencyMs: z.number().int().min(0),
});
app.post('/probes', zValidator('json', ProbeSchema), (c) => {
const data = c.req.valid('json');
return c.json({ stored: data.serviceId }, 201);
});
Validators exist for json, query,
param, header, and form. Invalid
input returns 400 before your handler runs. Combine with
TypeScript
strict mode for end-to-end safety from route definition to service layer.
Raw body and signed webhooks
For HMAC-verified webhooks, read the raw body before JSON parsing:
const raw = await c.req.text() on a dedicated route without
global JSON middleware. Verify the signature against raw bytes, then
JSON.parse(raw). Same lesson as Express: never parse JSON
globally on routes that need the untouched buffer.
JSX server rendering and static assets
Hono includes a lightweight JSX runtime (hono/jsx) for HTML
responses without React on the server:
/** @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>
));
For static files on Workers, use hono/cloudflare-workers asset
bindings or serve from R2. On Node, pair with
@hono/node-server/serve-static. JSX is optional — JSON APIs
never need it — but it is useful for health dashboards, OAuth callback
pages, and edge-rendered error screens without shipping a SPA.
Deployment across runtimes
Hono’s main value is write once, deploy many. Patterns differ per platform:
- Cloudflare Workers —
wrangler.tomlbinds D1, KV, R2; export default app; usec.envfor bindings (typed viaBindingsgeneric). - Deno Deploy —
Deno.serve(app.fetch); import from JSR (jsr:@hono/hono) or npm specifier. - Bun —
Bun.serve({ fetch: app.fetch }); native TypeScript, fast startup. - Node (VPS/Kubernetes) —
@hono/node-server; same app behind reverse proxy TLS termination.
Environment variables: on Workers use secrets and wrangler secret put;
on Node use process.env validated at boot. Avoid assuming
filesystem access — edge runtimes have none. Use object storage (R2, S3)
per our
object storage guide
for uploads and logs.
Worked example: Harbor Fleet edge status API
Harbor Fleet runs a global status API on Cloudflare Workers with Hono. The design optimizes for low latency reads and reliable probe ingestion:
- Routes:
GET /v1/status(public aggregate),GET /v1/services/:id(detail),POST /v1/probes(authenticated ingest from regional checkers). - Storage: D1 SQLite for service definitions; KV for latest probe snapshot per service (60s TTL); no synchronous cross-region writes on the read path.
- Middleware chain:
logger→secureHeaders→ request ID viac.set→ route handlers.bearerAuthonly on/v1/probes. - Read path:
GET /v1/statusreads KV keys in parallel withPromise.all; falls back to D1 last-known row if KV miss (stale-but-available). - Write path: probe POST validates with Zod, writes KV immediately, enqueues D1 upsert via
ctx.waitUntil()so the client gets 202 within 10 ms. - Caching:
Cache-Control: public, max-age=15on status aggregate; purge on manual incident banner update.
The same Hono app runs locally via wrangler dev and in CI via
@hono/node-server with in-memory stubs replacing D1/KV bindings.
Integration tests call app.request('/v1/status') directly —
no listening port required, similar to supertest but using the Web Standard
Request constructor. Production deploys through Workers with
automatic TLS and anycast routing; a Node replica on the VPS handles admin
mutations that need longer CPU time, sharing route modules imported from a
common package.
Framework decision table
| Choose Hono when… | Prefer Express when… | Prefer NestJS when… |
|---|---|---|
| Deploying to Workers, edge, or multiple JS runtimes | Team and ecosystem are Node-only and mature | Large team needs DI, modules, and OpenAPI conventions |
| Cold start and bundle size budgets are tight | Maximum npm middleware compatibility required | Enterprise layered architecture is mandated |
Web Standard fetch portability is a goal |
Legacy Express code and tutorials dominate | Guards, pipes, and testing utilities out of the box |
| JSON APIs and lightweight HTML at the edge | Long-running Node processes with heavy CPU work | Microservices share Nest decorators across repos |
| Benchmark latency matters on high-QPS read paths | Webhook receivers on a single VPS suffice | GraphQL or complex domain modules need structure |
Common pitfalls
- Assuming Node APIs —
fs,child_process, and sync crypto break on Workers; use Web Crypto and bindings. - Heavy bundles on edge — importing large SDKs blows Worker size limits; tree-shake and split admin routes to Node.
- Global JSON parsing before HMAC verify — same webhook mistake as Express; read raw body first on signed routes.
- Ignoring
waitUntil— background D1 writes dropped when the Worker isolate terminates after response. - No
Bindingstyping —c.envtypos fail silently at runtime on Workers. - Stateful in-memory caches on serverless — isolates recycle; use KV or Redis for shared cache.
- Blocking CPU in the hot path — JSON schema validation on megabyte payloads; enforce size limits.
- Mixing
export default apppatterns — Workers wantfetchhandler; verify adapter docs per runtime.
Production checklist
- Declare TypeScript
BindingsandVariableson the rootHonoapp. secureHeaders, restrictivecors, and auth middleware on mutating routes.- Central
onErrorandnotFoundhandlers; safe client messages on 5xx. - Zod (or equivalent) validation on every POST/PUT/PATCH body and query params.
- Structured logging with request IDs; avoid logging secrets or full auth headers.
- Health route (
/health) excluded from heavy middleware for probe reliability. - Test with
app.request()in CI; no port binding required. - Bundle analysis for Workers (
wrangler deploy --dry-run); keep under platform limits. - Secrets in platform vaults (Wrangler secrets, Deno env), never in source.
- Document which routes run edge vs Node; split CPU-heavy work to long-lived processes.
Key takeaways
- Hono is a small, fast framework built on Web Standard
Request/Response— portable across edge and server runtimes. - Middleware and mounting mirror Express ergonomics without Node lock-in; async errors propagate cleanly.
- Validators and TypeScript generics keep edge handlers typed end-to-end with minimal boilerplate.
- Edge deployment (Workers + D1/KV) suits read-heavy, globally distributed APIs like status pages and BFFs.
- Reach for Express or Nest on long-lived Node services with heavy dependencies; use Hono where latency, bundle size, and multi-runtime portability win.
Related reading
- Express fundamentals explained — Node middleware chains and the baseline HTTP framework
- Deno fundamentals explained — secure runtime and
Deno.servedeployment - Bun fundamentals explained — fast JavaScript runtime and
Bun.serve - Serverless computing explained — FaaS, cold starts, and when edge fits