# Working with Forms

> Build type-safe forms with TanStack Form, Zod validation, and TanStack Start server functions in your TanStack Start Drizzle SaaS application.

*Canonical: https://makerkit.dev/docs/tanstack-drizzle/development-guide/working-with-forms*

---

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`

```typescript
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`

```typescript
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](#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`

```typescript
'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.

```typescript
'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:

```typescript
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 `throw`s `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:

```typescript
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:

```typescript
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).

```typescript
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`:

```typescript
'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:

```typescript
'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):

```typescript
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

```typescript
// ✅ 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

```typescript
<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

```typescript
toast.promise(mutation.mutateAsync({ data }), {
  loading: 'Processing...',
  success: 'Success!',
  error: 'Failed',
});
```

### 4. Show Loading States

```typescript
const mutation = useMutation({ mutationFn: myFunction });
const isPending = mutation.isPending;

<Button disabled={isPending}>
  {isPending ? 'Saving...' : 'Save'}
</Button>
```

### 5. Always Validate on the Server

```typescript
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:

```typescript
<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](#revalidating-after-a-mutation).

```typescript
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`:

```typescript
<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:

```typescript
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:

```typescript
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>
  );
}
```

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "Why use toast.promise instead of try/catch?", "answer": "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."},
     {"question": "When should I use mutate vs mutateAsync?", "answer": "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)."},
     {"question": "How do I refresh data after a mutation?", "answer": "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'."},
     {"question": "Why is the mutation called with { data }?", "answer": "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 })."},
     {"question": "Can I use the same schema for create and update forms?", "answer": "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() })."}
   ]
/%}

## Related Documentation

- [Server Functions Guide](./server-functions)
- [Function Middleware](./function-middleware)
- [UI Components](../ui-components/overview)
- [Adding Features](./adding-features)

---

**Next:** [Database Operations →](./database-operations)
