../

Hono

Hono v4 (4.13): a small, dependency-free web framework built on Web-standard Request/Response. The same app runs on Bun, Deno, Node, Cloudflare Workers, Vercel and AWS Lambda, with typed routing, middleware, validation and an end-to-end typed RPC client. Runtime basics are in Bun, the HTTP primitives in Fetch API.

Why Hono

A Hono app is a fetch(request, env, ctx) => Response function, so each runtime only needs a thin entry.

RuntimeEntryNotes
Bunexport default appserves on :3000; WebSockets and static files via hono/bun
DenoDeno.serve(app.fetch)hono/deno helpers
Node 20+serve({ fetch: app.fetch })adapter @hono/node-server
Cloudflare Workersexport default appc.env holds KV, D1, R2 and secret bindings
Vercelexport default appzero-config Hono detection
AWS Lambdaexport const handler = handle(app)hono/aws-lambda
Service Worker, Netlify, Lambda@Edgeadapter in hono/<runtime>same app code
PresetRouterPick when
honoSmartRouter (RegExpRouter + TrieRouter)default; fastest matching
hono/quickSmartRouter (LinearRouter, TrieRouter)short-lived processes where route registration dominates
hono/tinyPatternRoutersmallest bundle

Setup on Bun

bun create hono@latest my-api   # choose the "bun" template
cd my-api
bun run --hot src/index.ts      # hot reload on :3000
bun add hono                    # add to an existing project
src/index.ts
import { Hono } from "hono";
 
const app = new Hono();
 
app.get("/", (c) => c.text("Hello Hono"));
 
export default app;              // Bun.serve picks up fetch
Entry formUse when
export default appdefaults are fine (port from PORT or 3000)
export default { port: 8787, fetch: app.fetch }fixed port, plus websocket for WebSockets
Bun.serve({ fetch: app.fetch, port })you need the server handle (shutdown, server.requestIP)

For Node, run npm create hono@latest and pick the nodejs template. For JSX, set "jsx": "react-jsx" and "jsxImportSource": "hono/jsx" in tsconfig.json.

Routing

Pattern / APIMatches
app.get("/posts/:id", h)/posts/42; c.req.param("id")
"/posts/:id?"/posts and /posts/42
"/posts/:id{[0-9]+}"params constrained by a regexp
"/files/:path{.+}"rest of the path, slashes included
"/assets/*"wildcard, any depth
app.post, .put, .patch, .delete, .optionsone method
app.all(path, h)every method
app.on(["PUT", "DELETE"], path, h)several methods (or a custom one like "PURGE")
app.use(path?, mw)middleware for a path prefix ("/admin/*") or everything
app.route("/users", users)mount a sub-app under a prefix
new Hono().basePath("/api")prefix every route of this app
app.mount("/legacy", fetchFn)mount another framework's fetch handler
new Hono({ strict: false })treat /a and /a/ the same

Handlers and middleware run in registration order, and the first handler that returns a response wins. Register specific routes before catch-alls, and middleware before the routes it guards.

src/routes/books.ts
import { Hono } from "hono";
 
// chain the calls: the chained type is what RPC reads
export const books = new Hono()
  .get("/", (c) => c.json({ books: [] as string[] }))
  .get("/:id", (c) => {
    const id = Number(c.req.param("id"));
    return c.json({ id });
  })
  .post("/", async (c) => c.json(await c.req.json(), 201));
src/app.ts
import { Hono } from "hono";
import { books } from "./routes/books";
 
const app = new Hono()
  .basePath("/api")
  .route("/books", books);   // /api/books, /api/books/:id
 
export type AppType = typeof app;
export default app;

Context

Every handler and middleware gets one Context (c).

