# Routes (/docs/code/routes)

---
title: Routes
description: 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.
kind: guide
package: questpie
---

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

```ts title="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:

```bash
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:

```ts
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" }
```

<Callout type="warn" title="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.
</Callout>

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

<Callout type="warn" title="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`.
</Callout>

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

```ts title="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.

```ts title="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.

```ts title="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 });
	});
```

<Callout type="warn" title="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.
</Callout>

## Where each topic lives

| Topic                                          | Page                                              |
| ---------------------------------------------- | ------------------------------------------------- |
| Parsing the input, typing the return           | [Input and output](/docs/code/routes/validation)  |
| Who may call a route                           | [Access control](/docs/code/routes/access)        |
| OpenAPI summaries and MCP tools                | [Metadata](/docs/code/routes/metadata)            |
| Inference helpers, direct calls, introspection | [Types and tooling](/docs/code/routes/typescript) |

## Next

**[Services](/docs/code/services)** are the shared objects a handler resolves
from its context. **[Jobs](/docs/code/jobs)** take the slow half of a handler
off the request path.
