Working with Forms
Build type-safe forms with TanStack Form, Zod validation, and TanStack Start server functions in your TanStack Start Drizzle SaaS application.
Forms connect your UI to server functions. The TanStack Start Drizzle kit uses a powerful form stack that provides type safety from schema definition through to the server:
- TanStack Form (
@tanstack/react-form): Form state management with fine-grained subscriptions - @kit/ui/field: Pre-styled field primitives (
Field,FieldLabel,FieldDescription,FieldError) - Zod: Schema validation shared between client and server
- TanStack Query: Server functions are called through
useMutation, which owns the request lifecycle (isPending,onSuccess,onError)
This guide shows you the complete pattern used throughout the application.
The Complete Form Pattern
1. Define Zod Schema
The first step is to define the Zod schema for the form. This schema will be used to validate the form data on the server and client sides.
File: schemas/feature.schema.ts
import { z } from 'zod';export const createProjectSchema = z.object({ name: z.string().min(3, 'Name must be at least 3 characters'), description: z.string().optional(),});export type CreateProjectInput = z.output<typeof createProjectSchema>;2. Create the Server Function
Next, create the server function that validates the input and writes to the database. Pass the shared schema to .validator(), and read the authenticated user from the middleware-injected context. Suffix the export with Action so it's obvious the function runs on the server.
File: lib/server/functions/create-project.functions.ts
import { createServerFn } from '@tanstack/react-start';import { authFunctionMiddleware } from '@kit/function-middleware/functions';import { requireActiveOrganizationId } from '@kit/better-auth/context';import { db, project } from '@kit/database';import { getLogger } from '@kit/shared/logger';import { generateId } from '@kit/shared/uuid';import { createProjectSchema } from '../../schemas/feature.schema';export const createProjectFunction = createServerFn({ method: 'POST' }) .middleware(authFunctionMiddleware) .validator(createProjectSchema) .handler(async ({ data, context }) => { // context.user is available from authFunctionMiddleware const organizationId = await requireActiveOrganizationId(); const logger = (await getLogger()).child({ userId: context.user.id, projectName: data.name, organizationId, }); logger.info('User attempting to create project...'); const [newProject] = await db .insert(project) .values({ // uuidv7 is a good choice for the id — unique and easy to generate. // Alternatively use gen_random_uuid() as a column default in the schema. id: generateId(), name: data.name, description: data.description, organizationId, }) .returning(); logger.info({ projectId: newProject.id }, 'Project created successfully'); return { projectId: newProject.id }; });Refreshing data: there is no
revalidatePathin TanStack Start. If a mutation changes data a route loader renders, you must refresh it explicitly — see Revalidating After a Mutation below. This is easy to forget and shows up as stale tables, badges, or names after an otherwise-successful save.
3. Build the Form Component
Now build the form. It uses useForm from @tanstack/react-form for form state, the Zod schema for validation via validators, and useMutation to call the server function. This mirrors the real UpdateOrganizationNameForm in the kit.
File: components/project-form.tsx
'use client';import { useForm } from '@tanstack/react-form';import { useMutation } from '@tanstack/react-query';import { Button } from '@kit/ui/button';import { Field, FieldError, FieldLabel } from '@kit/ui/field';import { Input } from '@kit/ui/input';import { Textarea } from '@kit/ui/textarea';import { toast } from '@kit/ui/sonner';import { createProjectFunction } from '../lib/server/functions/create-project.functions';import { createProjectSchema } from '../schemas/feature.schema';export function ProjectForm() { const createProject = useMutation({ mutationFn: createProjectFunction }); const form = useForm({ defaultValues: { name: '', description: '', }, validators: { onChange: createProjectSchema, onSubmit: createProjectSchema, }, onSubmit: ({ value }) => { toast.promise( createProject.mutateAsync({ data: value }).then(() => form.reset()), { loading: 'Creating project...', success: 'Project created successfully!', error: 'Failed to create project', }, ); }, }); const isPending = createProject.isPending; return ( <form onSubmit={(e) => { e.preventDefault(); void form.handleSubmit(); }} className="space-y-4" > <form.Field name="name"> {(field) => { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( <Field data-invalid={isInvalid}> <FieldLabel>Project Name</FieldLabel> <Input name={field.name} placeholder="Enter project name" disabled={isPending} data-testid="project-name-input" value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} aria-invalid={isInvalid} /> <FieldError errors={field.state.meta.errors} /> </Field> ); }} </form.Field> <form.Field name="description"> {(field) => { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( <Field data-invalid={isInvalid}> <FieldLabel>Description (Optional)</FieldLabel> <Textarea name={field.name} rows={4} placeholder="Describe your project" disabled={isPending} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} aria-invalid={isInvalid} /> <FieldError errors={field.state.meta.errors} /> </Field> ); }} </form.Field> <Button type="submit" disabled={isPending} data-testid="submit-button"> {isPending ? 'Creating...' : 'Create Project'} </Button> </form> );}Note: adding data-testid attributes to the form fields is a good practice for end-to-end testing. It's going to be very helpful to have these attributes when writing tests.
Note the call shape: the server function receives { data }, so the mutation is called as createProject.mutateAsync({ data }).
Revalidating After a Mutation
In TanStack Start the route loader owns server data. A server function mutation does not re-run any loader — there is no revalidatePath equivalent that fires automatically. Whenever a mutation changes data that a route loader renders, you must refresh it explicitly, or the UI keeps showing stale values until the next navigation. This is the single most common regression when writing a TanStack Start server function that mutates loader-backed data.
The rule: in the mutation's success path, await router.invalidate() before you close the dialog or let the success toast resolve. Awaiting matters — it guarantees the loader has refetched before the user is told the save succeeded.
'use client';import { useForm } from '@tanstack/react-form';import { useMutation } from '@tanstack/react-query';import { useRouter } from '@tanstack/react-router';import { toast } from '@kit/ui/sonner';export function RenameForm() { const router = useRouter(); const rename = useMutation({ mutationFn: renameFunction }); const form = useForm({ defaultValues: { name: '' }, validators: { onChange: renameSchema, onSubmit: renameSchema }, onSubmit: ({ value }) => { toast.promise( rename .mutateAsync({ data: value }) .then(() => router.invalidate()), // ← refetch loader data { loading: 'Saving...', success: 'Saved', error: 'Failed to save', }, ); }, }); // ...}With the onSuccess callback form, make it async and await the invalidate first:
const rename = useMutation({ mutationFn: renameFunction, onSuccess: async () => { await router.invalidate(); onOpenChange(false); },});When it's required — and when it isn't
| Situation | Refresh? | How |
|---|---|---|
Mutation changes data rendered by a route loader (tables, names, role/status badges, avatars) | Yes | await router.invalidate() in the success path |
List rendered from a TanStack Query (useQuery), not a loader | Yes | queryClient.invalidateQueries(...) or the query's refetch() |
Mutation ends by navigating away (router.navigate(...), window.location) or the server function throws redirect() | No | Navigation re-runs loaders |
| Write-only data with nothing displayed (password change), or a value that only changes after out-of-band confirmation (email change pending verification) | No | Nothing to refresh yet |
If a reusable UI primitive can't reach the router (e.g. a shared component under @kit/ui), don't hard-wire useRouter() into it — expose an onUpdate callback prop and let each caller pass () => router.invalidate(). This keeps generic primitives decoupled from routing while still refreshing loader data at the call site.
Calling Server Functions with TanStack Query
Authenticated Server Function
All mutating server functions attach a middleware tuple from @kit/function-middleware, which guarantees the user is logged in:
import { z } from 'zod';import { createServerFn } from '@tanstack/react-start';import { authFunctionMiddleware } from '@kit/function-middleware/functions';const mySchema = z.object({ name: z.string().min(3),});export const myFunction = createServerFn({ method: 'POST' }) .middleware(authFunctionMiddleware) .validator(mySchema) .handler(async ({ data, context }) => { // data: validated input from the schema // context.user: current authenticated user const user = context.user; // { id, email, ... } return { success: true }; });Key Points:
- Import middleware tuples from
@kit/function-middleware context.useris available fromauthFunctionMiddleware- Mutations are
{ method: 'POST' }by default
Server Function with Permission Checks
Layer the withFeaturePermission middleware for declarative permission checks:
import { z } from 'zod';import { createServerFn } from '@tanstack/react-start';import { authFunctionMiddleware } from '@kit/function-middleware/functions';import { withFeaturePermission } from '@kit/function-middleware/server';const schema = z.object({ name: z.string(),});export const protectedFunction = createServerFn({ method: 'POST' }) .middleware([...authFunctionMiddleware, withFeaturePermission({ project: ['create'] })]) .validator(schema) .handler(async ({ data, context }) => { // Permission already verified by middleware // context.organizationId is available return { success: true }; });Server Function with Logging
Logging is super important for debugging and monitoring.
It's a good practice to log the user id, the action name, the input data (but be careful with sensitive information), and each step of an async operation (before executing, after executing, and in case of errors).
import { z } from 'zod';import { createServerFn } from '@tanstack/react-start';import { authFunctionMiddleware } from '@kit/function-middleware/functions';import { getLogger } from '@kit/shared/logger';const mySchema = z.object({ name: z.string() });export const myFunction = createServerFn({ method: 'POST' }) .middleware(authFunctionMiddleware) .validator(mySchema) .handler(async ({ data, context }) => { const logger = (await getLogger()).child({ userId: context.user.id, action: 'my-action', }); logger.info('Action started'); try { // Do work logger.info('Action completed successfully'); return { success: true }; } catch (error) { logger.error({ error }, 'Action failed'); throw error; } });Form Patterns
Pattern 1: With toast.promise (Recommended)
Best for most forms — provides automatic toast notifications. This is the exact pattern used by UpdateOrganizationNameForm:
'use client';import { useForm } from '@tanstack/react-form';import { useMutation } from '@tanstack/react-query';import { toast } from '@kit/ui/sonner';export function MyForm() { const mutation = useMutation({ mutationFn: myFunction }); const form = useForm({ defaultValues: { /* ... */ }, validators: { onChange: mySchema, onSubmit: mySchema }, onSubmit: ({ value }) => { toast.promise( mutation.mutateAsync({ data: value }).then(() => form.reset()), { loading: 'Saving...', success: 'Saved successfully!', error: 'Failed to save', }, ); }, }); const isPending = mutation.isPending; return ( <form onSubmit={(e) => { e.preventDefault(); void form.handleSubmit(); }} > {/* Form fields */} <Button disabled={isPending}> {isPending ? 'Saving...' : 'Save'} </Button> </form> );}Pattern 2: With mutate and callbacks
Use when you need custom success/error handling:
'use client';import { useForm } from '@tanstack/react-form';import { useMutation } from '@tanstack/react-query';export function MyForm({ onSuccess }: { onSuccess: () => void }) { const mutation = useMutation({ mutationFn: myFunction, onSuccess, }); const form = useForm({ defaultValues: { /* ... */ }, validators: { onChange: mySchema, onSubmit: mySchema }, onSubmit: ({ value }) => mutation.mutate({ data: value }), }); const isPending = mutation.isPending; return ( <form onSubmit={(e) => { e.preventDefault(); void form.handleSubmit(); }} > {mutation.isError && ( <Alert variant="destructive">{mutation.error.message}</Alert> )} {/* Form fields */} <Button disabled={isPending}> {isPending ? 'Saving...' : 'Save'} </Button> </form> );}Form Dialogs
Combining forms with dialogs (the pattern used in the codebase):
import { useForm } from '@tanstack/react-form';import { useMutation } from '@tanstack/react-query';import { useRouter } from '@tanstack/react-router';import { Button } from '@kit/ui/button';import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from '@kit/ui/dialog';import { Field, FieldError, FieldLabel } from '@kit/ui/field';import { Input } from '@kit/ui/input';import { toast } from '@kit/ui/sonner';import { createProjectFunction } from '../lib/server/functions/create-project.functions';import { createProjectSchema } from '../schemas/project.schema';export function CreateProjectDialog({ open, onOpenChange,}: { open: boolean; onOpenChange: (open: boolean) => void;}) { return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent onEscapeKeyDown={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} > <DialogHeader> <DialogTitle>Create New Project</DialogTitle> <DialogDescription> Enter the details for your new project. </DialogDescription> </DialogHeader> <CreateProjectForm onOpenChange={onOpenChange} /> </DialogContent> </Dialog> );}function CreateProjectForm({ onOpenChange,}: { onOpenChange: (open: boolean) => void;}) { const router = useRouter(); const createProject = useMutation({ mutationFn: createProjectFunction }); const form = useForm({ defaultValues: { name: '', }, validators: { onChange: createProjectSchema, onSubmit: createProjectSchema, }, onSubmit: ({ value }) => { toast.promise( createProject .mutateAsync({ data: value }) // Refresh loader data so the new project shows in the list, // then reset the form and close the dialog. .then(() => router.invalidate()) .then(() => { form.reset(); onOpenChange(false); }), { loading: 'Creating project...', success: 'Project created successfully!', error: 'Failed to create project', }, ); }, }); const isPending = createProject.isPending; return ( <form onSubmit={(e) => { e.preventDefault(); void form.handleSubmit(); }} className="space-y-4" > <form.Field name="name"> {(field) => { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( <Field data-invalid={isInvalid}> <FieldLabel>Project Name</FieldLabel> <Input name={field.name} disabled={isPending} data-testid="project-name-input" value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} aria-invalid={isInvalid} /> <FieldError errors={field.state.meta.errors} /> </Field> ); }} </form.Field> <div className="flex justify-end gap-2"> <DialogClose render={ <Button type="button" variant="outline" disabled={isPending} onClick={() => form.reset()} > Cancel </Button> } /> <Button type="submit" disabled={isPending} data-testid="submit-button" > {isPending ? 'Creating...' : 'Create'} </Button> </div> </form> );}It's a good practice to declare the form component outside the dialog component. This makes the code more readable and maintainable — and ensures the form is only instantiated when the dialog is opened.
Form Best Practices
1. Always Use Zod Schemas
// ✅ Good - shared schema for client and serverexport const schema = z.object({ email: z.email(),});// Form uses the schema via validatorsconst form = useForm({ defaultValues: { email: '' }, validators: { onChange: schema, onSubmit: schema }, onSubmit: ({ value }) => { /* ... */ },});// Server function uses the same schemaexport const action = createServerFn({ method: 'POST' }) .middleware(authFunctionMiddleware) .validator(schema) .handler(/* ... */);2. Add data-testid for E2E Tests
<Input name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} data-testid="email-input"/><Button type="submit" data-testid="submit-button"> Submit</Button>3. Use toast.promise for Better UX
toast.promise(mutation.mutateAsync({ data }), { loading: 'Processing...', success: 'Success!', error: 'Failed',});4. Show Loading States
const mutation = useMutation({ mutationFn: myFunction });const isPending = mutation.isPending;<Button disabled={isPending}> {isPending ? 'Saving...' : 'Save'}</Button>5. Always Validate on the Server
export const myFunction = createServerFn({ method: 'POST' }) .middleware(authFunctionMiddleware) .validator(mySchema) // server-side validation .handler(async ({ data }) => { // data is already validated and typed });6. Use FieldError for Errors
FieldError from @kit/ui/field reads field.state.meta.errors and translates each message via i18n — you never render validation strings by hand:
<form.Field name="email"> {(field) => { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( <Field data-invalid={isInvalid}> <FieldLabel>Email</FieldLabel> <Input name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} aria-invalid={isInvalid} /> {/* Shows translated validation errors */} <FieldError errors={field.state.meta.errors} /> </Field> ); }}</form.Field>7. Revalidate Loader Data After Mutations
If the mutation changes anything a route loader renders, await router.invalidate() in the success path before closing the dialog or resolving the toast. See Revalidating After a Mutation.
const router = useRouter();toast.promise( mutation.mutateAsync({ data }).then(() => router.invalidate()), { loading: 'Saving...', success: 'Saved', error: 'Failed to save' },);Common Form Patterns
Binding Inputs
TanStack Form uses controlled inputs bound through the form.Field render prop. Wire value, onChange, and onBlur from field.state / field.handleChange / field.handleBlur:
<form.Field name="email"> {(field) => ( <Field data-invalid={field.state.meta.isTouched && !field.state.meta.isValid}> <FieldLabel>Email</FieldLabel> <Input name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} /> <FieldError errors={field.state.meta.errors} /> </Field> )}</form.Field>Conditional Fields
Show or hide fields based on other field values. Use form.Subscribe to react to another field without re-rendering the whole form:
return ( <form onSubmit={(e) => { e.preventDefault(); void form.handleSubmit(); }}> <form.Field name="type">{(field) => /* ... */}</form.Field> <form.Subscribe selector={(state) => state.values.type}> {(type) => type === 'organization' ? ( <form.Field name="organizationName">{(field) => /* ... */}</form.Field> ) : null } </form.Subscribe> </form>);Field Arrays
For dynamic lists of fields (like tags or team members), use form.Field in array mode with mode="array" and the pushValue / removeValue helpers:
function TeamMembersForm() { const form = useForm({ defaultValues: { members: [{ email: '' }], }, validators: { onChange: teamSchema, onSubmit: teamSchema }, onSubmit: ({ value }) => { /* ... */ }, }); return ( <form onSubmit={(e) => { e.preventDefault(); void form.handleSubmit(); }}> <form.Field name="members" mode="array"> {(membersField) => ( <> {membersField.state.value.map((_, index) => ( <div key={index} className="flex gap-2"> <form.Field name={`members[${index}].email`}> {(field) => ( <Field> <Input name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} /> </Field> )} </form.Field> <Button type="button" onClick={() => membersField.removeValue(index)} > Remove </Button> </div> ))} <Button type="button" onClick={() => membersField.pushValue({ email: '' })} > Add Member </Button> </> )} </form.Field> </form> );}Frequently Asked Questions
Why use toast.promise instead of try/catch?
When should I use mutate vs mutateAsync?
How do I refresh data after a mutation?
Why is the mutation called with { data }?
Can I use the same schema for create and update forms?
Related Documentation
Next: Database Operations →