Skip to main content
In a full-stack project the web app talks to 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.
import { apiClient } from '@/services/api-client.service';

// resource → method → call
const user = await apiClient.users.getCurrent();
const page = await apiClient.users.list({ page: 1, perPage: 20 });
The shape mirrors the API directory: 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.
import { useApiQuery } from '@/hooks';
import { apiClient } from '@/services/api-client.service';

// no input
const { data: user, isLoading } = useApiQuery(apiClient.users.getCurrent);

// with input
const { data: page } = useApiQuery(apiClient.users.list, { page: 1, perPage: 20 });

// with TanStack Query options
const { data } = useApiQuery(apiClient.users.list, { page: 1 }, { staleTime: 30_000 });
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.
import { useApiMutation } from '@/hooks';
import { apiClient } from '@/services/api-client.service';
import { handleApiError } from '@/utils';

const { mutate, isPending } = useApiMutation(apiClient.users.patchCurrent);

mutate(
  { fullName: 'Ada Lovelace' },
  { onError: (e) => handleApiError(e) },
);
Dynamic path params come from the input — there’s no separate 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 the ORPC_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.
import { queryKey } from '@/hooks';
import { apiClient } from '@/services/api-client.service';
import queryClient from '@/query-client';

// key for a list query with input
const key = queryKey(apiClient.users.list, { page: 1 });

// invalidate every users.list query
queryClient.invalidateQueries({ queryKey: queryKey(apiClient.users.list) });

// seed the cache after a mutation
queryClient.setQueryData(queryKey(apiClient.users.getCurrent), updatedUser);
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:
import { createFileRoute, redirect } from '@tanstack/react-router';

import { queryKey } from '@/hooks';
import { apiClient } from '@/services/api-client.service';
import queryClient from '@/query-client';

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: async () => {
    const key = queryKey(apiClient.users.getCurrent);
    if (queryClient.getQueryData(key)) return;

    try {
      await queryClient.fetchQuery({
        queryKey: key,
        queryFn: () => apiClient.users.getCurrent(),
      });
    } catch {
      throw redirect({ to: '/sign-in' });
    }
  },
});

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:
import { useCurrentUser } from '@/hooks';

function Greeting() {
  const { data: user } = useCurrentUser();
  return <span>Hi, {user?.fullName}</span>;
}
It also subscribes to the 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.