Skip to Content
FrameworkServer

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:

  1. schema — flat Zod shape for the request (optional).
  2. 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

FromInjected 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 pathsummary + OpenAPI tags
HTTP method + handlerDefault 200 response

Handler Return Conventions

The return value is automatically serialized to JSON. Never call c.json(...) or construct a Response manually.

IntentHow
Success (200)return data;
Custom statusc.status(201); return data; — set status before return
Business errorthrow 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 errorHandled by schema — handler is never invoked (auto 400)
Unhandled throwFramework catches → 500 { message: "Internal Server Error" } (logged)

Rules:

  • ✅ return { ok: true };
  • ✅ c.status(201); return created;
  • ❌ return c.json({ ok: true }); — never use c.json()
  • ❌ return new Response(...); — never construct Response
  • ❌ 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.headers

Note 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); }
Last updated on