Engines

Frontend

The usual split is a backend that defines behaviour and a frontend that calls it over HTTP, with a hand-written client and a copy of every type in between. That copy is what goes stale. Here the route is a plain function, and the transport is derived.

The one thing you write

A route is a schema plus a function. The function returns a plain value, not a Response — which is what lets the same function be called directly instead of reached over HTTP.

api-contract.tsts
import { z } from "zod";import { defineRoute, createApp } from "yatta.js/universal";import { HttpError } from "yatta.js/api"; export const User = z.object({  id: z.string(),  email: z.string(),  name: z.string(),}); export const api = createApp(  {    getUser: defineRoute(      {        method: "get",        path: "/users/:id",        params: z.object({ id: z.string() }),        response: User,      },      async ({ params, services }) => {        const user = await services.db.users.findById(params.id);        if (!user) throw new HttpError(404, "No such user");        return user;      },    ),     createUser: defineRoute(      {        method: "post",        path: "/users",        body: z.object({ email: z.string(), name: z.string() }),        response: User,      },      async ({ body, services }) => services.db.users.insert(body),    ),  },  { services: { db, auth, realtime } },);

The services are injected, so a handler uses your database, your auth and your realtime directly — and a test can pass a stand-in instead.

Note
services.db is typed from what you passed to createApp. Nothing is any, and nothing reaches for a module-level singleton, so two apps in one process cannot see each other's data.

Server: call it directly

The app has a method per route. On the server, call it. There is no HTTP hop, no serialization round trip, and no second definition to maintain.

anywhere on the serverts
// A Server Component, a job, a cron handler, a test — all the same.const user = await api.getUser({ params: { id } }); // Typed from the route. No annotation, no interface.const name: string = user.name; // @ts-expect-error id must be a stringawait api.getUser({ params: { id: 1 } });

This is the part that matters and it is worth being clear about why. The caller and the handler are in the same process, with the same database connection, under the same trust boundary. Going out to HTTP and back between them buys no isolation at all — it costs a round trip, a socket, and a new way to fail. So it does not do that.

Server: serve it over HTTP

For a browser, the same table becomes endpoints. mount() is the whole transport.

server.tsts
import { mount } from "yatta.js/universal";import { api } from "./api-contract"; Bun.serve({ fetch: mount(api) });

Browser: the same methods

createClient(app) derives the browser client from the same table. Same method names, same arguments, same return types — there is no client file to write.

browser.tsts
import { createClient } from "yatta.js/universal";import { api } from "./api-contract"; const client = createClient(api, { baseUrl: "/api" }); const user = await client.getUser({ params: { id } });const name: string = user.name; // A failure carries the status and the server's own message.try {  await client.getUser({ params: { id: "missing" } });} catch (err) {  err.status; // 404  err.message; // "No such user" — not "Request failed"}

Both paths behave the same

That is a property, not a hope. The validators run on both — arguments are checked in process as well as over HTTP — so a value one refuses cannot be accepted by the other, and a handler that drifts from its declared response fails the same way on both.

  • A bad body is a 400 naming the field, on both paths.
  • An HttpError keeps its status; anything else becomes a 500 with "Internal error" — an unexpected message is never echoed to the caller.
  • Nothing returned is a legitimate answer, not a JSON parse failure.
  • A path that exists but not for this verb is a 405 with an Allow header, not a 404 that sends you hunting for a typo in a correct path.

Frameworks

The loading, de-duplication, sharing between readers and rollback all live once, in the binding layer. Each framework adds only the line that connects its reactivity to a value changing — a signal write, a ref assignment, a resource. So the caching behaves identically everywhere, because there is only one implementation of it.

React

app/profile/[id]/page.tsxts
"use client";import { useCall, useRoutes } from "yatta.js/react";import { api } from "@/api-contract";import { use } from "react"; export default function Page({ params }) {  const { params: p } = use(params);  const routes = useRoutes(api);  const { data, error, refetch } = useCall(routes.getUser, { params: { id: p.id } });   if (error) return <p>{error.message}</p>;  return <h1>{data?.name ?? "…"}</h1>;}

