Hono API Development
Route chaining → type-safe RPC → end-to-end types.
- Building REST APIs with Hono
- Type-safe RPC with hono/client
- OpenAPI documentation with Zod
- Testing APIs with testClient
- When user mentions "Hono", "RPC", or "OpenAPI"
NOT for: Bun runtime APIs (use bun-fieldguide), other frameworks (Express, Fastify)
Hono v4+ with @hono/zod-openapi v1.0+ Check hono.dev for latest patterns.
Route Chaining — Critical Pattern
Type inference flows through method chain. Break chain = lose types.
// ✅ Chained routes preserve types
const app = new Hono()
.get("/users", (c) => c.json({ users: [] }))
.get("/users/:id", (c) => {
const id = c.req.param("id"); // Typed!
return c.json({ id });
})
.post("/users", async (c) => {
const body = await c.req.json();
return c.json({ created: true }, 201);
});
export type AppType = typeof app; // Full route types!
❌ NEVER break the chain:
const app = new Hono();
app.get("/users", handler1); // Types LOST!
app.post("/users", handler2);
Path parameters — typed automatically:
.get('/posts/:id/comments/:commentId', (c) => {
const { id, commentId } = c.req.param(); // Both string
return c.json({ postId: id, commentId });
})
Query parameters — use Zod for validation:
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const QuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
const app = new Hono().get("/search", zValidator("query", QuerySchema), (c) => {
const { page, limit } = c.req.valid("query"); // Fully typed!
return c.json({ page, limit });
});
Middleware in chain:
const app = new Hono()
.use("*", logger())
.use("/api/*", cors())
.get("/api/public", (c) => c.json({ public: true }))
.use("/api/admin/*", authMiddleware)
.get("/api/admin/users", (c) => c.json({ users: [] }));
Factory Pattern — Context Typing
Use createFactory<Env>() to type context variables across middleware and routes.
import { createFactory } from "hono/factory";
import type { Database } from "bun:sqlite";
type Env = {
Variables: {
user: { id: string; role: "admin" | "user" };
requestId: string;
db: Database;
};
};
const factory = createFactory<Env>();
// Typed middleware
const authMiddleware = factory.createMiddleware(async (c, next) => {
const token = c.req.header("authorization")?.replace("Bearer ", "");
if (!token) throw new HTTPException(401, { message: "Unauthorized" });
const user = await verifyToken(token);
c.set("user", user); // Type-checked!
await next();
});
// Typed handlers
const getProfile = factory.createHandlers((c) => {
const user = c.get("user"); // Typed: { id: string; role: 'admin' | 'user' }
return c.json({ user });
});
// Assemble app
const app = factory
.createApp()
.use("*", dbMiddleware)
.use("/api/*", authMiddleware)
.get("/api/profile", ...getProfile);
export type AppType = typeof app;
Multi-module structure:
// routes/users.ts
export const usersRoute = factory
.createApp()
.get("/", (c) => c.json({ users: [] }))
.post("/", zValidator("json", CreateUserSchema), async (c) => {
const data = c.req.valid("json");
return c.json({ created: true }, 201);
});
// index.ts
const app = factory
.createApp()
.use("*", dbMiddleware)
.route("/users", usersRoute)
.route("/posts", postsRoute);
See factory-pattern.md for advanced patterns.
Error Handling
import { HTTPException } from "hono/http-exception";
// Throw typed errors
app.get("/users/:id", async (c) => {
const user = await findUser(c.req.param("id"));
if (!user) {
throw new HTTPException(404, { message: "User not found" });
}
return c.json({ user });
});
// Custom error classes
class NotFoundError extends HTTPException {
constructor(resource: string) {
super(404, { message: `${resource} not found` });
}
}
class UnauthorizedError extends HTTPException {
constructor(message = "Unauthorized") {
super(401, { message });
}
}
// Centralized handler
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status);
}
if (err instanceof ZodError) {
return c.json(
{
error: "Validation failed",
issues: err.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
},
400
);
}
const isDev = Bun.env.NODE_ENV !== "production";
return c.json({ error: isDev ? err.message : "Internal server error" }, 500);
});
app.notFound((c) => c.json({ error: "Not found", path: c.req.path }, 404));
See error-handling.md for patterns.
Zod OpenAPI
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { swaggerUI } from "@hono/swagger-ui";
const UserSchema = z
.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(100),
})
.openapi("User");
const route = createRoute({
method: "get",
path: "/users/{id}",
request: {
params: z.object({ id: z.string().uuid() }),
},
responses: {
200: {
content: { "application/json": { schema: UserSchema } },
description: "User found",
},
404: {
content: {
"application/json": { schema: z.object({ error: z.string() }) },
},
description: "User not found",
},
},
tags: ["Users"],
summary: "Get user by ID",
});
const app = new OpenAPIHono();
app.openapi(route, (c) => {
const { id } = c.req.valid("param"); // Typed!
const user = db.query("SELECT * FROM users WHERE id = ?").get(id);
if (!user) return c.json({ error: "User not found" }, 404);
return c.json(user, 200);
});
// Swagger UI
app.get("/docs", swaggerUI({ url: "/openapi.json" }));
app.doc("/openapi.json", {
openapi: "3.1.0",
info: { title: "API", version: "1.0.0" },
});
See zod-openapi.md for complete patterns.
RPC Client — End-to-End Types
// Server
const app = new Hono()
.get("/posts", (c) => c.json({ posts: [] }))
.get("/posts/:id", (c) => c.json({ id: c.req.param("id") }))
.post("/posts", zValidator("json", CreatePostSchema), async (c) => {
const data = c.req.valid("json");
return c.json({ id: "123", ...data }, 201);
});
export type AppType = typeof app;
// Client
import { hc } from "hono/client";
import type { AppType } from "./server";
const client = hc<AppType>("http://localhost:3000");
// GET request
const res = await client.posts.$get();
const data = await res.json(); // Typed: { posts: any[] }
// GET with params
const res2 = await client.posts[":id"].$get({ param: { id: "123" } });
// POST request
const res3 = await client.posts.$post({
json: { title: "Hello", content: "World" },
});
// With headers
const res4 = await client.posts.$get(
{},
{
headers: { Authorization: "Bearer token" },
}
);
Testing with testClient
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { testClient } from "hono/testing";
import { Database } from "bun:sqlite";
import app from "./server";
describe("API Tests", () => {
let db: Database;
beforeEach(() => {
db = new Database(":memory:");
db.run(
"CREATE TABLE posts (id TEXT PRIMARY KEY, title TEXT, content TEXT)"
);
});
afterEach(() => {
db.close();
});
const client = testClient(app);
test("GET /posts returns posts", async () => {
const res = await client.posts.$get();
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty("posts");
});
test("POST /posts creates post", async () => {
const res = await client.posts.$post({
json: { title: "Test", content: "Content" },
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data).toMatchObject({ title: "Test" });
});
test("Protected route requires auth", async () => {
const res = await client.api.profile.$get();
expect(res.status).toBe(401);
});
test("Protected route accepts valid token", async () => {
const res = await client.api.profile.$get(
{},
{
headers: { Authorization: "Bearer valid-token" },
}
);
expect(res.status).toBe(200);
});
});
See testing-patterns.md for complete patterns.
Middleware Patterns
// Logging
import { logger } from "hono/logger";
app.use("*", logger());
// CORS
import { cors } from "hono/cors";
app.use(
"/api/*",
cors({
origin: ["http://localhost:3000"],
credentials: true,
})
);
// Rate limiting
const rateLimiter = factory.createMiddleware(async (c, next) => {
const ip = c.req.header("x-forwarded-for") || "unknown";
const key = `rate:${ip}`;
const count = await cache.incr(key);
if (count === 1) await cache.expire(key, 60);
if (count > 100) {
throw new HTTPException(429, { message: "Rate limit exceeded" });
}
await next();
});
// Request ID
const requestId = factory.createMiddleware(async (c, next) => {
c.set("requestId", crypto.randomUUID());
await next();
c.res.headers.set("x-request-id", c.get("requestId"));
});
Rules
ALWAYS:
- Chain routes for type inference →
.get().post().put() - Export
type AppType = typeof appfor RPC client - Use
createFactory<Env>()for typed context variables - Validate with Zod schemas via
zValidator - Handle errors with
HTTPExceptionand centralizedonError - Test with
testClientfor type safety
NEVER:
- Break method chain with variable assignment between routes
- Use
anytypes — let Hono infer or define explicitly - Use
JSON.parse(await c.req.text())— usec.req.json()or Zod validator - Skip request validation on user input
- Expose stack traces in production
When Type Errors Occur:
- Check route chaining not broken
- Verify
export type AppTypematches actual app - Ensure middleware uses
createFactoryfor context types - Check client using correct
param,query, orjsonkeys
References
Examples:
- typed-routes.md — Complete route chaining examples
- testing-patterns.md — Testing with testClient
References:
- factory-pattern.md — Context typing with createFactory
- zod-openapi.md — OpenAPI integration patterns
- error-handling.md — HTTPException and error patterns
- middleware.md — Auth, logging, CORS patterns
External:
- Hono: https://hono.dev
- @hono/zod-openapi: https://github.com/honojs/middleware/tree/main/packages/zod-openapi