End-to-end type safety for API routes: define a route with a Standard Schema validator (zod, valibot, arktype, or any other implementation), get runtime validation on the server, and call it from the client with a fetch helper that knows every route's paths, methods, request shapes, and response types.
Three pieces work together:
defineApi()wraps an API route handler with schema validation.pracht typegenregisters every API route's request/response types onRegister["apiRoutes"](the same mechanism that types route ids and loader data).apiFetch()reads those registered types so requests are checked at compile time and responses come back typed.
Each piece degrades gracefully: defineApi works without typegen (runtime
validation only), and apiFetch works without registered types (every path
accepted, payloads unknown).
// src/api/items.ts
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 }),
});
export const GET = defineApi({
query: z.object({ page: z.coerce.number().optional() }),
handler: async ({ query, context }) => {
return { items: await context.db.items.list({ page: query.page ?? 1 }) };
},
});defineApi accepts up to three schemas, all optional:
| Schema | Validates | Input handed to the schema |
|---|---|---|
body |
Request body | Parsed JSON (default), or a record built from FormData for form/multipart submissions, or raw text otherwise |
query |
Query string | Record with one string per key; repeated keys (?tag=a&tag=b) become string arrays |
params |
Route params ([id] …) |
The raw string param record |
The handler receives the regular ApiRouteArgs (request, context, url,
signal, route) plus the validated body and query values, with params
replaced by the params schema's output when one is given. Schema output types
flow through — z.coerce.number() gives the handler a number.
Query and params schemas receive strings. The wire format for query strings and route params is text: every value the schema sees is a string (or a string array for repeated query keys). Write schemas that accept string input —
z.coerce.number(),z.enum([...]), transforms — neverz.number(), which could not validate any request.apiFetch()rejects query and params keys whose schema input has no string representation at compile time (when the schema exposes a concrete input type). A repeated key (?tag=a&tag=b) arrives as an array but a single?tag=aarrives as a plain string, so accept both:z.union([z.string(), z.array(z.string())]).
Handlers may return:
- a
Responsefor full control over status and headers, or - a JSON value made from strings, finite numbers, booleans, null, arrays, and
plain objects, sent as
Response.json(value)— this is what makes response types inferable on the client.
The JSON shape must survive serialization without changing type. Values such
as Date, BigInt, undefined, class instances, sparse arrays, and circular
objects are rejected (at compile time where TypeScript can identify them, and
again at runtime for JavaScript callers and value-level cases such as NaN).
Serialize them explicitly or return a Response when you need custom wire
formats.
When a handler only needs a different status code or extra headers, return
json(value, init) — it behaves like Response.json() but keeps the payload
type visible to apiFetch():
import { defineApi, json } from "@pracht/core";
export const POST = defineApi({
body: itemSchema,
handler: ({ body }) => json({ created: body.name }, { status: 201 }),
});If a handler can return a plain Response on any branch, its client output is
unknown: status, content type, and body cannot be inferred from the
Response type. Use json() or keep JSON-returning handlers separate when
callers need a precise result type.
Invalid requests never reach the handler. The route answers with a standardized JSON body:
// HTTP 422 (schema rejected) or 400 (unparseable body)
{
"error": "validation",
"issues": [{ "in": "body", "path": ["name"], "message": "Required" }]
}in is "body", "query", or "params". Query and params issues from the
same request are reported together. isApiValidationErrorBody(value) type
guards this shape, and apiValidationErrorResponse(issues) builds the same
response from your own handlers or middleware.
Plain handlers (export function GET(args)) keep working unchanged —
defineApi is opt-in per export, and both styles can mix in one module.
pracht typegenAlongside the route id/param/loader registrations (see
docs/ROUTING.md), the generated
src/pracht.d.ts now registers every API route:
declare module "@pracht/core" {
interface Register {
routes: { /* ... */ };
apiRoutes: {
"/api/items": {
path: "/api/items";
params: Record<never, never>;
methods: ApiRouteMethodMap<typeof import("./api/items")>;
};
"/api/items/:id": {
path: "/api/items/:id";
params: { "id": RouteParamInput; };
methods: ApiRouteMethodMap<typeof import("./api/items/[id]")>;
};
};
}
}ApiRouteMethodMap extracts { body, query, params, output } per exported HTTP
method: defineApi handlers carry their schema input types and handler
return type when every branch returns JSON; handlers that can return a
Response and plain handlers register with unknown response types.
Routes that only export a default handler accept every method, untyped.
Run pracht typegen --check in CI to fail when the generated files are
stale. Type generation discovers API paths without importing the API modules,
so top-level route code and runtime service initialization do not execute
during codegen.
While pracht dev runs, the generated files refresh automatically whenever
route files are added, removed, or renamed, and whenever the route manifest or
one of its imported definition modules changes. The watcher activates after
pracht typegen has created the default src/pracht.d.ts; until then the dev
banner prints the command needed to enable typed routes. Handler signature
changes need no regeneration: the declaration references route modules with
typeof import(...), so those types update live.
import { apiFetch } from "@pracht/core";
// GET is the default method; the response type is the handler's return type.
const list = await apiFetch("/api/items", { query: { page: 2 } });
// ^? { items: Item[] }
const created = await apiFetch("/api/items", {
method: "POST",
body: { name: "Pracht" }, // type error if it doesn't match the body schema
});
const item = await apiFetch("/api/items/:id", { params: { id: "42" } });With registered types, the compiler rejects unknown paths, methods a route
does not export, missing or mismatched bodies, unknown query keys, missing
params, and params schemas that cannot accept their string wire values. Params
are substituted into the path template with proper segment encoding. Method
unions remain correlated with their request shapes:
when POST and PUT accept different bodies, a dynamic "POST" | "PUT" value
must be paired with a correspondingly discriminated options object.
Runtime behavior:
- Plain objects passed as
bodyare JSON-encoded withContent-Type: application/json;FormData,URLSearchParams,Blob, typed arrays, streams, and strings pass through unchanged. Routes whose body schema containsFile/Blobvalues acceptFormDatain their typedbody— JSON-encoding aFilewould silently drop it, so send multipart. For other binary payloads and streams, use a plain handler and readrequest.bodyor the appropriateRequestmethod directly;defineApi()parses non-JSON, non-form bodies as text before passing them to a schema. GETandHEADrequests are bodyless; passingbodyfor either method is rejected by generated types and at runtime.- 2xx JSON responses are parsed;
text/*responses resolve to the text;HEAD, 204, and 205 responses resolve toundefined; anything else resolves to the rawResponse. - Non-2xx responses throw
ApiFetchErrorcarryingstatus,response, the parsedbody, and — when the server sent the standardized validation failure — the normalizedissues.
import { apiFetch, ApiFetchError } from "@pracht/core";
try {
await apiFetch("/api/items", { method: "POST", body: { name: "" } });
} catch (error) {
if (error instanceof ApiFetchError && error.issues) {
showFieldErrors(error.issues); // [{ in: "body", path: ["name"], message: "..." }]
}
}ApiFetchOptions also accepts headers, signal, a custom fetch
implementation, and a baseUrl prefix for absolute-origin calls during SSR
or in tests.
Calling APIs during SSR. Relative URLs only resolve in the browser — server-side
fetchthrows on them. In loaders and other server code, pass an absolutebaseUrl. Better: calling your own origin over HTTP from a single-process server wastes a round trip and can deadlock, so extract the shared logic into a function that both the API route and the loader call directly.
<Form> accepts the same Standard Schema validators for progressive
enhancement:
import { Form, type ApiValidationIssue } from "@pracht/core";
import { itemSchema } from "../schemas/item";
function NewItem() {
const [issues, setIssues] = useState<ApiValidationIssue[]>([]);
return (
<Form
action="/api/items"
method="post"
schema={itemSchema}
onValidationIssues={setIssues}
onSubmit={() => setIssues([])}
>
<input name="name" />
{issues.map((issue) => (
<p class="error">{issue.message}</p>
))}
<button>Create</button>
</Form>
);
}actionautocompletes API route paths registered bypracht typegen(any URL string still works).schemavalidates the form's data client-side before submitting (one entry per field, arrays for repeated fields,Filevalues untouched). On failure the request is skipped andonValidationIssuesfires.onValidationIssuesalso fires when the server answers with the standardized 400/422 validation body — so adefineApiroute with the same schema gives identical error shapes whether JavaScript validated first or the server did. Share one schema module between the API route and the form.onResponsereceives theResponsefor every non-redirect fetch submission — read a success payload withresponse.json(), or branch onresponse.ok/response.statusto surface non-validation failures (500s would otherwise pass silently). The body is never consumed before the callback runs.
Without JavaScript the form still submits natively and the server-side schema remains the source of truth.
@pracht/core depends only on @standard-schema/spec — a types-only package
with zero runtime bytes. The validator library is yours to pick, and where
its weight lands matters:
- Server-only schemas (
defineApiwithout a shared<Form>schema) never reach the browser — API modules are excluded from client bundles entirely. Any validator works; pick for DX. - Client-shared schemas (
<Form schema>, schema modules imported by components) ship to the browser. Prefer a light validator there: valibot (modular, ~1-2 KB gz for typical schemas) orzod/mini(Zod 4's tree-shakable build) instead of the fullzodimport. Hand-rolled schemas are also fine — the spec is a single~standard.validatemethod, implementable in a dozen lines with no dependency at all.
The docs use zod in examples for familiarity; nothing in pracht assumes it.
- Validation runs inside the wrapped handler, after API middleware — a middleware short-circuit still prevents body parsing.
formDataToRecord,searchParamsToRecord, andvalidateStandardSchemaare exported for reuse in custom handlers or middleware.