API Routes
Standalone server endpoints that live alongside your pages. Export named HTTP method handlers or one default handler, then return Response objects directly.
File Convention
API routes live in src/api/. The file path maps to the URL:
| File | URL |
|---|---|
src/api/health.ts |
/api/health |
src/api/users.ts |
/api/users |
src/api/users/[id].ts |
/api/users/:id |
Method Handlers
Export named functions for each HTTP method you want to handle. Unhandled methods return 405.
import type { ApiRouteArgs } from "@pracht/core";
export function GET({ request }: ApiRouteArgs) {
return Response.json([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]);
}
export async function POST({ request }: ApiRouteArgs) {
const body = await request.json();
// Create user...
return Response.json({ id: 3, ...body }, { status: 201 });
}You can also export one default handler and branch on request.method yourself:
import type { ApiRouteArgs } from "@pracht/core";
export default async function handler({ request }: ApiRouteArgs) {
if (request.method === "GET") {
return Response.json([{ id: 1, name: "Alice" }]);
}
if (request.method === "POST") {
const body = await request.json();
return Response.json({ id: 2, ...body }, { status: 201 });
}
return new Response("Method not allowed", { status: 405 });
}API Middleware
API routes can have their own middleware chain, separate from page middleware. Configure it in defineApp:
export const app = defineApp({
// Page routes...
api: {
middleware: ["rateLimit"],
},
});API middleware runs before the handler, just like page middleware runs before loaders.
Same-Origin Protection (CSRF)
By default, pracht rejects state-changing API requests (POST, PUT, PATCH, DELETE) that come from another origin with a 403 — before any API middleware runs. A request is considered same-origin when the browser says so (Sec-Fetch-Site: same-origin) or its Origin/Referer header matches the request URL's origin. Sec-Fetch-Site: same-site is not accepted, since sibling subdomains can be attacker-controlled. Requests without any browser provenance headers — curl, server-to-server calls, tests — pass through, because a browser form can't produce them.
WebSocket upgrade requests get the same check, even though they are GET. Browsers do not apply CORS to WebSocket, so without it any page on the web could open a socket to your app with the user's cookies attached (cross-site WebSocket hijacking).
This is controlled by requireSameOrigin on the API config and defaults to true:
export const app = defineApp({
api: {
requireSameOrigin: false, // default: true — set false to opt out
},
});Only opt out if you implement your own CSRF protection in middleware — for example to allowlist trusted cross-origin callers. See the authentication recipe for the full CSRF layering guide.
Middleware Without a Manifest (Higher-Order Functions)
When using the pages router or any setup without a routes.ts manifest, you can apply middleware to individual API routes with a plain higher-order function — no framework API required:
import type { ApiRouteArgs, ApiRouteHandler } from "@pracht/core";
export function withAuth(handler: ApiRouteHandler): ApiRouteHandler {
return async (args: ApiRouteArgs) => {
const session = args.request.headers.get("cookie")?.includes("session=");
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(args);
};
}Then wrap any handler export:
import { withAuth } from "../lib/with-auth";
export const GET = withAuth(({ request }) => {
return Response.json({ user: "Alice" });
});You can compose multiple wrappers for stacking:
import { withAuth } from "../lib/with-auth";
import { withRateLimit } from "../lib/with-rate-limit";
export const POST = withAuth(withRateLimit(async ({ request }) => {
const body = await request.json();
return Response.json({ ok: true });
}));This pattern works with both the pages router and the manifest router — it's just JavaScript.
Full Control
API handlers receive the same LoaderArgs context (request, params, context, signal) and return standard Response objects. You have full control over status codes, headers, and body format.
export function GET() {
return new Response("plain text", {
status: 200,
headers: { "content-type": "text/plain" },
});
}WebSockets
API routes are also where WebSocket upgrades belong. Return a 101 response and pracht passes it through untouched — no security headers, no cache headers, and crucially no reconstruction, which would drop the response's webSocket handle.
This requires a runtime that can hold a connection open, which today means the Cloudflare adapter with a Durable Object owning the socket:
import type { ApiRouteArgs } from "@pracht/core";
export async function GET({ context, request, url }: ApiRouteArgs) {
if (request.headers.get("upgrade") !== "websocket") {
return new Response("Expected a WebSocket upgrade", { status: 426 });
}
const { CHAT_ROOM } = context.env as { CHAT_ROOM: DurableObjectNamespace };
const room = url.searchParams.get("room") ?? "lobby";
return CHAT_ROOM.get(CHAT_ROOM.idFromName(room)).fetch(request);
}Cross-origin upgrades are blocked by default (see Same-Origin Protection), but authenticating the connection is still yours to do — the handshake is an ordinary request carrying cookies, so API middleware works normally. The Node and Vercel adapters cannot serve upgrades; see Adapters for the Durable Object and ws-on-Node patterns.
Validation and Typed Fetch
Wrap a handler with defineApi() to validate the request with any Standard Schema validator (zod, valibot, arktype, …) before it runs. Invalid requests get a standardized 422 response ({ error: "validation", issues }); handlers can return JSON-safe primitives, arrays, and plain objects, sent as Response.json(). Serialize values such as Date explicitly, or return a Response for custom wire formats.
import { defineApi } from "@pracht/core";
import * as z from "zod";
export const POST = defineApi({
body: z.object({ name: z.string().min(1) }),
handler: ({ body }) => ({ created: body.name }),
});Run pracht typegen and the apiFetch() client checks every call at compile time — paths, methods, params, bodies, queries — and returns the handler's response type:
import { apiFetch } from "@pracht/core";
const created = await apiFetch("/api/items", {
method: "POST",
body: { name: "Pracht" }, // type-checked against the body schema
});Query and params values reach their schemas as strings (the URL wire format) — use string-accepting inputs like z.coerce.number(), never z.number(); generated calls reject concrete schema keys that cannot accept strings. Handlers that need a custom status code keep their typed payload with json(value, { status: 201 }) instead of Response.json(). After the first pracht typegen run, pracht dev refreshes the generated types automatically when route files are added, removed, or renamed and when the route manifest or an imported definition module changes; before it, the dev banner prints a setup tip.
Non-2xx responses throw ApiFetchError; validation failures expose the normalized issues for form error display. <Form> accepts the same schemas via its schema and onValidationIssues props, so client-side and server-side validation share one schema module; its onResponse prop receives every non-redirect response for success payloads and non-validation failures.