Request (c.req)Returns
param("id") / param()one path param / all as an object
query("q") / queries("tag")string | undefined / string[] | undefined
header("authorization")header value (case-insensitive)
json<T>(), text(), arrayBuffer(), blob(), formData()body readers (cached, safe to call twice)
parseBody()multipart/form-data or urlencoded into an object; { all: true } for repeats
valid("json")data that passed a validator, typed
url, path, method, routePathrequest URL, path, verb, matched pattern
rawthe underlying Request
Response helperDoes
c.json(data, status?, headers?)JSON response; the status becomes part of the RPC type
c.text(s), c.html(s | jsx)text or HTML
c.body(data, status?)raw body (string, bytes, stream)
c.redirect(url, 301?)302 by default
c.notFound()runs the notFound handler
c.status(201), c.header("k", "v")set before returning a body
c.resthe response, readable and replaceable after await next()
Context stateDoes
c.set("key", v) / c.get("key") / c.var.keyper-request values, typed through Variables
c.envruntime bindings: Workers env, the Bun Server, Node's incoming/outgoing
env(c) from hono/adapterenvironment variables on any runtime
c.executionCtx.waitUntil(p)Workers: finish work after responding
c.errorthe thrown error, visible to middleware after next()
import { Hono } from "hono";
 
type AppEnv = {
  Bindings: { DB_URL: string };      // c.env (Workers)
  Variables: { user: { id: string } };
};
 
const app = new Hono<AppEnv>();
 
app.use(async (c, next) => {
  c.set("user", { id: "u_1" });    // checked vs AppEnv
  await next();
});
 
app.get("/me", (c) => c.json({ id: c.var.user.id }));

Middleware

Middleware is async (c, next) => { …; await next(); … }. Code before next() runs on the way in, code after it runs on the way out (onion order). Return a Response instead of calling next() to short-circuit.

MiddlewareImportNotes
logger()hono/loggermethod, path, status, time
cors({ origin, credentials })hono/corsorigin may be a string, list or function
secureHeaders()hono/secure-headersHelmet-style headers, CSP option
csrf({ origin })hono/csrfchecks Origin on form posts
jwt({ secret, alg: "HS256" })hono/jwtalg is required; payload in c.get("jwtPayload")
bearerAuth({ token })hono/bearer-authstatic token(s) or verifyToken
basicAuth({ username, password })hono/basic-author verifyUser
etag()hono/etagETag plus 304 on match
compress()hono/compressgzip/deflate via CompressionStream
timeout(5000)hono/timeout504 when the handler is slower
bodyLimit({ maxSize: 1 << 20 })hono/body-limit413 on larger bodies
requestId()hono/request-idc.var.requestId, X-Request-Id header
prettyJSON(), timing(), cache()hono/pretty-json, hono/timing, hono/cache?pretty, Server-Timing, Cache API
ipRestriction(getConnInfo, rules)hono/ip-restrictionallow/deny lists
serveStatic({ root: "./public" })hono/bunstatic files on Bun
every, some, excepthono/combinecompose middleware

Cookies: getCookie, setCookie, deleteCookie, getSignedCookie, setSignedCookie from hono/cookie.

src/middleware/auth.ts
import { createMiddleware } from "hono/factory";
import { HTTPException } from "hono/http-exception";
 
type User = { id: string; role: "admin" | "user" };
declare function findSession(
  token: string,
): Promise<User | null>;
 
export const requireUser = createMiddleware<{
  Variables: { user: User };
}>(async (c, next) => {
  const token = c.req.header("authorization")?.slice(7);
  const user = token ? await findSession(token) : null;
  if (!user) throw new HTTPException(401);
  c.set("user", user);           // typed for later handlers
  await next();
});

createFactory<AppEnv>() from hono/factory gives createApp, createMiddleware and createHandlers that share one Env type.

Validation

TargetValidates
jsonbody; needs Content-Type: application/json
formmultipart/form-data or urlencoded body
query, param, header, cookiethe matching request part (header names lowercase)
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
 
const ListQuery = z.object({
  page: z.coerce.number().int().min(1).default(1),
  tag: z.string().optional(),
});
 
