apps/api through a fully typed oRPC client. You call endpoints as plain methods — apiClient.users.getCurrent() — and wrap them in TanStack Query with two thin hooks: useApiQuery and useApiMutation.
The oRPC client and these hooks ship with the Auth plugin (
auth-starter). Install it and apps/web gains the client, the hooks, and the authenticated app shell. The base web app has no API wiring.The client
The client lives in@/services/api-client.service.ts. It’s an oRPC client typed by the API’s exported AppClient, so every resource, method, input and output is real — change an endpoint’s .output() and the call site’s types update on the next API type build.
apiClient.<resource>.<method>(input). Method names are the codegen-derived names from the contract — users.getCurrent, users.patchCurrent, users.list, files.upload, files.getUrl, files.remove.
Queries
useApiQuery takes a procedure and an optional input. It derives the query key from the procedure itself, so you never hand-write keys.
data is typed from the endpoint’s .output() schema. When the procedure takes no input you can pass query options as the second argument directly — the hook tells an input apart from an options object for you.
Mutations
useApiMutation wraps a procedure in useMutation. The error type is ORPCError (an Error with optional data and status), which is exactly what handleApiError expects.
pathParams argument. apiClient.users.update({ userId, ...changes }) resolves to PUT /users/{userId}; the path segment and the body are one typed input.
Query keys
Keys are derived from the procedure via theORPC_PATH symbol the client attaches to every node. queryKey(procedure, input) returns the path array (plus the input when present), so invalidating or seeding a query never means stringly-typed keys.
useApiQuery calls queryKey for you; reach for it directly only when you invalidate or write the cache by hand. This is the same primitive the authenticated route uses to prefetch the current user:
The current user
Reading the signed-in user is common enough to have its own hook,useCurrentUser, built on the same pieces — apiClient.users.getCurrent and a derived key cached forever:
user:updated socket event and writes fresh data straight into the cache, so the UI stays current without refetching. See Services for the socket wiring.
Where types come from
The web app never imports a generated client bundle. The API emits TypeScript declarations (pnpm --filter api build:types) and the web app depends on "api": "workspace:*", importing import type { AppClient } from 'api'. The contract — import { contract } from 'api/contract' — drives the oRPC link’s routing. One source of truth, no drift. See How Ship works.