Typed client
Write a route once. The server reads your table to make endpoints; the browser reads the same table to make typed calls. Both read the same schema objects, so the shape of a request and a response is written one time.
The problem it removes
A normal API client is a second copy of your backend. Someone writes a method, someone hand-writes the response interface, and the two drift: a field gets renamed on the server, the type keeps its old name, and the bug shows up as undefined in a browser rather than as a compile error.
This removes the second copy. A route is declared once; the server and the client are two views of that one declaration.
A contract file
Put the routes in their own file with no handlers in it. That file is safe to import from a browser, which is the point — a table with handlers attached would drag your database into the client bundle.
import { z } from "zod";import { route } from "yatta/rpc"; export const User = z.object({ id: z.string(), email: z.string(), name: z.string(),}); export const routes = { getUser: route({ method: "get", path: "/users/:id", params: z.object({ id: z.string() }), response: User, }), createUser: route({ method: "post", path: "/users", body: z.object({ email: z.string(), name: z.string() }), response: User, }),};Every validator is optional. A route with no body declares no body, and then passing one is a type error instead of a field quietly dropped on the floor.
The server
Handlers are passed separately, so they stay on the server.
import { serve, fail } from "yatta/rpc";import { routes } from "./api-contract";import { db } from "./db"; export const api = serve(routes, { prefix: "/api", handlers: { getUser: async ({ params }) => db.users.findById(params.id) ?? fail(404, "No such user"), createUser: async ({ body }) => db.users.insert(body), },});The handler is typed against the route's own schemas, so reading a body field the schema does not declare is an error where you wrote it.
For a short route, serverRoute() attaches the handler in place instead. Serve that table the same way. Use whichever reads better.
import { serverRoute, serve, fail } from "yatta/rpc"; const routes = { getUser: serverRoute( { method: "get", path: "/users/:id", params: z.object({ id: z.string() }), response: User }, async ({ params }) => db.users.findById(params.id) ?? fail(404, "No such user"), ),}; export const api = serve(routes, { prefix: "/api" });The browser
import { clientFor } from "yatta/rpc";import { routes } from "./api-contract"; const api = clientFor(routes, { baseUrl: "/api" }); // The type comes from `User`. Nothing was written twice.const user = await api.getUser({ params: { id: "42" } });const name: string = user.name; // A wrong type is a compile error, not a runtime surprise.await api.createUser({ body: { email: 1, name: "Ada" } }); // ✗What you stop doing
- Writing a copy of each response type. Change the schema and both sides change together.
- Keeping a list of URLs in step with the server. Rename a path and the client stops compiling.
- Writing
await fetch(…)with a hand-built body and a cast on the result. - Running a build step and committing a generated file that can fall out of date.
Errors carry the server's own message
A failure is an Error with the status and the parsed body attached, both typed as CallError. The message is what the server said, so status === 404 comes with "No such user" rather than a generic failure notice.
import type { CallError } from "yatta/client"; try { await api.getUser({ params: { id } });} catch (err) { // 404, and "No such user" — not "Request failed with status 404". showError((err as CallError).message); if ((err as CallError).status === 404) showNotFound();}Any validator, not just Zod
The client and serve() work with any library that follows Standard Schema — Zod, Valibot, ArkType. Yatta does not depend on any of them, so you pick one and nothing in the framework changes.
import * as v from "valibot"; export const User = v.object({ id: v.string(), email: v.string(), name: v.string(),}); // Everything else stays exactly as it was.Catch a handler that drifts
Turn this on while you build. It checks what a handler returns against the response schema, so a mismatch is an error naming the field instead of an empty value in a browser.
serve(routes, { prefix: "/api", handlers, validateResponses: true, // development only});Plain routes, no table
yatta/client works on its own. Give it a list of method and path with no handlers at all, and you get the same typed methods — useful when your routes are plain router calls and there is no table to share.
import { createClient, route } from "yatta/client";import { z } from "zod"; const api = createClient( { getUser: route({ method: "get", path: "/users/:id", params: z.object({ id: z.string() }), response: z.object({ id: z.string(), email: z.string(), name: z.string() }), }), }, { baseUrl: "/api" },);clientFor() is the one to reach for on a shared table. Plain createClient() on a table that carries handlers produces types the compiler cannot resolve, which looks like full typing and accepts anything.Cookies and headers
onRequest runs before each call, which is where a cookie jar or an auth header goes. Default headers apply to every call, and any call can override them or pass an AbortSignal.
const api = clientFor(routes, { baseUrl: "/api", headers: { "x-app": "web" }, onRequest: (request) => { request.headers.set("authorization", `Bearer ${token}`); },}); await api.listUsers({ headers: { "x-trace": traceId } });await api.listUsers({ signal: controller.signal });