Ship forms are react-hook-form + Zod, submitted through the typed oRPC client. Three pieces do the work: useApiForm (a form bound to a Zod schema), useApiMutation (the typed mutation), and handleApiError (server errors mapped back onto fields).
useApiForm, useApiMutation and handleApiError ship with the Auth plugin (auth-starter), which adds the full-stack web wiring on top of the base app.
useApiForm takes a Zod schema and returns a typed react-hook-form instance with the zodResolver already applied. The form’s values are inferred from the schema — no separate FormParams type to maintain.
import { z } from 'zod';
import { useApiForm } from '@/hooks';
const schema = z.object({
fullName: z.string().min(1, 'Full name is required').max(128),
});
const form = useApiForm(schema, { mode: 'onBlur' });
// ^ UseFormReturn<{ fullName: string }>
Pass any useForm options (mode, defaultValues, …) as the second argument; only resolver is owned by the hook.
This repo uses Zod 4. Reach for z.email(), z.url(), z.uuid() rather than Zod 3’s z.string().email().
Submitting
Pair the form with useApiMutation for a matching endpoint. On success, seed or invalidate the cache; on error, hand the error to handleApiError:
import { useApiForm, useApiMutation, queryKey } from '@/hooks';
import { apiClient } from '@/services/api-client.service';
import { handleApiError } from '@/utils';
import queryClient from '@/query-client';
const form = useApiForm(schema, { defaultValues: { fullName: '' } });
const { mutate, isPending } = useApiMutation(apiClient.users.patchCurrent);
const onSubmit = form.handleSubmit((data) =>
mutate(data, {
onSuccess: (user) => queryClient.setQueryData(queryKey(apiClient.users.getCurrent), user),
onError: (e) => handleApiError(e, form.setError),
}),
);
handleApiError
handleApiError(e, setError?) from @/utils reads the structured errors object off an oRPC error:
- Field errors (
{ fullName: 'Full name is required' }) are pushed onto the form via setError, focusing the first one.
- A
global error is surfaced as a Sonner toast.
Pass form.setError to route messages onto fields; omit it to only toast.
import { handleApiError } from '@/utils';
mutate(data, { onError: (e) => handleApiError(e, form.setError) });
Complete example
A real route component — a TanStack Router route under apps/web/src/routes/**. No document <head> helpers, no page-scope wrappers; layout and auth are handled by the parent route (_authenticated). The form lives in FormProvider so child inputs can use useFormContext.
import { createFileRoute } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
import { FormProvider } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { queryKey, useApiForm, useApiMutation, useCurrentUser } from '@/hooks';
import { apiClient } from '@/services/api-client.service';
import { handleApiError } from '@/utils';
import queryClient from '@/query-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const profileSchema = z.object({
fullName: z.string().min(1, 'Full name is required').max(128),
});
function ProfileForm() {
const { data: user } = useCurrentUser();
const methods = useApiForm(profileSchema, {
mode: 'onBlur',
defaultValues: { fullName: user?.fullName ?? '' },
});
const { mutate, isPending } = useApiMutation(apiClient.users.patchCurrent);
const onSubmit = methods.handleSubmit((data) =>
mutate(data, {
onSuccess: (updated) => {
if (!updated) return;
queryClient.setQueryData(queryKey(apiClient.users.getCurrent), updated);
toast.success('Your profile has been updated.');
methods.reset({ fullName: updated.fullName });
},
onError: (e) => handleApiError(e, methods.setError),
}),
);
const { register, formState } = methods;
return (
<FormProvider {...methods}>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="fullName">Full Name</Label>
<Input
{...register('fullName')}
id="fullName"
placeholder="Enter full name"
className={formState.errors.fullName ? 'border-destructive' : ''}
/>
{formState.errors.fullName && (
<p className="text-sm text-destructive">{formState.errors.fullName.message}</p>
)}
</div>
<Button type="submit" disabled={!formState.isDirty || isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</form>
</FormProvider>
);
}
export const Route = createFileRoute('/_authenticated/app/profile')({
component: ProfileForm,
});
The mutation input is typed from the endpoint’s .input() schema, the response is typed from its .output(), and validation messages your handler returns under errors land back on the right fields — one schema, checked on both sides. See Calling the API for the client and query keys.