Form Handling & Validation
Introduction to Form Handling
Forms are essential for user interaction in web applications. Next.js 13+ with Server Actions provides powerful patterns for form handling that are both simple and secure. This lesson covers comprehensive form implementation with validation, error handling, and best practices.
Basic Form with Server Actions
Let's start with a simple contact form using Server Actions:
<!-- app/contact/page.tsx -->
import { submitContactForm } from './actions'
export default function ContactPage() {
return (
<div className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Contact Us</h1>
<form action={submitContactForm} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
type="email"
id="email"
name="email"
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message
</label>
<textarea
id="message"
name="message"
required
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
className="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
Send Message
</button>
</form>
</div>
)
}
<!-- app/contact/actions.ts -->
'use server'
export async function submitContactForm(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
const message = formData.get('message') as string
// Process the form data
console.log({ name, email, message })
// In a real app, you would save to database or send email
// await prisma.contact.create({ data: { name, email, message } })
return { success: true }
}
React Hook Form for Complex Forms
For complex forms with many fields and validation logic, React Hook Form provides excellent performance and developer experience:
# Install React Hook Form npm install react-hook-form
<!-- app/components/user-form.tsx -->
'use client'
import { useForm } from 'react-hook-form'
import { useState } from 'react'
interface UserFormData {
firstName: string
lastName: string
email: string
age: number
phone: string
address: string
city: string
zipCode: string
}
export function UserForm() {
const [isSubmitting, setIsSubmitting] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
reset
} = useForm<UserFormData>()
const onSubmit = async (data: UserFormData) => {
setIsSubmitting(true)
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
if (response.ok) {
alert('User created successfully!')
reset()
}
} catch (error) {
console.error('Submit error:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">
First Name
</label>
<input
{...register('firstName', {
required: 'First name is required',
minLength: {
value: 2,
message: 'First name must be at least 2 characters'
}
})}
className="w-full px-3 py-2 border rounded-lg"
/>
{errors.firstName && (
<p className="text-red-500 text-sm mt-1">
{errors.firstName.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">
Last Name
</label>
<input
{...register('lastName', {
required: 'Last name is required'
})}
className="w-full px-3 py-2 border rounded-lg"
/>
{errors.lastName && (
<p className="text-red-500 text-sm mt-1">
{errors.lastName.message}
</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address'
}
})}
className="w-full px-3 py-2 border rounded-lg"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Age</label>
<input
type="number"
{...register('age', {
required: 'Age is required',
min: { value: 18, message: 'Must be at least 18 years old' },
max: { value: 120, message: 'Invalid age' }
})}
className="w-full px-3 py-2 border rounded-lg"
/>
{errors.age && (
<p className="text-red-500 text-sm mt-1">{errors.age.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}
Zod for Type-Safe Validation
Zod provides runtime type checking and validation that works seamlessly with TypeScript:
# Install Zod npm install zod
<!-- lib/validations/user-schema.ts -->
import { z } from 'zod'
export const userSchema = z.object({
firstName: z.string()
.min(2, 'First name must be at least 2 characters')
.max(50, 'First name must be less than 50 characters'),
lastName: z.string()
.min(2, 'Last name must be at least 2 characters')
.max(50, 'Last name must be less than 50 characters'),
email: z.string()
.email('Invalid email address')
.toLowerCase(),
age: z.number()
.int('Age must be a whole number')
.min(18, 'Must be at least 18 years old')
.max(120, 'Invalid age'),
phone: z.string()
.regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
confirmPassword: z.string(),
website: z.string().url('Invalid URL').optional().or(z.literal('')),
bio: z.string()
.max(500, 'Bio must be less than 500 characters')
.optional(),
terms: z.boolean()
.refine(val => val === true, 'You must accept the terms and conditions')
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
})
export type UserFormData = z.infer<typeof userSchema>
Integrating React Hook Form with Zod
Combine React Hook Form with Zod for the best developer experience:
# Install the resolver npm install @hookform/resolvers
<!-- app/components/validated-form.tsx -->
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { userSchema, type UserFormData } from '@/lib/validations/user-schema'
export function ValidatedForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: {
firstName: '',
lastName: '',
email: '',
age: 18,
phone: '',
password: '',
confirmPassword: '',
website: '',
bio: '',
terms: false
}
})
const onSubmit = async (data: UserFormData) => {
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
if (response.ok) {
alert('Registration successful!')
reset()
} else {
const error = await response.json()
alert(error.message || 'Registration failed')
}
} catch (error) {
console.error('Submit error:', error)
alert('An error occurred. Please try again.')
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto p-6">
<h2 className="text-2xl font-bold mb-6">Registration Form</h2>
<div>
<label className="block text-sm font-medium mb-1">First Name</label>
<input
{...register('firstName')}
className="w-full px-3 py-2 border rounded-lg"
/>
{errors.firstName && (
<p className="text-red-500 text-sm mt-1">{errors.firstName.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name</label>
<input
{...register('lastName')}
className="w-full px-3 py-2 border rounded-lg"
/>
{errors.lastName && (
<p className="text-red-500 text-sm mt-1">{errors.lastName.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-lg"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Age</label>
<input
type="number"
{...register('age', { valueAsNumber: true })}
className="w-full px-3 py-2 border rounded-lg"
/>
{errors.age && (
<p className="text-red-500 text-sm mt-1">{errors.age.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
{...register('password')}
className="w-full px-3 py-2 border rounded-lg"
/>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm Password</label>
<input
type="password"
{...register('confirmPassword')}
className="w-full px-3 py-2 border rounded-lg"
/>
{errors.confirmPassword && (
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword.message}</p>
)}
</div>
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
{...register('terms')}
className="w-4 h-4"
/>
<span className="text-sm">I accept the terms and conditions</span>
</label>
{errors.terms && (
<p className="text-red-500 text-sm mt-1">{errors.terms.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Submitting...' : 'Register'}
</button>
</form>
)
}
Server-Side Validation
Always validate on the server, even if you validate on the client:
<!-- app/api/users/route.ts -->
import { NextResponse } from 'next/server'
import { userSchema } from '@/lib/validations/user-schema'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
export async function POST(request: Request) {
try {
const body = await request.json()
// Validate with Zod
const validatedData = userSchema.parse(body)
// Check if email already exists
const existingUser = await prisma.user.findUnique({
where: { email: validatedData.email }
})
if (existingUser) {
return NextResponse.json(
{ error: 'Email already registered' },
{ status: 400 }
)
}
// Hash password
const hashedPassword = await bcrypt.hash(validatedData.password, 12)
// Create user
const user = await prisma.user.create({
data: {
firstName: validatedData.firstName,
lastName: validatedData.lastName,
email: validatedData.email,
age: validatedData.age,
phone: validatedData.phone,
hashedPassword,
bio: validatedData.bio,
website: validatedData.website,
}
})
return NextResponse.json(
{ message: 'User created successfully', userId: user.id },
{ status: 201 }
)
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', issues: error.issues },
{ status: 400 }
)
}
console.error('Registration error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
Server Actions with Validation
Use Server Actions with Zod for type-safe form handling:
<!-- app/actions/user-actions.ts -->
'use server'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import bcrypt from 'bcryptjs'
const createUserSchema = z.object({
firstName: z.string().min(2),
lastName: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
})
export async function createUser(prevState: any, formData: FormData) {
try {
const rawData = {
firstName: formData.get('firstName'),
lastName: formData.get('lastName'),
email: formData.get('email'),
password: formData.get('password'),
}
const validatedData = createUserSchema.parse(rawData)
const existingUser = await prisma.user.findUnique({
where: { email: validatedData.email }
})
if (existingUser) {
return {
success: false,
error: 'Email already registered'
}
}
const hashedPassword = await bcrypt.hash(validatedData.password, 12)
await prisma.user.create({
data: {
...validatedData,
hashedPassword,
}
})
revalidatePath('/users')
return {
success: true,
message: 'User created successfully'
}
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: 'Validation failed',
issues: error.issues
}
}
return {
success: false,
error: 'Failed to create user'
}
}
}
<!-- app/components/user-form-with-action.tsx -->
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { createUser } from '../actions/user-actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button
type="submit"
disabled={pending}
className="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{pending ? 'Creating...' : 'Create User'}
</button>
)
}
export function UserFormWithAction() {
const [state, formAction] = useFormState(createUser, null)
return (
<form action={formAction} className="space-y-4 max-w-md mx-auto p-6">
{state?.error && (
<div className="p-3 text-red-500 bg-red-50 rounded-lg">
{state.error}
</div>
)}
{state?.success && (
<div className="p-3 text-green-500 bg-green-50 rounded-lg">
{state.message}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">First Name</label>
<input
type="text"
name="firstName"
required
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name</label>
<input
type="text"
name="lastName"
required
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
name="email"
required
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
name="password"
required
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<SubmitButton />
</form>
)
}
- Create a multi-step registration form using React Hook Form
- Implement Zod schema validation with custom error messages
- Add async validation to check if email is available
- Create a Server Action that validates and saves the form data
- Implement optimistic UI updates using useOptimistic
- Add file upload with validation for image size and type
Form Best Practices
- Always validate on both client and server
- Provide clear, helpful error messages
- Show validation errors inline near the relevant fields
- Disable submit buttons during submission
- Use progressive enhancement with Server Actions
- Implement proper loading states and feedback
- Clear sensitive fields (passwords) on submission error
- Use appropriate input types (email, tel, url) for better UX
Summary
Modern form handling in Next.js combines Server Actions, React Hook Form, and Zod validation for type-safe, performant, and secure form implementations. Always validate on both client and server, provide clear feedback, and use progressive enhancement for the best user experience.