Skip to content

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

  1. You write one Zod schema for your form
  2. The server validates FormData against it with parseFormData
  3. The client submits the form with useFormAction, using the same schema
  4. Validation errors come back mapped to each field, ready to display
  5. The schema is the single source of truth, so field names, types, and errors stay in sync
ExportModulePurpose
parseFormData(schema, data)@/formServer-side: validate FormData against a Zod schema, throwing a structured FormError on failure
useFormAction(schema, serverFn, options?)@/formClient-side hook: wraps a server function in a mutation and exposes onSubmit plus typed fields
  1. Define the schema:

    import { z } from "zod"
    const contactFormSchema = z.object({
    email: z.email(),
    message: z.string().min(1),
    })
  2. Create a server function and validate the incoming FormData with parseFormData:

    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!" }
    })
  3. 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.

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.