Server
API Handlers
An HTTP endpoint is a .ts file whose name matches the HTTP method (lowercase), living in the folder of the URL it serves.
Every handler file exports exactly two things:
schema— flat Zod shape for the request (optional).default— the async handler function.
// app/orders/[id:uuid]/get.ts — thin: validate, call the repository
import { getOrder } from "../repository"; // throws HTTPException(404) → { error, message, code }
export const schema = {}; // empty — id is auto-injected from [id:uuid]
export default async function handler(c) {
const { id } = c.req.valid("param");
return getOrder(id);
}Handlers call the repository — db.select/insert/update/delete/transaction in get.ts/post.ts/… or *.cron.ts gets a warning (code.no-inline-drizzle-in-handler; it does not block deploy). Queries and business rules live in app/<resource>/repository.ts (see Examples) so cron jobs and other handlers reuse them.
Schema shape
Flat, minimal, 2 keys max. The framework auto-fills everything else.
export const schema = {
body?: z.object({ ... }), // POST/PUT/PATCH payload
query?: z.object({ ... }), // ?page=1&search=foo
};Do NOT write:
- ❌
request: { ... }— no wrapper, flat keys only - ❌
params: .../param: ...— auto-injected from[brackets]in filepath - ❌
responses: ...— framework generates 200/400/500 defaults - ❌
summary,tags— auto-generated from filepath - ❌
body: { content: { "application/json": { schema: ... } } }— just put the zod object directly
If the endpoint needs no body/query and has no [brackets], write export const schema = {}; or omit schema entirely.
What the framework auto-fills
| From | Injected into the route |
|---|---|
Filepath [id:uuid] | params: z.object({ id: z.string().uuid() }) |
Filepath [slug:slug] | params: z.object({ slug: z.string().regex(...) }) |
Filepath [n:number] / [n:int] | params: z.object({ n: z.coerce.number().int() }) |
Filepath [x] (no type) | params: z.object({ x: z.string() }) |
body: z.object(...) | Wrapped in content: { "application/json": { schema } } |
| URL path | summary + OpenAPI tags |
| HTTP method + handler | Default 200 response |
Handler Return Conventions
The return value is automatically serialized to JSON. Never call c.json(...) or construct a Response manually.
| Intent | How |
|---|---|
| Success (200) | return data; |
| Custom status | c.status(201); return data; — set status before return |
| Business error | throw new HTTPException(404, { message: "...", cause: { code: "NOT_FOUND" } }) (from hono/http-exception, anywhere incl. repository) → { error, message, code } |
| No content (204) | c.status(204); return null; |
| Validation error | Handled by schema — handler is never invoked (auto 400) |
| Unhandled throw | Framework catches → 500 { message: "Internal Server Error" } (logged) |
Rules:
- ✅
return { ok: true }; - ✅
c.status(201); return created; - ❌
return c.json({ ok: true });— never usec.json() - ❌
return new Response(...);— never constructResponse - ❌
res.send(...)— this is Hono, not Express
Validated Input
All input must go through c.req.valid(...). Reading c.req.param() / c.req.query() / c.req.json() directly bypasses validation and is not supported.
const { id } = c.req.valid("param"); // auto-injected from filepath [brackets]
const query = c.req.valid("query"); // from schema.query
const body = c.req.valid("json"); // from schema.body
const headers = c.req.valid("header"); // from schema.headersNote the slot is always "param" (singular) even when the filepath has multiple brackets.
Examples
List with query
// app/orders/repository.ts — every DB call + business rule lives here (handlers never call db.*)
import { HTTPException } from "hono/http-exception";
import { db, eq, desc, count } from "app/db";
import { orders } from "./orders.table";
export async function listOrders(q: { page: number; pageSize: number }) {
const [data, [{ total }]] = await Promise.all([
db.select().from(orders).orderBy(desc(orders.createdAt)).limit(q.pageSize).offset((q.page - 1) * q.pageSize),
db.select({ total: count() }).from(orders),
]);
return { data, total };
}
export async function getOrder(id: string) {
const [row] = await db.select().from(orders).where(eq(orders.id, id));
if (!row) throw new HTTPException(404, { message: "Order not found", cause: { code: "NOT_FOUND" } });
return row;
}
export async function createOrder(input: { customerName: string; total: number }) {
const [row] = await db.insert(orders)
.values({ id: crypto.randomUUID(), ...input, status: "pending", createdAt: Date.now() })
.returning();
return row;
}// app/orders/get.ts
import { z } from "@hono/zod-openapi";
import { listOrders } from "./repository";
export const schema = {
query: z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
}),
};
export default async function handler(c) {
return listOrders(c.req.valid("query")); // { data, total }
}Create with body
// app/orders/post.ts
import { z } from "@hono/zod-openapi";
import { createOrder } from "./repository";
export const schema = {
body: z.object({
customerName: z.string().min(1),
total: z.number().positive(),
}),
};
export default async function handler(c) {
c.status(201);
return createOrder(c.req.valid("json"));
}Get by typed path param
// app/orders/[id:uuid]/get.ts — thin: validate, call the repository
import { getOrder } from "../repository"; // throws HTTPException(404) → { error, message, code }
export const schema = {}; // empty — id is auto-injected from [id:uuid]
export default async function handler(c) {
const { id } = c.req.valid("param");
return getOrder(id);
}