Backend & APIs No. 088

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.

  • namehono-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.

~/.claude/skills/hono-edge/SKILL.md

--- name: hono-edge description: Builds Hono v4 APIs that run on Cloudflare Workers, Bun, Deno, Node and Vercel - typed Bindings and Variables, routing and sub-apps,… license: MIT ---

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

RuntimeEntry
Cloudflare Workersexport default app (bindings arrive as c.env)
Bunexport default app or export default { port: 3000, fetch: app.fetch }
DenoDeno.serve(app.fetch)
Node.jsimport { serve } from "@hono/node-server"; serve({ fetch: app.fetch, port: 3000 })
Vercel / Next.js routeimport { handle } from "hono/vercel"; export const GET = handle(app)
AWS Lambdaimport { 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" and return 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 use c.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 dist and inspect output size.

Verification

  1. Tests via app.request cover success, 401, 422, 404 and 500 paths.
  2. tsc --noEmit passes in both server and client packages; hovering client.orders.$post shows typed input and output.
  3. Run on the target runtime locally (wrangler dev, bun run, node) and curl the endpoints, checking CORS preflight with curl -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.

esc