Forms
Type-safe form handling that ties together TanStack Start server functions, TanStack Query mutations, and Zod validation. The @/form package lets you share a single schema between client and server, submit native FormData, and surface per-field validation errors with full TypeScript inference.
How it works
- You write one Zod schema for your form
- The server validates
FormDataagainst it withparseFormData - The client submits the form with
useFormAction, using the same schema - Validation errors come back mapped to each field, ready to display
- The schema is the single source of truth, so field names, types, and errors stay in sync
| Export | Module | Purpose |
|---|---|---|
parseFormData(schema, data) | @/form | Server-side: validate FormData against a Zod schema, throwing a structured FormError on failure |
useFormAction(schema, serverFn, options?) | @/form | Client-side hook: wraps a server function in a mutation and exposes onSubmit plus typed fields |
Building a Form
Section titled “Building a Form”-
Define the schema:
import { z } from "zod"const contactFormSchema = z.object({email: z.email(),message: z.string().min(1),}) -
Create a server function and validate the incoming
FormDatawithparseFormData:import { createServerFn } from "@tanstack/react-start"import { parseFormData } from "@/form"const contactFormAction = createServerFn({ method: "POST" }).validator((data: FormData) => parseFormData(contactFormSchema, data)).handler(async ({ data }) => {// `data` is fully typed: { email: string; message: string }await saveContactMessage(data)return { success: "Thanks, we'll be in touch!" }}) -
Build the form component with
useFormAction:import { useFormAction } from "@/form"function ContactForm() {const { data, error, isPending, onSubmit, fields } = useFormAction(contactFormSchema,contactFormAction,)return (<form onSubmit={onSubmit} noValidate><input name={fields.email.name} type="email" />{fields.email.errors.map((err) => (<p key={err}>{err}</p>))}<textarea name={fields.message.name} />{fields.message.errors.map((err) => (<p key={err}>{err}</p>))}{error && <p>{error.message}</p>}{data?.success && <p>{data.success}</p>}<button type="submit" disabled={isPending}>{isPending ? "Sending..." : "Send"}</button></form>)}
onSubmit calls event.preventDefault(), builds a FormData from the form element, and triggers the mutation-so you never have to manage individual input state.
Mutation Options
Section titled “Mutation Options”The third argument to useFormAction is forwarded to TanStack Query’s useMutation, so you can hook into the mutation lifecycle:
import { useQueryClient } from "@tanstack/react-query"import { useFormAction } from "@/form"
function ContactForm() { const queryClient = useQueryClient()
const { onSubmit } = useFormAction(contactFormSchema, contactFormAction, { onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["messages"] }) }, })
// ...}The returned object also spreads the full mutation result, giving you access to isPending, isSuccess, reset, mutate, and everything else from useMutation.