Server routes
A +server.ts file turns a route into a plain HTTP endpoint. No page is rendered, no load runs, and nothing is sent to the browser. Use it for APIs, webhooks, file downloads and anything a fetch calls.
import type { LoadContext } from "@sigil-dev/grimoire";
export function GET({ url }: LoadContext) {
return Response.json({ q: url.searchParams.get("q") });
}Methods
Export one function per method:
GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD
Each is an ordinary async function returning a Response. The names are what the framework looks for — there is no default export and no handler property.
export async function GET({ url }: LoadContext) {
const page = Number(url.searchParams.get("page") ?? "1");
return Response.json(await listBooks(page));
}
export async function POST({ request }: LoadContext) {
const book = await create(await request.json());
return Response.json(book, { status: 201 });
}Each is a LoadContext, the same object load receives, minus nothing — request, params, url, locals, fetch, cookies and setHeaders are all there. locals is populated by hooks, so a server route sees the same session a page would.
Status codes
Return a Response with whatever status you mean. Response.json(data, { status: 201 }) and new Response(body, { status: 204 }) both work — there is no separate result type to learn.
For a redirect, throw the sentinel:
throw redirect("/login");Sharing a path with a page
A server route and a page can sit at the same URL. Server routes are matched first, and if a method has no handler:
- If a page exists at that path, the page takes the request.
- Otherwise it's a 405.
That fallthrough is deliberate — it is what lets a chat page sit next to its WebSocket endpoint at the same URL without one shadowing the other. It also means an unimplemented method on an API route returns 405, but an unimplemented method on a path that also has a page quietly renders the page instead. If you expected a 405 and got HTML, that is why.
WebSockets
A +server.ts can also accept WebSocket connections at the same path, by exporting websocket with Bun's handlers plus an optional upgrade:
import type { ServerWebSocket } from "bun";
type Socket = ServerWebSocket<{ name: string }>;
export function upgrade({ url }: { url: URL }) {
const name = url.searchParams.get("name")?.slice(0, 20) || "anonymous";
return { name };
}
export const websocket = {
open(ws: Socket) {
ws.subscribe("room");
ws.publish("room", `${ws.data.name} joined`);
},
message(ws: Socket, message: string | Buffer) {
ws.publish("room", `${ws.data.name}: ${message}`);
ws.send(`you: ${message}`);
},
close(ws: Socket) {
ws.publish("room", `${ws.data.name} left`);
},
};upgraderuns before the connection is accepted. Whatever object it returns is merged intows.datanext toparams; throwing refuses the connection with a 426.open,message,closeanddrainare Bun's own handlers, so topics and everything else Bun sockets can do works here.- The same file can export
GET,POSTand the other methods alongside it, so one URL serves both the socket and plain HTTP.
See Realtime with WebSockets for the full pattern including the client side.
Dotfiles
Directories starting with a dot are scanned, so API routes that must live at a dotted path work — src/routes/.well-known/+server.ts is reachable. This is worth knowing because a scanner that skipped dotfiles would make those paths 404 with nothing in the route table to explain it.
Next
- Route files — the file conventions
- hooks.server.ts — populating
localsfor API routes