# Next.js Forms

You are building forms in a Next.js 16 App Router project with React 19, TypeScript and Zod 4. Write code for the versions in `package.json`, not the ones in your training data: Server Functions, `useActionState` and Zod 4's error helpers replace most of the form code you remember.

## Before you write code

- Read `node_modules/next/dist/docs/01-app/02-guides/forms.md`, `01-getting-started/07-mutating-data.md` and `02-guides/server-actions.md`. Their Zod snippets still use Zod 3 calls (`error.flatten()`, `invalid_type_error`): translate them as below.
- Check `package.json` for `zod` (4.x), `react-hook-form` with `@hookform/resolvers` (5.x reads Zod 4), `@tanstack/react-form`, and the UI kit's field parts (`components/ui/field.tsx`: `Field`, `FieldLabel`, `FieldDescription`, `FieldError`, `FieldGroup`, `FieldSet`, `FieldLegend`). Use what the project has.
- Find the project's session and permission helpers (a data access layer, `lib/auth`): every Server Function calls them.
- Check `next.config.ts` for `experimental.serverActions` (`bodySizeLimit`, `allowedOrigins`) before adding uploads or a proxy domain.

## Server Functions that validate and return state

- Keep the schema in a plain shared module (`lib/schemas/post.ts`) that both the form and the Server Function import. Never export it from the `"use server"` file: such a file may only export async functions (and types).
- A Server Function is a public POST endpoint. In each one, in this order: check the session, parse the input, authorize this user for this record, write. Never trust hidden inputs or bound IDs without checking ownership.
- Build the input from `FormData` field by field. `Object.fromEntries(formData)` adds `$ACTION_` keys and keeps only the last value of repeated names; use `formData.getAll("tags")` for those.
- Return expected failures (validation, a taken email, a failed write) as a typed state. Throw only for bugs; they reach `error.tsx`.
- Return the submitted text values with the errors: React resets an uncontrolled form after its action finishes, so the form must re-fill itself from state. Never echo passwords or files back.

```ts
// app/posts/actions.ts
"use server";

import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { PostSchema } from "@/lib/schemas/post";

type Fields = keyof z.input<typeof PostSchema>;
export type PostState = {
  errors?: Partial<Record<Fields, string[]>>;
  message?: string;
  values?: Partial<Record<Fields, string>>;
};

export async function createPost(_prev: PostState, formData: FormData): Promise<PostState> {
  const user = await requireUser(); // the project's session check
  const values = {
    body: String(formData.get("body") ?? ""),
    title: String(formData.get("title") ?? ""),
  };
  const parsed = PostSchema.safeParse(values);
  if (!parsed.success) {
    return { errors: z.flattenError(parsed.error).fieldErrors, values };
  }
  const post = await db.post.create({ data: { ...parsed.data, authorId: user.id } }).catch(() => null);
  if (!post) return { message: "The post could not be saved. Try again.", values };
  updateTag("posts");
  redirect(`/posts/${post.id}`);
}
```

## Validation with Zod 4

- Use the top-level formats: `z.email()`, `z.url()`, `z.uuid()`; `z.string().email()` is deprecated.
- Set messages with `error` or the string shorthand: `z.string().trim().min(1, "Enter a title.")`, `z.string({ error: "Enter a title." })`. `message` is deprecated; `invalid_type_error`, `required_error` and `errorMap` no longer exist.
- FormData holds strings, `File`s or `null`. Use `z.coerce.number()` for numbers but guard the empty string, which coerces to 0: `z.string().min(1, "Enter an amount.").pipe(z.coerce.number<string>().positive())` (the type argument lets `pipe` accept it). Checkboxes send `"on"` when checked and nothing otherwise: read `formData.has("terms")`, or parse the string with `z.stringbool()`.
- Read errors with `z.flattenError(error).fieldErrors` for flat forms and `z.treeifyError(error)` for nested objects and arrays; `z.prettifyError(error)` is for logs. `error.flatten()` and `error.format()` are deprecated.
- Use `safeParseAsync` when a refinement is async (a uniqueness check). Type form values with `z.input<typeof Schema>` and parsed data with `z.output<typeof Schema>` when coercion or defaults make them differ.
- Client-side checks are a courtesy; the server parse is the only one that counts. Keep native constraints (`required`, `type="email"`, `minLength`) that match the schema.

## The form component

