Engines

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.

api-contract.tsts
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.

api-server.tsts
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.

api-server.tsts
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

api-client.tsts
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.

handling a failurets
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.

api-contract.tsts
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.

api-server.tsts
serve(routes, {  prefix: "/api",  handlers,  validateResponses: true, // development only});
Warning
It costs one validation per request, which is why it is off by default. Leave it on in development and off in production.

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.

api-client.tsts
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" },);
Note
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.

api-client.tsts
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 });