Skip to main content

form-handling

Next.js forms with Server Actions — useActionState, useFormStatus, useOptimistic, zod validation with field errors, progressive enhancement, file uploads and the Form component. Use when building or fixing a form in the App Router. Choosing a Route Handler for an endpoint is api-design, refreshing data after a mutation is data-fetching.

Source facts

Repository
Agents-Store/codex-plugins
Last source activity
November 1, 2026 at 02:46
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
form-handling
description
Next.js forms with Server Actions — useActionState, useFormStatus, useOptimistic, zod validation with field errors, progressive enhancement, file uploads and the Form component. Use when building or fixing a form in the App Router. Choosing a Route Handler for an endpoint is api-design, refreshing data after a mutation is data-fetching.
# Form Handling Next.js forms use Server Actions for mutations with progressive enhancement — forms work even without JavaScript. ## Basic Server Action Form The simplest pattern — a form that calls a Server Action directly: ```tsx // app/actions.ts 'use server' import { redirect } from 'next/navigation' import { revalidatePath } from 'next/cache' export async function createInvoice(formData: FormData) { const rawFormData = { customerId: formData.get('customerId') as string, amount: Number(formData.get('amount')), status: formData.get('status') as string, } // Insert into database... await db.invoice.create({ data: rawFormData }) revalidatePath('/invoices') redirect('/invoices') } ``` ```tsx // app/invoices/create/page.tsx import { createInvoice } from '@/app/actions' export default function CreateInvoicePage() { return ( <form action={createInvoice}> <input name="customerId" type="text" required /> <input name="amount" type="number" required /> <select name="status"> <option value="pending">Pending</option> <option value="paid">Paid</option> </select> <button type="submit">Create Invoice</button> </form> ) } ``` This form works **without JavaScript** (progressive enhancement). The `action` attribute submits directly to the server. ## Form State with `useActionState` For showing validation errors and pending state: ```tsx // app/actions.ts 'use server' export type FormState = { message: string errors?: { [key: string]: string[] } } export async function createUser(prevState: FormState, formData: FormData) { const name = formData.get('name') as string const email = formData.get('email') as string // Validate const errors: { [key: string]: string[] } = {} if (!name || name.length < 2) { errors.name = ['Name must be at least 2 characters'] } if (!email || !email.includes('@')) { errors.email = ['Please enter a valid email'] } if (Object.keys(errors).length > 0) { return { message: 'Validation failed', errors } } // Create user... await db.user.create({ data: { name, email } }) return { message: 'User created successfully' } } ``` ```tsx // app/ui/signup-form.tsx 'use client' import { useActionState } from 'react' import { createUser, type FormState } from '@/app/actions' const initialState: FormState = { message: '' } export function SignupForm() { const [state, formAction, pending] = useActionState(createUser, initialState) return ( <form action={formAction}> <div> <label htmlFor="name">Name</label> <input id="name" name="name" type="text" required /> {state.errors?.name && ( <p className="text-red-500 text-sm">{state.errors.name[0]}</p> )} </div> <div> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" required /> {state.errors?.email && ( <p className="text-red-500 text-sm">{state.errors.email[0]}</p> )} </div> <button type="submit" disabled={pending}> {pending ? 'Creating...' : 'Sign Up'} </button> {state.message && <p aria-live="polite">{state.message}</p>} </form> ) } ``` ## Submit Button with `useFormStatus` Extract the submit button for reusable pending state: ```tsx // app/ui/submit-button.tsx 'use client' import { useFormStatus } from 'react-dom' export function SubmitButton({ children }: { children: React.ReactNode }) { const { pending } = useFormStatus() return ( <button type="submit" disabled={pending}> {pending ? 'Submitting...' : children} </button> ) } ``` **Important:** `useFormStatus` must be used in a component that is a **child** of the `<form>`. It does not work in the same component that renders the form. ## Zod Validation Use Zod for type-safe schema validation in Server Actions. Note for zod v4 (current): `z.string().email()` is deprecated in favor of `z.email()`, and the `errorMap` option is replaced by `error`: ```typescript // app/lib/schemas.ts import { z } from 'zod' export const CreatePostSchema = z.object({ title: z.string().min(1, 'Title is required').max(100, 'Title too long'), content: z.string().min(10, 'Content must be at least 10 characters'), published: z.coerce.boolean().default(false), }) export type CreatePostInput = z.infer<typeof CreatePostSchema> ``` ```typescript // app/actions.ts 'use server' import { CreatePostSchema } from '@/app/lib/schemas' import { revalidatePath } from 'next/cache' export type PostFormState = { message: string errors?: Record<string, string[]> success?: boolean } export async function createPost( prevState: PostFormState, formData: FormData ): Promise<PostFormState> { const parsed = CreatePostSchema.safeParse({ title: formData.get('title'), content: formData.get('content'), published: formData.get('published'), }) if (!parsed.success) { return { message: 'Validation failed', errors: parsed.error.flatten().fieldErrors, } } try { await db.post.create({ data: parsed.data }) revalidatePath('/posts') return { message: 'Post created', success: true } } catch (e) { return { message: 'Database error. Failed to create post.' } } } ``` ## Optimistic Updates with `useOptimistic` Show optimistic UI while the Server Action is pending: ```tsx // app/ui/messages.tsx 'use client' import { useOptimistic } from 'react' import { sendMessage } from '@/app/actions' type Message = { id: string; text: string; sending?: boolean } export function Messages({ messages }: { messages: Message[] }) { const [optimisticMessages, addOptimisticMessage] = useOptimistic( messages, (state, newMessage: string) => [ ...state, { id: crypto.randomUUID(), text: newMessage, sending: true }, ] ) async function formAction(formData: FormData) { const text = formData.get('text') as string addOptimisticMessage(text) await sendMessage(text) } return ( <> <ul> {optimisticMessages.map((msg) => ( <li key={msg.id} className={msg.sending ? 'opacity-50' : ''}> {msg.text} {msg.sending && <span className="ml-2 text-sm">(Sending...)</span>} </li> ))} </ul> <form action={formAction}> <input name="text" type="text" required /> <button type="submit">Send</button> </form> </> ) } ``` ## File Uploads Handle file uploads via FormData in Server Actions: ```typescript // app/actions.ts 'use server' export async function uploadFile(formData: FormData) { const file = formData.get('file') as File if (!file || file.size === 0) { return { error: 'No file selected' } } // Validate file type and size const MAX_SIZE = 5 * 1024 * 1024 // 5MB const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'] if (file.size > MAX_SIZE) { return { error: 'File too large (max 5MB)' } } if (!ALLOWED_TYPES.includes(file.type)) { return { error: 'Invalid file type' } } const bytes = await file.arrayBuffer() const buffer = Buffer.from(bytes) // Save to disk or upload to S3/R2/etc. await writeFile(`/uploads/${file.name}`, buffer) return { success: true, filename: file.name } } ``` ```tsx // app/ui/upload-form.tsx 'use client' import { useActionState } from 'react' import { uploadFile } from '@/app/actions' export function UploadForm() { const [state, formAction, pending] = useActionState(uploadFile, null) return ( <form action={formAction}> <input type="file" name="file" accept="image/*" required /> <button type="submit" disabled={pending}> {pending ? 'Uploading...' : 'Upload'} </button> {state?.error && <p className="text-red-500">{state.error}</p>} {state?.success && <p className="text-green-500">Uploaded!</p>} </form> ) } ``` ## Next.js `<Form>` Component Next.js provides a `<Form>` component that extends `<form>` with prefetching, client-side navigation, and progressive enhancement: ```tsx import Form from 'next/form' export function SearchForm() { return ( <Form action="/search"> <input name="q" type="text" placeholder="Search..." /> <button type="submit">Search</button> </Form> ) } ``` When JavaScript is loaded, `<Form>` navigates client-side on submit. For GET forms (search, filters), it prefetches the target route's loading UI. ## Common Pitfalls | Problem | Cause | Fix | |---------|-------|-----| | Form doesn't reset after submit | State not cleared | Return new initial state or use `key` prop on form | | `useFormStatus` always shows `pending: false` | Component not inside `<form>` | Move `useFormStatus` to a child component of the form | | Stale data after mutation | Missing revalidation | Call `revalidatePath()` or `revalidateTag()` in Server Action | | Redirect after action fails silently | `redirect()` thrown inside try/catch | Call `redirect()` outside the try/catch block | | Type error on `formData.get()` | Returns `FormDataEntryValue | null` | Cast with `as string` or use Zod to parse | ## What This Skill Does NOT Cover - Third-party form libraries (React Hook Form, Formik) — use their docs - Client-only forms without Server Actions — standard React patterns - Complex multi-step wizard flows — combine patterns above with URL state - Real-time validation (debounced API calls) — use `useTransition` + fetch
View on GitHub