const app = new Hono().get(
  "/items",
  zValidator("query", ListQuery, (result, c) => {
    if (!result.success) {
      return c.json({ error: result.error.issues }, 400);
    }
  }),
  (c) => {
    const { page, tag } = c.req.valid("query");  // typed
    return c.json({ page, tag });
  },
);
PackageWorks with
@hono/zod-validator → zValidatorZod 3 and 4
@hono/standard-validator → sValidatorany Standard Schema lib: Zod, Valibot, ArkType
validator(target, fn) from hono/validatorhand-written checks; return data or a Response

Without a hook, a failed validation returns 400 with the error details. With several validators, the body is still parsed only once.

Error handling

import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
 
const app = new Hono();
 
app.get("/admin", () => {
  throw new HTTPException(403, { message: "Admins only" });
});
 
app.notFound((c) => c.json({ error: "Not found" }, 404));
 
app.onError((err, c) => {
  if (err instanceof HTTPException) return err.getResponse();
  console.error(err);
  return c.json({ error: "Internal error" }, 500);
});
APINotes
new HTTPException(status, { message, res, cause })res overrides the whole response
err.getResponse()plain-text response with the status (or res)
app.onError((err, c) => Response)catches everything thrown in handlers and middleware
app.notFound((c) => Response)only on the top-level app
c.errorafter await next(), middleware can inspect what was thrown

RPC client

hc<AppType>() from hono/client turns the server's route types into a typed fetch client. The client needs only import type, so no server code ships.

web/api.ts
import { hc } from "hono/client";
import type { InferResponseType } from "hono/client";
import type { AppType } from "../src/app";
 
const client = hc<AppType>("http://localhost:3000");
 
const res = await client.api.books[":id"].$get({
  param: { id: "7" },
});
if (res.ok) {
  const book = await res.json();         // { id: number }
}
 
type Books = InferResponseType<
  typeof client.api.books.$get
>;                                  // { books: string[] }
const url = client.api.books.$url();      // URL object
RuleWhy
Chain .get().post() and .route() calls; export typeof appunchained calls drop the route types
c.json(body, 404) with explicit statusesres.status then narrows res.json()
{ json }, { form }, { query }, { param }, { header }request args mirror the validators
parseResponse(client.x.$get())parses by Content-Type, throws DetailedError on non-2xx
hc(url, { headers, fetch, init })auth headers, custom fetch, credentials
strict: true in both tsconfigsotherwise inference degrades to any
Large apps: compile hc<typeof app> in a build stepkeeps editor type checking fast

JSX, streaming & SSE

src/pages.tsx
import { Hono } from "hono";
import type { FC } from "hono/jsx";
 
const Layout: FC = (props) => (
  <html>
    <body>{props.children}</body>
  </html>
);
 
const app = new Hono();
 
app.get("/hello/:name", (c) =>
  c.html(
    <Layout>
      <h1>Hello {c.req.param("name")}</h1>
    </Layout>,
  ),
);
HelperImportDoes
jsxRenderer(Layout) + c.render(jsx)hono/jsx-renderershared layout per route group
Suspense, renderToReadableStreamhono/jsx/streamingstream async components
html`...`, raw(s)hono/htmltagged template with escaping
stream(c, async (s) => ...)hono/streamingraw bytes: s.write, s.pipe(readable)
streamText(c, cb)hono/streamingtext/plain chunks: s.writeln
streamSSE(c, cb)hono/streamings.writeSSE({ data, event, id, retry })
s.onAbort(fn), s.sleep(ms), s.abortedall streamscleanup when the client leaves

hono/jsx/dom is a small client-side React-like renderer. SSE and chunked details are in Streaming.

WebSockets on Bun

src/ws.ts
import { Hono } from "hono";
import { upgradeWebSocket, websocket } from "hono/bun";
 
const app = new Hono();
 
app.get(
  "/ws",
  upgradeWebSocket((c) => ({
    onOpen(_evt, ws) {
      ws.send(`hi ${c.req.query("name") ?? "anon"}`);
    },
    onMessage(evt, ws) {
      ws.send(`echo: ${String(evt.data)}`);
    },
    onClose() {
      console.log("closed");
    },
  })),
);
 
