Routes
A route is one HTTP endpoint you write as a file. QUESTPIE mounts it, parses its input, and puts a typed method on your client.
Your collection already answers a fixed set of REST calls. This page is for the next one: a booking that touches three tables, a webhook, a nightly report.
Declare it
Put a file under routes/. Default-export a route() chain. Import route
from questpie/services.
import { ApiError } from "questpie/errors";
import { route } from "questpie/services";
import { z } from "zod";
export default route()
.post()
.schema(
z.object({
serviceId: z.string(),
scheduledAt: z.string().datetime(),
customerEmail: z.string().email(),
}),
)
.access(({ session }) => !!session?.user)
.handler(async ({ input, collections }) => {
const service = await collections.services.findOne({
where: { id: input.serviceId },
});
if (!service) throw ApiError.notFound("Service", input.serviceId);
const booking = await collections.appointments.create({
service: input.serviceId,
scheduledAt: new Date(input.scheduledAt),
customerEmail: input.customerEmail,
});
return { id: booking.id, status: "confirmed" as const };
});The handler argument is the app context your hooks and jobs already get. So
collections, globals, db, session, queue, storage and kv are on
it, plus your own services. input and params sit alongside them. Then:
questpie generate # mounts POST /create-booking, writes the client methodquestpie dev watches for you. It regenerates when a file appears or
disappears, not when you edit one. Now call it:
const booking = await client.routes.createBooking.post({
serviceId: "svc_1",
scheduledAt: "2026-07-01T10:00:00.000Z",
customerEmail: "ada@example.com",
});
// booking: { id: string; status: "confirmed" }A route with no `.access()` is public
Nothing else gates it. Routes have no default rule, and the HTTP handler adds
no auth wrapper. Collections work the other way round. Write
.access(({ session }) => !!session?.user) to require a signed-in caller.
The chain
route() returns a builder. Every method returns a new frozen instance, and
.handler() is terminal. It hands back the finished route, not a builder.
| Method | What it declares |
|---|---|
.get() .post() .put() .patch() .delete() .head() .options() | The HTTP method. |
.schema(z…) | A Zod schema for the input. Switches the route to JSON. |
.outputSchema(z…) | A Zod schema for the return value. Optional. |
.params<{ … }>() | Type-only. Narrows params to exactly these keys. |
.access(rule) | Who may call it. Omit it and the route is public. |
.meta({ … }) | Title, description, tags, MCP. No effect on routing. |
.raw() | The handler takes the Request and returns a Response. |
.handler(fn) | Terminal. Returns the route definition. |
Two different methods on one builder throw. route().get().post() fails at
import with route() accepts one HTTP method. Calling the same one twice is
fine.
route().handler(fn) shows you the defaults: method POST, raw mode, because
nothing asked for JSON. .schema() and .outputSchema() turn JSON mode on.
.raw() turns it off and drops any schema you set. The last of the two wins.
The file name is the URL
| File | Client method | HTTP |
|---|---|---|
routes/create-booking.post.ts | client.routes.createBooking.post | POST /api/create-booking |
routes/revenue-stats.get.ts | client.routes.revenueStats.get | GET /api/revenue-stats |
routes/admin/stats.get.ts | client.routes.admin.stats.get | GET /api/admin/stats |
routes/posts/[id].get.ts | no usable call, see below | GET /api/posts/:id |
Folders nest in both columns. Hyphens become camelCase on the client and stay
kebab-case in the URL. /api is the base path the starters hand their HTTP
handler, and it is yours to change. The .post in the file name is optional.
Drop it and the method comes from the builder, so create-booking.ts with
.post() lands in the same place.
A dynamic route has no usable client call
client.routes.posts["[id]"].get() requests /api/posts/[id] verbatim. The
client never splices your value in. Call a [param] route with fetch and
build the URL yourself. The server handler still reads the real value from
params.
Path params
[id] in a file name becomes :id in the URL. [...path] catches the rest of
the path and arrives as one string. Both land on params.
Without .params<…>() the handler sees params as Record<string, string>,
so every key reads as string and so does a typo. Declare the shape and the
typo becomes a compile error. The call takes no argument and does nothing at
runtime.
export default route()
.get()
.params<{ id: string }>()
.raw()
.handler(async ({ params, collections }) => {
const post = await collections.posts.findOne({ where: { id: params.id } });
return Response.json(post);
});Where the files live
Codegen scans routes/ and functions/, both recursively, and a name may not
appear in both. One route per file, default-exported. Files and folders whose
name starts with _ are skipped, so a shared _handler.ts beside your routes
stays a plain helper. A new file reaches the runtime and the client only after
questpie generate. Editing the body of a route you already generated does not
need it. questpie add route revenue-stats writes the file and generates for
you.
One method per file
A route definition owns one method. When one path needs two verbs, write two
files and share the body from an _ helper.
import { handleWebhook } from "./_handler.js";
export default route().post().raw().handler(handleWebhook);When the path matches but the method does not, you get 405 and an Allow
header listing the methods that path does have. Nothing matching is a 404.
Raw routes
.raw() hands you the Request and expects a Response. Reach for it to
stream, to redirect, to set your own content type, or to read the query string.
There is no input parsing and no output validation.
export default route()
.get()
.raw()
.handler(({ request }) => {
const period = new URL(request.url).searchParams.get("period");
return Response.json({ period });
});A JSON route reads its input from the body
Nothing parses the query string into input. A GET carries no body, so a
GET route with a required .schema() answers 400 and never runs your
handler. Use POST, or use .raw() and read the URL yourself.
Where each topic lives
| Topic | Page |
|---|---|
| Parsing the input, typing the return | Input and output |
| Who may call a route | Access control |
| OpenAPI summaries and MCP tools | Metadata |
| Inference helpers, direct calls, introspection | Types and tooling |
Next
Services are the shared objects a handler resolves from its context. Jobs take the slow half of a handler off the request path.