QUESTPIE
Code

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.

View markdown

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.

src/questpie/server/routes/create-booking.post.ts
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 method

questpie 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.

MethodWhat 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

FileClient methodHTTP
routes/create-booking.post.tsclient.routes.createBooking.postPOST /api/create-booking
routes/revenue-stats.get.tsclient.routes.revenueStats.getGET /api/revenue-stats
routes/admin/stats.get.tsclient.routes.admin.stats.getGET /api/admin/stats
routes/posts/[id].get.tsno usable call, see belowGET /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.

src/questpie/server/routes/posts/[id].get.ts
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.

src/questpie/server/routes/webhook/[id].post.ts
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.

src/questpie/server/routes/report.get.ts
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

TopicPage
Parsing the input, typing the returnInput and output
Who may call a routeAccess control
OpenAPI summaries and MCP toolsMetadata
Inference helpers, direct calls, introspectionTypes 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.

On this page