Hono on the Edge skill for Claude Code
One typed API for Workers, Bun, Deno and Node. Claude writes Hono apps against Web standard APIs so they run on Workers, Bun, Deno or Node, with Bindings and Variables typed from the start. Inputs pass through Zod or Standard Schema validators, errors flow through one onError handler, and routes are chained so the exported AppType gives the frontend a fully typed hc client without hand-written fetch wrappers.
- name
hono-edge - size201 lines · ≈1,900 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds Hono v4 APIs that run on Cloudflare Workers, Bun, Deno, Node and Vercel - typed Bindings and Variables, routing and sub-apps, built-in and custom middleware, Zod or Standard Schema validators, error handling with HTTPException, the type-safe RPC client, and tests with app.request. Use when starting a Hono project, porting an Express app to an edge runtime, sharing API types with a frontend through hc, or debugging Hono middleware, validation or RPC type inference. Try asking
- Build a Hono API on Workers with D1 and JWT auth
- My hc client types are all unknown. Fix the route definitions
- Port this small Express service to Hono so it runs on Bun
Use it when
- Starting an API on Workers, Bun, Deno or another edge runtime
- Sharing request and response types with a frontend through hc
- Adding validation, auth or CORS middleware to a Hono app
- Porting Express routes to Web standard Request and Response
Skip it when
- The question is Workers platform config, bindings or limits — use cloudflare-workers
- The service is a long-running Node app on Express — use express-node
How to check the result
app.request tests cover success and failure paths, tsc passes in server and client packages, and the app answers correctly under the target runtime's local dev server.
The common mistake it prevents
Registering routes in separate statements instead of chaining them, which silently removes them from the AppType the RPC client depends on.
A good Hono app is written once against Web standard Request/Response, declares its
environment types up front so c.env and c.var are checked, validates every input with
a validator middleware, and exports a route type the frontend consumes through hc
without duplicating schemas.
Workflow
- [ ] 1. Identify runtime(s): Workers, Bun, Deno, Node (@hono/node-server), Vercel, Lambda
- [ ] 2. Define Env type: Bindings (platform resources) and Variables (per-request values)
- [ ] 3. Structure routes as sub-apps mounted with app.route()
- [ ] 4. Add middleware in order: request ID/logger -> CORS -> security headers -> auth
- [ ] 5. Validate params/query/json with validators; read via c.req.valid()
- [ ] 6. Centralize errors in app.onError and app.notFound
- [ ] 7. Export AppType for hc; test with app.request()
Entry points per runtime
| Runtime | Entry |
|---|---|
| Cloudflare Workers | export default app (bindings arrive as c.env) |
| Bun | export default app or export default { port: 3000, fetch: app.fetch } |
| Deno | Deno.serve(app.fetch) |
| Node.js | import { serve } from "@hono/node-server"; serve({ fetch: app.fetch, port: 3000 }) |
| Vercel / Next.js route | import { handle } from "hono/vercel"; export const GET = handle(app) |
| AWS Lambda | import { handle } from "hono/aws-lambda"; export const handler = handle(app) |
Avoid Node-only APIs (fs, process.env, Buffer without nodejs_compat) in shared
code. On Workers, configuration comes from bindings in c.env, not process.env.
Typed environment and structure
import { Hono } from "hono";
type Env = {
Bindings: { DB: D1Database; KV: KVNamespace; JWT_SECRET: string };
Variables: { userId: string; requestId: string };
};
const app = new Hono<Env>().basePath("/api");
const orders = new Hono<Env>()
.get("/", async (c) => {
const { results } = await c.env.DB.prepare("SELECT id, total FROM orders WHERE user_id = ?")
.bind(c.var.userId).all();
return c.json({ data: results });
})
.get("/:id", (c) => c.json({ id: c.req.param("id") }));
const routes = app.route("/orders", orders);
export type AppType = typeof routes;
export default app;
For RPC type inference, define routes by chaining on the same instance (or on sub-apps
mounted via app.route). Routes added in separate statements (app.get(...); app.post(...))
still work at runtime but drop out of the inferred type.
Middleware
import { cors } from "hono/cors";
import { secureHeaders } from "hono/secure-headers";
import { requestId } from "hono/request-id";
import { logger } from "hono/logger";
import { bodyLimit } from "hono/body-limit";
import { jwt } from "hono/jwt";
import { createMiddleware } from "hono/factory";
app.use(requestId());
app.use(logger());
app.use("/api/*", cors({ origin: ["https://app.example.com"], credentials: true }));
app.use(secureHeaders());
app.use("/api/*", bodyLimit({ maxSize: 100 * 1024 }));
app.use("/api/*", (c, next) => jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next));
const requireUser = createMiddleware<Env>(async (c, next) => {
const payload = c.get("jwtPayload") as { sub?: string } | undefined;
if (!payload?.sub) return c.json({ error: "unauthorized" }, 401);
c.set("userId", payload.sub);
await next();
});
Middleware runs in registration order before await next() and in reverse after it.
Always await next(); forgetting await makes post-processing run before the handler.
Bindings are only available per request, which is why the JWT middleware above is built
inside a handler that reads c.env.
Validation
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const createOrder = z.object({ sku: z.string().min(1), quantity: z.number().int().min(1).max(100) });
orders.post(
"/",
zValidator("json", createOrder, (result, c) => {
if (!result.success) return c.json({ error: "validation", issues: result.error.issues }, 422);
}),
async (c) => {
const body = c.req.valid("json"); // typed, validated
return c.json({ ok: true, body }, 201);
},
);
Targets: json, form, query, param, header, cookie. For Valibot, ArkType or any
Standard Schema library use @hono/standard-validator. JSON validation requires the
client to send Content-Type: application/json, otherwise the body is treated as empty.
Errors
import { HTTPException } from "hono/http-exception";
app.onError((err, c) => {
if (err instanceof HTTPException) return err.getResponse();
console.error(JSON.stringify({ requestId: c.var.requestId, msg: err.message, stack: err.stack }));
return c.json({ error: "internal", requestId: c.var.requestId }, 500);
});
app.notFound((c) => c.json({ error: "not_found" }, 404));
// in handlers
throw new HTTPException(403, { message: "forbidden" });
RPC client
import { hc } from "hono/client";
import type { AppType } from "../server/src/index";
const client = hc<AppType>("https://api.example.com/api", { headers: { Authorization: `Bearer ${token}` } });
const res = await client.orders.$post({ json: { sku: "A1", quantity: 2 } });
if (res.ok) {
const data = await res.json(); // typed from the handler's c.json(...)
}
Return explicit status codes in c.json(body, status) so the client can narrow on
res.status. In monorepos, enable TypeScript strict in both packages and keep the
same hono version, or inference degrades to unknown. For large apps, precompile the
client type (export type Client = ReturnType<typeof hc<AppType>>) to keep the IDE fast.
Background work and streaming
- Workers:
c.executionCtx.waitUntil(promise)for work after the response (analytics, cache writes). Not for anything that must retry; use Queues for that. - Streaming:
import { streamSSE } from "hono/streaming"andreturn streamSSE(c, async (s) => { await s.writeSSE({ data }) }).
Tests
import { describe, expect, it } from "vitest";
import app from "./index";
describe("orders", () => {
it("rejects invalid body", async () => {
const res = await app.request("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
body: JSON.stringify({ sku: "" }),
}, { DB: fakeDb, KV: fakeKv, JWT_SECRET: "test" }); // third arg = Bindings
expect(res.status).toBe(422);
});
});
For Workers-specific bindings in tests use @cloudflare/vitest-pool-workers, which runs
tests inside the Workers runtime with real local D1/KV/R2.
Gotchas
c.req.json()can be read once; validators already consume it, so usec.req.valid.- Route order matters for overlapping patterns; specific routes before wildcards.
cors()must be registered before routes it applies to, including OPTIONS handling.- Global mutable state persists across requests within an isolate on Workers but is not shared between isolates; never use it as a cache you rely on.
- Large dependencies inflate Worker bundle size and cold start; check with
wrangler deploy --dry-run --outdir distand inspect output size.
Verification
- Tests via
app.requestcover success, 401, 422, 404 and 500 paths. tsc --noEmitpasses in both server and client packages; hoveringclient.orders.$postshows typed input and output.- Run on the target runtime locally (
wrangler dev,bun run,node) andcurlthe endpoints, checking CORS preflight withcurl -X OPTIONS -H "Origin: ..." -i.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.