The method and its arguments are the whole API. No wrapper function per endpoint, no dependency array, and no name string to keep in step.

Note
React destructures flat, Vue reads state.value, Solid returns an accessor. The shape follows each framework's idiom — what is identical across all of them is the caching, the de-duplication and the rollback underneath.

Vue

Profile.vuets
<script setup lang="ts">import { useCall } from "yatta.js/frameworks";import { api } from "@/api-contract"; const { state, reload } = useCall(store, api.getUser, { params: { id } });</script> <template>  <h1 v-if="state.value.data">{{ state.value.data.name }}</h1>  <p v-else-if="state.value.error">{{ state.value.error.message }}</p></template>

Solid

Profile.tsxts
import { useSolidCall } from "yatta.js/frameworks"; const user = useSolidCall(store, api.getUser, { params: { id } }); return <Show when={user().data}>{u => <h1>{u().name}</h1>}</Show>;

Svelte

Profile.sveltets
<script lang="ts">  import { useSvelteCall } from "yatta.js/frameworks";   const user = useSvelteCall(store, api.getUser, { params: { id: data.id } });</script> <h1>{user.data?.name}</h1>

Angular

profile.component.tsts
@Component({  providers: [provideYatta(() => api, () => new QueryCache())],  template: `@if (user.state().data; as u) { {{ u.name }} }`,})export class Profile {  private readonly yatta = inject(YattaAngularClient);  readonly user = this.yatta.call(api.getUser, { params: { id: this.id } });}

Qwik

component.tsxts
import { useQwikCall } from "yatta.js/frameworks"; export default component$(() => {  const call = useQwikCall(store, api.getUser, { params: { id } });  return <h1>{call.state.value.data?.name}</h1>;});

Qwik is built on a signal and a resource rather than a subscription, because a Qwik container is rendered on the server, serialized, and resumed without re-running the component. Anything held in a closure does not survive that; serialized state does.

No framework at all

any.htmlts
import { createDomCall } from "yatta.js/frameworks"; const call = createDomCall(store, api.getUser, { params: { id } });const off = call.subscribe((state) => {  document.querySelector("h1").textContent = state.data?.name ?? "";});

Next.js

One catch-all route file, and the same table. Seven named exports come from one call, so there is no way for one verb to get a different table than the others.

app/api/[[...path]]/route.tsts
import { toNextRoute } from "yatta.js/next";import { api } from "@/api-contract"; export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = toNextRoute(api);

Realtime

The frontend observes a realtime connection you already have, rather than opening a second one. Two connections means two topic sets and two event streams, and neither is the one the server is pushing to.

realtimets
import { createFrontend } from "yatta.js/frontend";import { createRealtimeClient } from "yatta.js/realtime"; export const frontend = createFrontend(api, {  baseUrl: "/api",  realtime: createRealtimeClient({ url: "/realtime" }),  cookieName: "session",}); // Connection state, readable — so one component can show a banner and others// can disable a button without keeping separate copies.frontend.realtime.onStatus((status) => console.log(status)); // Sends made while disconnected are queued and flushed on reconnect, so a// message is not lost because the network blinked.await frontend.realtime.send("chat.message", { text: "hello" });

What is verified, and what is not

Stated plainly, because a framework binding that has only been typechecked is easy to mistake for one that works.

  • Verified: the binding layer, the plain-DOM binding, Vue (a real SSR render), and the mutation sequence every framework shares.
  • Logic verified, rendering not: Solid. Its binding is a signal write and an accessor, both asserted — but producing markup needs Solid's compiler, which this project does not run.
  • Typechecked only: Angular, Svelte and Qwik. Each needs a browser, a compiler or a resumable container that is not available here. Their code is a thin layer over the tested parts, but that is an argument, not evidence.
  • Not verified in a browser: in-browser reconnection, optimistic rollback after an unmount, and hydration behaviour. No DOM is available in this project's test run.
