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 revalidatePath in 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

SituationRefresh?How
Mutation changes data rendered by a route loader (tables, names, role/status badges, avatars)Yesawait router.invalidate() in the success path
List rendered from a TanStack Query (useQuery), not a loaderYesqueryClient.invalidateQueries(...) or the query's refetch()
Mutation ends by navigating away (router.navigate(...), window.location) or the server function throws redirect()NoNavigation 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)NoNothing 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.user is available from authFunctionMiddleware
  • 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

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 server
export const schema = z.object({
email: z.email(),
});
// Form uses the schema via validators
const form = useForm({
defaultValues: { email: '' },
validators: { onChange: schema, onSubmit: schema },
onSubmit: ({ value }) => {
/* ... */
},
});
// Server function uses the same schema
export 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?
toast.promise automatically shows loading, success, and error states from a single promise. It reduces boilerplate and provides a consistent user experience. Pass it mutation.mutateAsync({ data }) so it tracks the request lifecycle.
When should I use mutate vs mutateAsync?
Use mutateAsync when you need to await the result and chain follow-up work (like toast.promise, form.reset(), or closing a dialog). Use mutate when you're handling results through the useMutation callbacks (onSuccess, onError, onSettled).
How do I refresh data after a mutation?
There is no revalidatePath in TanStack Start, and mutations do not auto-refresh loaders. If a mutation changes data a route loader renders, you MUST await router.invalidate() (from useRouter()) in the success path before closing the dialog or resolving the toast. For lists backed by useQuery instead of a loader, use queryClient.invalidateQueries(...) or the query's refetch(). No refresh is needed when the flow navigates away (router.navigate / redirect()). See 'Revalidating After a Mutation'.
Why is the mutation called with { data }?
Server functions created with createServerFn receive their validated input under the data key, so the mutation is called as mutation.mutate({ data }) and the handler reads it as ({ data }).
Can I use the same schema for create and update forms?
Yes. Use .partial() for update schemas to make all fields optional, then extend with required fields like id: updateProjectSchema = createProjectSchema.partial().extend({ id: z.string() }).

Next: Database Operations →