defineRoute
defineRoute declares exactly one endpoint. It is the heart of baguette: a single object whose zod schemas become runtime validation, inferred handler types, the OpenAPI spec, and the error funnel — all at once. Every file in api/ default-exports one call to it.
// api/customers/[id].ts -> GET /api/customers/{id}
import { defineRoute, z } from "@prehoy/baguette";
export default defineRoute({
method: "get",
summary: "Get a customer",
tags: ["Customers"],
request: { params: z.object({ id: z.string() }) },
response: z.object({ id: z.string(), name: z.string() }),
handler: (c, { params }) => c.json({ id: params.id, name: "Ada" }),
});
The path comes from the file
You never write the URL. It is derived from the file's location under api/:
| File | Route |
|---|---|
api/hello.ts | GET /api/hello |
api/customers/[id].ts | /api/customers/{id} |
api/orders/[id]/items.ts | /api/orders/{id}/items |
A [segment] in a filename becomes an OpenAPI {segment} path parameter — declare it in request.params with a matching key.
The route object
method
One of "get", "post", "put", "patch", "delete". One method per file.
request
Up to three zod schemas, each optional:
params— path parameters. Keys must match the[segment]s in the filename.query— the query string. Coercion and defaults work as normal zod.body— the JSON body. It is parsed lazily, straight through the schema — there is no parse-everything middleware and the body is never read manually.
request: {
params: z.object({ id: z.string() }),
query: z.object({ expand: z.boolean().default(false) }),
body: z.object({ items: z.array(ItemSchema) }),
}
Whatever you declare arrives on the handler's second argument, validated and fully typed. Anything that fails validation returns 400 before your handler runs.
response
A single zod schema is the 200 body:
response: OrderSchema,
Or map status codes to schemas when an endpoint has more than one shape:
response: {
200: OrderSchema,
404: z.object({ error: z.string() }),
},
The response schema feeds the OpenAPI spec so the generated docs describe exactly what a caller receives.
handler
handler: (c, input) => c.json(/* ... */)
cis the HonoContext— usec.json,c.req.header, and so on.inputis{ params, query, body }, containing only the schemas you declared, fully typed.
Handlers may be async. An unhandled throw is caught by the error funnel and returned as a clean 500, so you only handle the errors you care about.
handler: async (c, { params, body }) => {
const order = await createOrder(params.id, body);
return c.json(order);
}
summary & tags
Optional OpenAPI metadata. summary titles the operation; tags group it in the Scalar sidebar.
Auth & middleware
Declare auth on the route instead of calling requireAuth(c) by hand — forget it once on a mutation and you have an IDOR. auth: true runs the resolver you pass to serve({ auth }); a falsy result is a 401, otherwise the user is on the context:
export default defineRoute({
method: "post",
rateLimit: { limit: 5, window: "1m" }, // 429 after 5/min per IP — runs before auth
auth: true, // resolver runs; 401 if it returns no user
middleware: [csrfCheck], // any extra Hono middleware, after auth
request: { body: z.object({ name: z.string() }) },
handler: (c, { body }) => {
const user = c.get("user"); // typed
return c.json({ ownerId: user.id, name: body.name });
},
});
rateLimit runs before auth, so brute-force and email-bombing are capped before they reach your resolver. It keys by client IP (honouring X-Forwarded-For behind a proxy) by default — pass key: (c) => … to bucket by user or email instead. The default counter is in-process; set RATELIMIT_STORE=redis (+ REDIS_URL) to share it across replicas.
Type c.get("user") once by augmenting BaguetteUser:
declare module "@prehoy/baguette" {
interface BaguetteUser { id: string; orgId: string }
}
A route with auth: true throws at boot if no resolver is configured — you can't ship an unprotected "protected" route by accident.
The one escape hatch
For a genuinely schema-less endpoint — most often a third-party webhook that must accept any payload — default-export a plain Hono handler instead of a defineRoute. The loader mounts it with app.all:
// api/webhooks/incoming.ts
import type { Context } from "hono";
// Blessed exception to "all I/O through zod": an external webhook.
export default async function incomingWebhook(c: Context) {
const body = await c.req.json().catch(() => null);
return c.json({ received: true });
}
This is the only sanctioned way to skip schemas, and it is lint-flagged so it stays rare. Everything else goes through zod.
Related
- serve — load a directory of routes and start the server.
- CLI —
baguette new route <path>scaffolds a route in the right place. - Clean-code contract — the rules
defineRouteis built to enforce.