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 / DenoBun.serve or Deno.serve with app.fetch as the handler.
  • Node.js@hono/node-server adapter bridges to http.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 Workerswrangler.toml binds D1, KV, R2; export default app; use c.env for bindings (typed via Bindings generic).
  • Deno DeployDeno.serve(app.fetch); import from JSR (jsr:@hono/hono) or npm specifier.
  • BunBun.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: loggersecureHeaders → request ID via c.set → route handlers. bearerAuth only on /v1/probes.
  • Read path: GET /v1/status reads KV keys in parallel with Promise.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=15 on 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 APIsfs, 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 Bindings typingc.env typos 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 app patterns — Workers want fetch handler; verify adapter docs per runtime.

Production checklist

  • Declare TypeScript Bindings and Variables on the root Hono app.
  • secureHeaders, restrictive cors, and auth middleware on mutating routes.
  • Central onError and notFound handlers; 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