Hono is an edge-first web framework — hc<typeof app>(baseUrl) creates a type-safe RPC client from the server app type. app.get("/posts", zValidator("query", PostQuerySchema), (c) => ...) validates with Zod. streamText(c, async (stream) => { stream.write("data"); stream.close() }) streams responses. streamSSE(c, async (stream) => { await stream.writeSSE({ data, event, id }) }) sends Server-Sent Events. upgradeWebSocket((c) => ({ onOpen(_, ws) { ws.send("hello") }, onMessage(event, ws) { ws.send(event.data) } })) upgrades to WebSocket. Cloudflare Workers: c.env.DB for D1, c.env.KV for KV Namespace, c.env.R2 for R2 Bucket. createFactory<Env>() creates typed factory for reusable middleware. app.request("/path", { method: "POST", body }) tests handlers in-process without a network. Monorepo: export the AppType from the Hono app and import in the client. Claude Code generates Hono RPC clients, streaming endpoints, WebSocket handlers, and Cloudflare Workers integrations.
CLAUDE.md for Hono Advanced
## Hono Advanced Stack
- Version: hono >= 4.6
- RPC client: const client = hc<typeof app>("http://localhost:3000") — infers from app routes
- Typed client call: const res = await client.api.posts.$get({ query: { page: "1" } })
- Zod validator: app.post("/posts", zValidator("json", CreatePostSchema), (c) => { const body = c.req.valid("json") })
- Stream: import { streamText, streamSSE } from "hono/streaming"
- WebSocket: import { upgradeWebSocket } from "hono/cloudflare-workers"
- CF bindings: type Bindings = { DB: D1Database; KV: KVNamespace; BUCKET: R2Bucket }; const app = new Hono<{ Bindings: Bindings }>()
- Test: const res = await app.request("/api/posts", { method: "GET" })
Hono RPC App with Types
// src/index.ts — Hono app with RPC-compatible route definitions
import { Hono } from "hono"
import { zValidator } from "@hono/zod-validator"
import { jwt } from "hono/jwt"
import { cors } from "hono/cors"
import { logger } from "hono/logger"
import { timing } from "hono/timing"
import { z } from "zod"
// Cloudflare Workers environment bindings
type Bindings = {
DB: D1Database
KV: KVNamespace
BUCKET: R2Bucket
JWT_SECRET: string
ENVIRONMENT: string
}
// Variables available in handler context
type Variables = {
userId: string
jwtPayload: { sub: string; email: string; role: string }
}
// Schemas
const CreatePostSchema = z.object({
title: z.string().min(5).max(200),
content: z.string().min(20),
excerpt: z.string().max(500).optional(),
published: z.boolean().default(false),
})
const PostQuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(10),
search: z.string().optional(),
})
// ── Auth middleware ────────────────────────────────────────────────────────
const authMiddleware = jwt({
secret: (c) => c.env.JWT_SECRET,
alg: "HS256",
})
const setUser = async (c: any, next: () => Promise<void>) => {
const payload = c.get("jwtPayload")
c.set("userId", payload.sub)
await next()
}
// ── Posts routes ───────────────────────────────────────────────────────────
const posts = new Hono<{ Bindings: Bindings; Variables: Variables }>()
posts
.get("/", zValidator("query", PostQuerySchema), async (c) => {
const { page, pageSize, search } = c.req.valid("query")
const offset = (page - 1) * pageSize
const searchClause = search ? `AND title LIKE '%${search}%'` : ""
const { results } = await c.env.DB.prepare(
`SELECT * FROM posts WHERE 1=1 ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
).bind(pageSize, offset).all()
const { results: [countRow] } = await c.env.DB.prepare(
"SELECT COUNT(*) as total FROM posts",
).all()
return c.json({
posts: results,
total: (countRow as any)?.total ?? 0,
page,
pageSize,
})
})
.get("/:id", async (c) => {
const { id } = c.req.param()
const post = await c.env.DB.prepare(
"SELECT * FROM posts WHERE id = ?",
).bind(id).first()
if (!post) return c.json({ message: "Not found" }, 404)
return c.json(post)
})
.post(
"/",
authMiddleware,
setUser,
zValidator("json", CreatePostSchema),
async (c) => {
const body = c.req.valid("json")
const userId = c.get("userId")
const slug = body.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
const id = crypto.randomUUID()
await c.env.DB.prepare(
"INSERT INTO posts (id, title, slug, content, excerpt, published, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
).bind(id, body.title, slug, body.content, body.excerpt ?? null, body.published ? 1 : 0, userId, new Date().toISOString()).run()
const post = await c.env.DB.prepare("SELECT * FROM posts WHERE id = ?").bind(id).first()
return c.json(post, 201)
},
)
// ── Streaming route ────────────────────────────────────────────────────────
import { streamSSE } from "hono/streaming"
const stream = new Hono<{ Bindings: Bindings }>()
stream.get("/notifications/:userId", (c) => {
const userId = c.req.param("userId")
return streamSSE(c, async (stream) => {
// Poll KV for new notifications
let cursor = Date.now()
for (let i = 0; i < 30; i++) { // 30 second max connection
const key = `notifications:${userId}:${cursor}`
const value = await c.env.KV.get(key)
if (value) {
await stream.writeSSE({
data: value,
event: "notification",
id: String(cursor),
})
cursor = Date.now()
}
await stream.sleep(1000) // Poll every second
}
await stream.writeSSE({ data: "Connection timeout", event: "close", id: "close" })
})
})
// ── File upload with R2 ────────────────────────────────────────────────────
const uploads = new Hono<{ Bindings: Bindings; Variables: Variables }>()
uploads.post("/images", authMiddleware, setUser, async (c) => {
const formData = await c.req.formData()
const file = formData.get("file") as File | null
if (!file) return c.json({ message: "No file provided" }, 400)
if (!file.type.startsWith("image/")) return c.json({ message: "File must be an image" }, 400)
if (file.size > 5 * 1024 * 1024) return c.json({ message: "File too large (max 5MB)" }, 400)
const ext = file.name.split(".").pop() ?? "jpg"
const key = `images/${Date.now()}-${crypto.randomUUID()}.${ext}`
await c.env.BUCKET.put(key, file.stream(), {
httpMetadata: { contentType: file.type },
customMetadata: { uploadedBy: c.get("userId") },
})
const url = `https://${c.env.ENVIRONMENT === "production" ? "cdn.yourdomain.com" : "dev-cdn.yourdomain.com"}/${key}`
return c.json({ url, key }, 201)
})
// ── Main app ───────────────────────────────────────────────────────────────
const app = new Hono<{ Bindings: Bindings }>()
app
.use("*", logger())
.use("*", timing())
.use("/api/*", cors({ origin: "*", allowMethods: ["GET", "POST", "PATCH", "DELETE"] }))
.route("/api/posts", posts)
.route("/api/stream", stream)
.route("/api/upload", uploads)
.get("/health", (c) => c.json({ ok: true }))
.notFound((c) => c.json({ message: "Not found" }, 404))
.onError((err, c) => {
console.error(err)
return c.json({ message: "Internal server error" }, 500)
})
export default app
// Export type for RPC client
export type AppType = typeof app
Hono RPC Client
// lib/api/client.ts — type-safe Hono RPC client
import { hc } from "hono/client"
import type { AppType } from "../../server/src/index"
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8787"
export const client = hc<AppType>(BASE_URL, {
headers: () => {
const token = typeof window !== "undefined"
? localStorage.getItem("access_token")
: null
return token ? { Authorization: `Bearer ${token}` } : {}
},
})
// Usage — fully typed:
// const res = await client.api.posts.$get({ query: { page: "1", pageSize: "10" } })
// const data = await res.json()
// data.posts ← Post[]
//
// const res = await client.api.posts.$post({ json: { title, content } })
Testing with app.request
// src/index.test.ts — Hono in-process testing
import { describe, it, expect, beforeEach } from "vitest"
import app from "./index"
import type { ExecutionContext } from "@cloudflare/workers-types"
// Mock Cloudflare Workers environment
const mockEnv = {
DB: {
prepare: (query: string) => ({
bind: (..._args: any[]) => ({
all: async () => ({ results: [] }),
first: async () => null,
run: async () => ({ success: true }),
}),
}),
} as unknown as D1Database,
KV: {} as KVNamespace,
BUCKET: {} as R2Bucket,
JWT_SECRET: "test-secret-key-min-32-characters-long",
ENVIRONMENT: "test",
}
describe("GET /health", () => {
it("returns ok", async () => {
const res = await app.request("/health", {}, mockEnv)
expect(res.status).toBe(200)
const body = await res.json() as { ok: boolean }
expect(body.ok).toBe(true)
})
})
describe("GET /api/posts", () => {
it("returns paginated posts", async () => {
const res = await app.request("/api/posts?page=1&pageSize=5", {}, mockEnv)
expect(res.status).toBe(200)
const body = await res.json() as { posts: unknown[]; total: number }
expect(Array.isArray(body.posts)).toBe(true)
expect(typeof body.total).toBe("number")
})
})
For the Bun + Elysia alternative when running on Bun runtime and end-to-end type safety with Eden Treaty client (instead of Hono’s hc RPC) is needed along with maximum throughput on the Bun runtime — Elysia is purpose-built for Bun while Hono supports every runtime from Cloudflare Workers to Bun to Node.js, see the Elysia.js guide. For the tRPC with Hono alternative when a tRPC adapter for Hono is used for RPC-style calls within a Hono app — this combines Hono’s edge performance with tRPC’s end-to-end TypeScript, useful when migrating from Express+tRPC to an edge-deployable server, see the tRPC guide. The Claude Skills 360 bundle includes Hono advanced skill sets covering RPC clients, streaming, and Cloudflare Workers bindings. Start with the free tier to try edge-native API generation.