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.
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.
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.
// 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.
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.
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
400naming the field, on both paths. - An
HttpErrorkeeps 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
405with anAllowheader, not a404that 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
"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.
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
<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
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
<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
@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
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
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.
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.
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.
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.
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"); }, ],});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.
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} />; });}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.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:
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.
const controller = new AbortController();await api.search({ query: { q }, signal: controller.signal });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.
/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/newdoes 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?/commentsand/posts/commentscannot 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.