export default { fetch: app.fetch, websocket };

Export websocket next to fetch, or Bun can't upgrade. Middleware that rewrites headers (for example cors) clashes with the upgrade, so keep it off /ws. Node uses upgradeWebSocket from @hono/node-server with ws. The RPC client connects with client.ws.$ws(). Protocol details are in WebSockets.

Testing

test/books.test.ts
import { describe, expect, it } from "bun:test";
import { testClient } from "hono/testing";
import app from "../src/app";
 
describe("books", () => {
  it("lists", async () => {
    const res = await app.request("/api/books");
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ books: [] });
  });
 
  it("creates", async () => {
    const res = await app.request("/api/books", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "Dune" }),
    });
    expect(res.status).toBe(201);
  });
 
  it("typed client", async () => {
    const client = testClient(app);
    const res = await client.api.books.$get();
    expect(res.ok).toBe(true);
  });
});
  • app.request(pathOrRequest, init?, env?) runs the app in-process, with no port. The third argument mocks Bindings.
  • testClient(app) is hc wired to app.request, so tests get the same types as clients.
  • Run with bun test. See Testing.

Deploying

TargetSetupRun
Bunexport default app or Bun.servebun run src/index.ts; bun build src/index.ts --target=bun to bundle
Nodeserve({ fetch: app.fetch, port }) from @hono/node-servernode dist/index.js (build with tsc or tsdown)
Cloudflare Workersbun create hono → cloudflare-workers, wrangler.jsoncbunx wrangler dev, bunx wrangler deploy
Vercelexport default app in src/index.tsvercel deploy
Dockeroven/bun image, CMD ["bun", "src/index.ts"]see Dockerfile
src/node.ts
import { serve } from "@hono/node-server";
import app from "./app";
 
const server = serve(
  { fetch: app.fetch, port: 3000 },
  (info) => console.log(`listening on :${info.port}`),
);
 
process.on("SIGTERM", () => server.close());

Workers bindings: type them in Bindings (or run wrangler types) and read them with c.env.MY_KV. There is no process.env by default.

Project layout

Hono API on Bun
my-api/src/index.ts        # Bun entry: Bun.serve + shutdownapp.ts          # builds the app, exports AppTypeenv.ts          # Zod-parsed process.envtypes.ts        # AppEnv: Bindings + Variablesroutes/books.ts    # new Hono() sub-app, chainedusers.tsauth.ts     # login, refreshmiddleware/auth.ts     # createMiddleware + typed Variablesrate-limit.tslib/db.tserrors.ts   # HTTPException helpersviews/layout.tsx  # hono/jsx, if you render HTMLtest/books.test.ts   # app.request / testClientbunfig.tomltsconfig.json       # strict, jsxImportSource hono/jsxpackage.json
  • One sub-app per resource, mounted with app.route() in app.ts. Keep index.ts runtime-specific so app.ts stays portable and testable.
  • Put the shared AppEnv in types.ts and use new Hono<AppEnv>() everywhere, so c.var and c.env agree across files.
  • In a monorepo, the frontend imports only type AppType (see pnpm monorepos).

Recipes

CRUD API with Zod validation

Use as the skeleton for a resource router.

src/routes/todos.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
 
const Input = z.object({
  title: z.string().min(1),
  done: z.boolean().default(false),
});
type Todo = z.infer<typeof Input> & { id: string };
const db = new Map<string, Todo>();
const save = (t: Todo) => { db.set(t.id, t); return t; };
const body = zValidator("json", Input);
 
export const todos = new Hono()
  .get("/", (c) => c.json([...db.values()]))
  .post("/", body, (c) => {
    const id = crypto.randomUUID();
    return c.json(save({ ...c.req.valid("json"), id }), 201);
  })
  .put("/:id", body, (c) => {
    const id = c.req.param("id");
    return c.json(save({ ...c.req.valid("json"), id }), 200);
  })
  .delete("/:id", (c) =>
    c.body(null, db.delete(c.req.param("id")) ? 204 : 404));