- Render `<form action={formAction}>` with a `name` on every control. It submits before hydration and without JavaScript; do not swap it for `onSubmit` plus `fetch`.
- `const [state, formAction, isPending] = useActionState(createPost, initialState)` with `initialState` declared outside the component. With extra arguments, `createPost.bind(null, id)` makes the signature `(id, prevState, formData)`.
- Put the submit button in its own component that calls `useFormStatus()` from `react-dom`: it reads the parent form only, so it must render inside the `<form>`. Disable it while `pending` and keep its label.
- Re-fill fields with `defaultValue={state.values?.title}`. Use controlled inputs only when the UI needs the value while typing.
- Wire each error to its control: `aria-invalid` on the input, `aria-describedby` pointing at the error's id (with `useId()`), the error text in `FieldError`. Mark the wrapper `data-invalid` (or the kit's `invalid` prop) for styles. Show form-level messages (`state.message`) in an element with `role="status"` or `aria-live="polite"`.
- After a failed submit, move focus to the first invalid field from an effect on `state`, so keyboard and screen reader users land on the problem.
- With Base UI, pass `fieldErrors` to `<Form errors={…}>` and name each `Field.Root`; with React Aria, to `<Form validationErrors={…}>`. Both wire the ARIA for you.
- A form that needs no returned state can stay in a Server Component with an inline `"use server"` function; add a client component only for errors and pending UI.

## After a write

- `updateTag(tag)` when the user must see their own change on the next screen (Server Functions only). `revalidateTag(tag, "max")` for stale-while-revalidate elsewhere: the second argument is required. `revalidatePath("/posts")` when the data is not tagged, `refresh()` to re-render the current route without invalidating cached data.
- `redirect()` last, outside any `try`/`catch`: it throws a control-flow error. If a `catch` must wrap it, call `unstable_rethrow(error)` (from `next/navigation`) first in the `catch`.
- Show success that stays on the page (a toast, an inline status) from the returned state, not from a client-side guess.

## Optimistic updates

- `useOptimistic(serverItems, reducer)` for lists and toggles that should change at once. Call its setter inside the form action or a `startTransition`; React warns when it runs outside one.
- Keep the server's data as the source: the optimistic value drops when the action settles and the refreshed props arrive. Return an error in state and show it when the write fails, so the revert is explained.

## Files and search forms

- Uploads use `<input type="file" name="avatar" accept="image/*">` in a normal `<form action>`; React sets the encoding. On the server, validate the `File` with `z.file().max(5_000_000).mime(["image/png", "image/jpeg"])`. An empty file input still sends a `File` with `size` 0: treat that as no file.
- Server Action bodies are capped at 1 MB. Raise `experimental.serverActions.bodySizeLimit` (for example `"6mb"`, leaving room for multipart overhead) only as far as needed. For large or many files, upload straight to storage with a presigned URL and send only the key to the Server Function.
- A search or filter form is a GET navigation, not a mutation: use `next/form`'s `<Form action="/search">` with `<input name="q">`. It prefetches the target and navigates on the client. Read the query from `searchParams` (a promise) on the page.

## When a client form library earns its place

Reach for one only for field arrays, fields that depend on each other, validation on every keystroke across many fields, multi-step wizards, or controlled third-party inputs. The Server Function still parses with the same schema and returns errors.

- React Hook Form 7: `useForm({ resolver: zodResolver(Schema), defaultValues })` from `@hookform/resolvers/zod` infers input and output types; don't pass generics that fight it. Use `register` for native inputs and `<Controller render={({ field, fieldState }) => …}>` for the kit's Select, Checkbox or Combobox. In `handleSubmit`, call the Server Function inside `startTransition`, then map its errors back with `form.setError(name, { message })`.
- TanStack Form 1: `useForm({ defaultValues, validators: { onChange: Schema }, onSubmit: async ({ value }) => … })` takes a Zod schema directly (Standard Schema), no adapter. Render `<form.Field name="email">{(field) => …}</form.Field>` with `field.state.value`, `field.handleChange` and `field.handleBlur`; pass `field.state.meta.errors` to `<FieldError errors={…}>` and treat the field as invalid when `isTouched && !isValid`. Submit with `e.preventDefault(); form.handleSubmit()`.
- These forms need JavaScript. Say so when you choose one for a public form.

## Do not

- Do not use `useFormState` from `react-dom` (renamed `useActionState`, in `react`), Zod 3 error APIs, or `z.string().email()`.
- Do not create an API route for your own form, or post with `fetch` from `onSubmit` when a `<form action>` works.
- Do not call `redirect()` inside `try`, or return `undefined` from an action whose state the form reads.
- Do not validate only on the client, skip the auth check because the page is protected, or trust a hidden `userId`.
- Do not put `useFormStatus` in the component that renders the `<form>`, or mark a field `disabled` when its value must be sent (disabled controls are left out of FormData; use `readOnly`).
- Do not show errors by color alone, or render them without linking them to the input.
- Do not call `revalidateTag` with one argument, or call `updateTag` from a Route Handler.

## Before you finish

- Run the type checker and linter, and fix what they report.
- Submit each form with JavaScript disabled once: it must still post, show server errors and keep the typed values.
- Submit invalid input and check that every error is announced, linked by `aria-describedby`, and that focus lands on the first invalid field.
- Call the Server Function as another user (or signed out) and confirm it refuses.
- Confirm the page shows the new data after the write without a manual reload, and that uploads over the limit fail with a message, not a crash.