Warning
The framework packages are development dependencies of the framework itself, used to typecheck the bindings. An app installs only the framework it actually uses — and React is an optional peer dependency, so a Bun service does not pull React in through Yatta.

Middleware runs on both paths

A check that lives in HTTP middleware is not run by api.getUser(). A Server Component calling a route directly sails straight past an authorisation check that HTTP enforces — and that is the worst place for the gap, because the code looks guarded.

So middleware belongs to the app, and runs on every path into a handler. A middleware that returns a value is answering instead of the handler; one that returns nothing lets the handler run.

api-contract.tsts
import { createApp, defineRoute, HttpError } from "yatta.js/universal"; const routes = { getUser: defineRoute(/* … */) }; export const api = createApp(routes, {  services: { db, auth },   middleware: [    async ({ name, ctx, services }) => {      const session = await services.auth.session(ctx);      if (!session) throw new HttpError(401, "Not signed in");    },  ],});
Note
App middleware runs outside a route's own middleware, so a route cannot accidentally shadow a cross-cutting check. And a middleware that returns a value stops the handler — a check that returns the user instead of throwing would otherwise still let the handler run, making every cache in the chain decorative.

One query, not fifty

A Server Component that awaits fifty routes issues fifty queries. It is not visible in the source — there is no repeated call, there is Promise.all over a list, and every call is correct on its own.

page.tsxts
import { loaderFor, withLoaders } from "yatta.js/batcher"; export default async function Page() {  return withLoaders(async () => {    const loader = loaderFor<string, User>("users", async (ids) => {      const rows = await db.users.findMany({ where: { id: { in: [...ids] } } });      return new Map(rows.map((row) => [row.id, row]));    });     // Fifty concurrent loads, one query.    const users = await Promise.all(ids.map((id) => loader.load(id)));    return <List users={users} />;  });}
Warning
Batching collects calls made in the same tick, which is what Promise.all produces. Fifty sequential awaits are fifty ticks and stay fifty queries — batching them would mean holding the first result open until the last arrived, which is unbounded latency for a guess. For that shape, add one route that returns a list.
Note
withLoaders is what makes this safe on a server. A loader cached on the app would be shared by every concurrent request — a cache of one user's data inside another user's response. Scoped to the call, it lives exactly as long as the work does.

Invalidating a whole route

A mutation on a user has to invalidate getUser for every id, not for the one id you happen to be looking at. Name the route:

tsts
await useMutation(api.updateUser, {  invalidates: [api.getUser, api.listUsers],   // every call to each, whatever its arguments}).call({ body });

Listing individual keys means writing down every argument a component has ever fetched, which is wrong the moment one asks for an id nobody predicted.

Cancelling

signal is forwarded to fetch and readable from ctx.signal on both paths. A search box typed three more characters can abandon the two answers nobody is waiting for.

tsts
const controller = new AbortController();await api.search({ query: { q }, signal: controller.signal });
Note
The server-side work still runs — a query cannot be un-issued. What the signal saves is the client's connection and the work of decoding a response nobody will read.

Path templates

One parser serves the client, the router and the handler, because three implementations of “what does this path mean” is how a route starts matching without getting its parameter.

tsts
/users              static/users/:id          one required parameter/users/:id?         one optional parameter, last segment only/files/*path        a wildcard, last segment only, may span slashes
  • A static segment is matched before a parameter, so /users/new does not become a fetch of the user whose id is the string "new".
  • An optional parameter in the middle is refused at startup, because /posts/:id?/comments and /posts/comments cannot be told apart.
  • A wildcard that is not last is refused, for the same reason.
  • A parameter is encoded whole, so a value containing a slash cannot add a path segment. A wildcard keeps its slashes and encodes each piece — that is the difference between the two.