# Next.js Forms Standard

> **Scope:** frontend/nextjs/forms
> **Layer:** 2 (on keyword)
> **Keywords:** form, react-hook-form, zod, validation, input, submit, zodResolver
> **Load When:** implementing any form in Next.js

**Verified against:** Next.js 15 + react-hook-form 7 + Zod 4 + @hookform/resolvers 3. Last-verified: 2026-05-20.

---

react-hook-form + Zod + shadcn Form components. Schema defines both validation rules and TypeScript types.

## Core Rules

- ALWAYS define the Zod schema first — derive the TypeScript type from it with `z.infer<>`
- ALWAYS use `zodResolver` to connect schema to react-hook-form
- ALWAYS use shadcn `<Form>`, `<FormField>`, `<FormItem>`, `<FormMessage>` for consistent UI
- NEVER use `useState` for form field values — react-hook-form handles this
- ALWAYS use `useMutation` from TanStack Query for form submission

## Complete Form Pattern

```tsx
// features/users/types/user.schemas.ts
import { z } from 'zod';

export const createUserSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.email('Invalid email address'),
  role: z.enum(['admin', 'user'], { error: 'Role is required' }),
});

export type CreateUserInput = z.infer<typeof createUserSchema>;
```

```tsx
// features/users/components/create-user-form.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
  Form, FormControl, FormField, FormItem, FormLabel, FormMessage
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useCreateUser } from '@/features/users/hooks/use-create-user';
import { createUserSchema, type CreateUserInput } from '@/features/users/types/user.schemas';

export function CreateUserForm({ onSuccess }: { onSuccess?: () => void }) {
  const form = useForm<CreateUserInput>({
    resolver: zodResolver(createUserSchema),
    defaultValues: { name: '', email: '', role: 'user' },
  });

  const { mutate: createUser, isPending } = useCreateUser();

  function onSubmit(values: CreateUserInput) {
    createUser(values, {
      onSuccess: () => { form.reset(); onSuccess?.(); },
      onError: (error) => form.setError('root', { message: error.message }),
    });
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Name</FormLabel>
              <FormControl><Input placeholder="John Doe" {...field} /></FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl><Input type="email" {...field} /></FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        {form.formState.errors.root && (
          <p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
        )}
        <Button type="submit" disabled={isPending}>
          {isPending ? 'Creating...' : 'Create User'}
        </Button>
      </form>
    </Form>
  );
}
```

## Edit Form Pattern

```tsx
// Edit forms reuse the same schema with partial()
const form = useForm<UpdateUserInput>({
  resolver: zodResolver(updateUserSchema),
  defaultValues: { name: user.name, email: user.email },
});
```

## Schema Composition

```ts
export const userSchema = z.object({ id: z.string(), name: z.string(), email: z.string() });
export const createUserSchema = userSchema.omit({ id: true });
export const updateUserSchema = createUserSchema.partial();
```

## Common Mistakes

| Wrong | Right | Why |
|-------|-------|-----|
| `useState` for each field | `useForm` | Unnecessary re-renders on every keystroke |
| Manual error display | `<FormMessage />` | Consistent UI, auto-connects to field errors |
| `fetch` in submit handler | `useMutation` | Loading state, error handling, cache invalidation |
| Type written manually | `z.infer<typeof schema>` | Single source of truth |
| `z.string().email()` | `z.email()` | Zod 4 moved format checks to top-level functions |
| `{ required_error: '...' }` | `{ error: '...' }` | Zod 4 unified error customization on one `error` param |

---

*MORPH-SPEC by Polymorphism Tech*
