The Query Layer
Contracts → api.op → exec → TanStack. The path that actually ships.
You’ve generated a typed client. Congratulations. Now don’t call api.op from random buttons like it’s 2019.
This page is the recommended app stack — the same shape used in production Education Hub code. Path-based api.get still works; this is the path you want when the app grows a second screen.
The stack
api.contracts.ts → named types (UserDto, CreateUserBody, …)
api.op.* → typed calls returning Result<T>
BaseQuery.exec → unwrap to data or QueryError
*.options.ts → queryOptions / mutationOptions
use-*.tsx → hooks your components importEach layer has one job. Skip a layer and you’ll rediscover why the layer existed.
1. Types from contracts
import type {
UserDto,
CreateUserBody,
ListUsersQueryParams,
} from "@/services/api/_generated/api.contracts";
export type User = UserDto;Not ServerModel<"UserDto">. Not ListUsersResponse["data"][number] when UserDto already exists. Import the thing with the name. See Type Helpers for the escape hatches.
2. Call with api.op
const { data, error } = await api.op.getUser({ id });Operation IDs from your OpenAPI spec become methods. No operationId? No method. Fix the spec.
3. Unwrap once
protected async exec<T>(promise: Promise<Result<T>>): Promise<T> {
const { data, error } = await promise;
if (error) throw new QueryError(error);
return data;
}Components and hooks should not negotiate with Result. The query class does. Details in Error Handling.
4. Options + hooks
static getUser = (id: string) =>
queryOptions({
queryKey: userQueryKeys.get(id),
queryFn: () => UserQuery.getUser(id),
enabled: !!id,
});Same options object for hooks and route loaders (ensureQueryData). One cache key to rule them all.
Query keys without stringly-typed regret
Ad-hoc ["user", "list"] works until invalidation becomes archaeology. A tiny factory keeps prefixes honest:
export function createQueryKeys(entity: string) {
const root = [entity] as const;
return {
root,
list: [...root, "list"] as const,
listWithParams: (params?: Record<string, unknown>) =>
[...root, "list", params] as const,
get: (id: string) => [...root, id] as const,
create: [...root, "create"] as const,
update: [...root, "update"] as const,
delete: [...root, "delete"] as const,
};
}import type { UserDto } from "@/services/api/_generated/api.contracts";
import { createQueryKeys } from "@/lib/query-keys.factory";
export type User = UserDto;
export const userQueryKeys = createQueryKeys("user");Invalidate with queryClient.invalidateQueries({ queryKey: userQueryKeys.root }) and move on with your life.
Daily scripts
Wire the CLI into package.json so watch mode is a habit, not a quest:
{
"scripts": {
"api:fetch": "chowbea-axios fetch",
"api:generate": "chowbea-axios generate",
"api:watch": "chowbea-axios watch",
"api:status": "chowbea-axios status",
"dev:all": "concurrently \"bun api:watch\" \"bun dev\""
}
}dev:all = backend types stay warm while you break the UI. Ideal.
Entrypoint name
The generator writes api.client.ts. Some apps rename or re-export it as api.http.ts. That’s fine — just pick one import path and stick to it. The docs say api.client.ts because that’s what init creates.
Full walkthrough
Want every file spelled out? → TanStack Query Example.