JWT-protected routes

Use for stateless API auth with a short-lived access token.

src/routes/auth.ts
import { Hono } from "hono";
import { jwt, sign } from "hono/jwt";
import type { JwtVariables } from "hono/jwt";
 
const SECRET = process.env.JWT_SECRET ?? "";
if (SECRET.length < 32) throw new Error("weak JWT_SECRET");
const app = new Hono<{ Variables: JwtVariables }>();
 
app.post("/login", async (c) => {
  const { email } = await c.req.json<{ email: string }>();
  // ...verify the password here
  const exp = Math.floor(Date.now() / 1000) + 15 * 60;
  const claims = { sub: email, exp };
  const token = await sign(claims, SECRET, "HS256");
  return c.json({ token });
});
 
app.use("/api/*", jwt({ secret: SECRET, alg: "HS256" }));
 
app.get("/api/me", (c) => {
  const payload = c.get("jwtPayload");  // verified claims
  return c.json({ sub: payload.sub });
});
 
export default app;

Typed RPC client

Use when a TypeScript frontend calls your Hono API.

web/books.ts
import { hc, type InferRequestType } from "hono/client";
import type { AppType } from "../src/app";
 
const api = hc<AppType>("/", {
  headers: () => ({ authorization: `Bearer ${getToken()}` }),
}).api;
 
type NewBook = InferRequestType<typeof api.books.$post>;
 
export async function getBook(id: number) {
  const res = await api.books[":id"].$get({
    param: { id: String(id) },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();                   // typed body
}
 
declare function getToken(): string;

Server-Sent Events stream

Use to push live updates (progress, notifications) over plain HTTP.

src/routes/events.ts
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
 
export const events = new Hono().get("/clock", (c) =>
  streamSSE(c, async (stream) => {
    let id = 0;
    stream.onAbort(() => console.log("client left"));
    while (!stream.aborted) {
      await stream.writeSSE({
        event: "tick",
        id: String(id++),
        data: JSON.stringify({ now: Date.now() }),
      });
      await stream.sleep(1000);
    }
  }),
);
// browser: new EventSource("/clock").addEventListener(
//   "tick", (e) => console.log(e.data))

Graceful shutdown on Bun

Use in containers so deploys don't cut off in-flight requests.

src/index.ts
import app from "./app";
 
const server = Bun.serve({
  port: Number(process.env.PORT ?? 3000),
  fetch: app.fetch,
});
 
let closing = false;
async function shutdown(signal: string) {
  if (closing) return;
  closing = true;
  console.log(`${signal}: draining`);
  // stop accepting, wait for in-flight requests
  await server.stop();
  // await db.end(); flush logs, close queues...
  process.exit(0);
}
 
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));

Rate limit middleware

Use for a fixed-window limit on one instance behind a proxy that sets X-Forwarded-For. Without a proxy, key on getConnInfo(c).remote.address from hono/bun. With several instances, keep the counters in Redis.

src/middleware/rate-limit.ts
import { createMiddleware } from "hono/factory";
 
type Bucket = { count: number; reset: number };
export function rateLimit(limit: number, windowMs: number) {
  const buckets = new Map<string, Bucket>();
  return createMiddleware(async (c, next) => {
    const fwd = c.req.header("x-forwarded-for");
    const key = fwd?.split(",")[0]?.trim() || "anon";
    const now = Date.now();
    const old = buckets.get(key);
    const b = old && old.reset > now
      ? { ...old, count: old.count + 1 }
      : { count: 1, reset: now + windowMs };
    buckets.set(key, b);
    const left = Math.max(0, limit - b.count);
    c.header("RateLimit-Limit", String(limit));
    c.header("RateLimit-Remaining", String(left));
    if (b.count > limit) {
      const secs = Math.ceil((b.reset - now) / 1000);
      c.header("Retry-After", String(secs));
      return c.json({ error: "Too many requests" }, 429);
    }
    await next();
  });
}

References