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