Next.js

Form Handling & Validation

26 min Lesson 19 of 40

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.

Modern Approach: Next.js Server Actions allow you to handle form submissions without creating API routes, providing automatic progressive enhancement and simplified error handling.

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>
  )
}
Performance Benefit: React Hook Form uses uncontrolled components and minimizes re-renders, making it perfect for forms with many fields.

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 }
    )
  }
}
Security: Never trust client-side validation alone. Always validate on the server to prevent malicious requests bypassing your validation logic.

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>
  )
}
Practice Exercise:
  1. Create a multi-step registration form using React Hook Form
  2. Implement Zod schema validation with custom error messages
  3. Add async validation to check if email is available
  4. Create a Server Action that validates and saves the form data
  5. Implement optimistic UI updates using useOptimistic
  6. 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.