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.
Same path, multiple methods
One file is one route, so to put a GET list and a POST create on the same REST path, add a .<method> suffix to the filename. The suffix is stripped from the URL; the actual method still comes from each file's defineRoute({ method }).
| File | Route |
|---|---|
api/widgets/index.get.ts | GET /api/widgets |
api/widgets/index.post.ts | POST /api/widgets |
api/widgets/[id].delete.ts | DELETE /api/widgets/{id} |
Without a suffix, give each route its own filename (widgets/list.ts, widgets/create.ts) — both styles work; the suffix is only for sharing one path.
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.