Next.js Form Error Handling
When building web applications with Next.js, form validation and error handling are crucial aspects that directly impact user experience. Well-implemented error handling makes your forms more accessible, user-friendly, and secure. This guide will walk you through different approaches to handle form errors in Next.js applications.
Understanding Form Error Handling
Form error handling involves:
- Validating user input
- Displaying meaningful error messages
- Preventing form submission with invalid data
- Providing visual feedback to help users correct mistakes
In Next.js, we have multiple options to handle these requirements effectively.
Client-Side Validation
Basic HTML Validation
Next.js forms can leverage built-in HTML form validation attributes:
export default function SimpleForm() {
  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <div>
        <label htmlFor="email">Email:</label>
        <input 
          type="email" 
          id="email" 
          required 
          pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$"
        />
      </div>
      <div>
        <label htmlFor="password">Password:</label>
        <input 
          type="password" 
          id="password" 
          required 
          minLength="8" 
        />
      </div>
      <button type="submit">Submit</button>
    </form>
  );
}
This approach uses HTML attributes like required, pattern, minLength, etc., to validate form fields. The browser will prevent form submission and display default error messages if validation fails.
Custom JavaScript Validation
For more control over validation and error messages, you can implement custom validation:
import { useState } from 'react';
export default function CustomValidationForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState({});
  const validateForm = () => {
    const newErrors = {};
    
    // Email validation
    if (!email) {
      newErrors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(email)) {
      newErrors.email = 'Email is invalid';
    }
    
    // Password validation
    if (!password) {
      newErrors.password = 'Password is required';
    } else if (password.length < 8) {
      newErrors.password = 'Password must be at least 8 characters';
    }
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  const handleSubmit = (e) => {
    e.preventDefault();
    if (validateForm()) {
      // Form is valid, proceed with submission
      console.log('Form submitted successfully!', { email, password });
    }
  };
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email:</label>
        <input 
          type="email" 
          id="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
        {errors.email && <p className="error">{errors.email}</p>}
      </div>
      <div>
        <label htmlFor="password">Password:</label>
        <input 
          type="password" 
          id="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
        {errors.password && <p className="error">{errors.password}</p>}
      </div>
      <button type="submit">Submit</button>
    </form>
  );
}
This approach gives you complete control over validation logic and custom error messages.
Using Form Libraries
React Hook Form
React Hook Form is a popular choice for handling forms in Next.js due to its performance and developer experience:
import { useForm } from 'react-hook-form';
export default function ReactHookFormExample() {
  const { 
    register, 
    handleSubmit, 
    formState: { errors } 
  } = useForm();
  
  const onSubmit = (data) => {
    console.log('Form submitted successfully!', data);
  };
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email:</label>
        <input 
          id="email"
          {...register('email', { 
            required: 'Email is required', 
            pattern: {
              value: /\S+@\S+\.\S+/,
              message: 'Email is invalid'
            }
          })}
        />
        {errors.email && <p className="error">{errors.email.message}</p>}
      </div>
      
      <div>
        <label htmlFor="password">Password:</label>
        <input 
          type="password" 
          id="password"
          {...register('password', { 
            required: 'Password is required',
            minLength: {
              value: 8,
              message: 'Password must be at least 8 characters'
            }
          })}
        />
        {errors.password && <p className="error">{errors.password.message}</p>}
      </div>
      
      <button type="submit">Submit</button>
    </form>
  );
}
React Hook Form offers:
- Reduced re-renders
- Built-in validation
- Easy error handling
- Form state management
Formik with Yup
Formik paired with Yup provides a comprehensive solution:
import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as Yup from 'yup';
const validationSchema = Yup.object({
  email: Yup.string()
    .email('Invalid email address')
    .required('Email is required'),
  password: Yup.string()
    .min(8, 'Password must be at least 8 characters')
    .required('Password is required')
});
export default function FormikExample() {
  const handleSubmit = (values, { setSubmitting }) => {
    // Submit form data
    console.log('Form submitted successfully!', values);
    setSubmitting(false);
  };
  return (
    <Formik
      initialValues={{ email: '', password: '' }}
      validationSchema={validationSchema}
      onSubmit={handleSubmit}
    >
      {({ isSubmitting }) => (
        <Form>
          <div>
            <label htmlFor="email">Email:</label>
            <Field type="email" name="email" id="email" />
            <ErrorMessage name="email" component="p" className="error" />
          </div>
          
          <div>
            <label htmlFor="password">Password:</label>
            <Field type="password" name="password" id="password" />
            <ErrorMessage name="password" component="p" className="error" />
          </div>
          
          <button type="submit" disabled={isSubmitting}>
            {isSubmitting ? 'Submitting...' : 'Submit'}
          </button>
        </Form>
      )}
    </Formik>
  );
}
The Yup schema provides a declarative way to define validation rules.
Server-Side Validation
While client-side validation improves user experience, server-side validation is essential for security. In Next.js, you can implement server-side validation in API routes:
// File: pages/api/register.js
export default function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }
  const { email, password } = req.body;
  const errors = {};
  // Server-side validation
  if (!email) {
    errors.email = 'Email is required';
  } else if (!/\S+@\S+\.\S+/.test(email)) {
    errors.email = 'Email is invalid';
  }
  if (!password) {
    errors.password = 'Password is required';
  } else if (password.length < 8) {
    errors.password = 'Password must be at least 8 characters';
  }
  // Return errors if validation fails
  if (Object.keys(errors).length > 0) {
    return res.status(400).json({ errors });
  }
  // Process valid form data
  // ...
  res.status(200).json({ success: true });
}
Combining Client and Server Validation
Here's a complete example combining client and server validation:
// File: pages/register.js
import { useState } from 'react';
import { useForm } from 'react-hook-form';
export default function RegisterForm() {
  const [serverErrors, setServerErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitSuccess, setSubmitSuccess] = useState(false);
  
  const { 
    register, 
    handleSubmit, 
    formState: { errors: clientErrors }
  } = useForm();
  
  const onSubmit = async (data) => {
    setIsSubmitting(true);
    setServerErrors({});
    
    try {
      const response = await fetch('/api/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
      });
      
      const result = await response.json();
      
      if (!response.ok) {
        // Server returned validation errors
        setServerErrors(result.errors || {});
      } else {
        // Successful submission
        setSubmitSuccess(true);
      }
    } catch (error) {
      setServerErrors({ form: 'An unexpected error occurred' });
    } finally {
      setIsSubmitting(false);
    }
  };
  if (submitSuccess) {
    return <div className="success-message">Registration successful!</div>;
  }
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {serverErrors.form && (
        <div className="server-error">{serverErrors.form}</div>
      )}
      
      <div>
        <label htmlFor="email">Email:</label>
        <input 
          id="email"
          {...register('email', { 
            required: 'Email is required', 
            pattern: {
              value: /\S+@\S+\.\S+/,
              message: 'Email is invalid'
            }
          })}
        />
        {clientErrors.email && (
          <p className="error">{clientErrors.email.message}</p>
        )}
        {serverErrors.email && (
          <p className="server-error">{serverErrors.email}</p>
        )}
      </div>
      
      <div>
        <label htmlFor="password">Password:</label>
        <input 
          type="password" 
          id="password"
          {...register('password', { 
            required: 'Password is required',
            minLength: {
              value: 8,
              message: 'Password must be at least 8 characters'
            }
          })}
        />
        {clientErrors.password && (
          <p className="error">{clientErrors.password.message}</p>
        )}
        {serverErrors.password && (
          <p className="server-error">{serverErrors.password}</p>
        )}
      </div>
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Register'}
      </button>
    </form>
  );
}
Styling Form Errors
Error messages should be visually distinct to help users identify issues. Here's a simple CSS example:
.error, .server-error {
  color: #d32f2f;
  font-size: 0.8rem;
  margin-top: 4px;
}
.server-error {
  border-left: 3px solid #d32f2f;
  padding-left: 8px;
  background-color: rgba(211, 47, 47, 0.05);
}
input.error {
  border-color: #d32f2f;
}
.success-message {
  color: #2e7d32;
  background-color: rgba(46, 125, 50, 0.1);
  padding: 16px;
  border-radius: 4px;
  border-left: 4px solid #2e7d32;
}
Accessibility Considerations
When implementing form error handling, ensure it's accessible to all users:
import { useState } from 'react';
export default function AccessibleForm() {
  const [email, setEmail] = useState('');
  const [errors, setErrors] = useState({});
  const validateEmail = () => {
    if (!email) {
      setErrors({...errors, email: 'Email is required'});
      return false;
    }
    
    if (!/\S+@\S+\.\S+/.test(email)) {
      setErrors({...errors, email: 'Email is invalid'});
      return false;
    }
    
    // Clear error if validation passes
    const newErrors = {...errors};
    delete newErrors.email;
    setErrors(newErrors);
    return true;
  };
  return (
    <form>
      <div>
        <label htmlFor="email">Email:</label>
        <input 
          type="email" 
          id="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          onBlur={validateEmail}
          aria-invalid={errors.email ? 'true' : 'false'}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <p 
            id="email-error" 
            role="alert" 
            className="error"
          >
            {errors.email}
          </p>
        )}
      </div>
      <button type="submit">Submit</button>
    </form>
  );
}
Key accessibility features:
- Using aria-invalidto mark invalid fields
- Using aria-describedbyto link error messages to input fields
- Using role="alert"for screen readers to announce errors
- On-blur validation for immediate feedback
Real-World Example: Contact Form
Here's a comprehensive contact form example incorporating the techniques we've discussed:
import { useState } from 'react';
import { useForm } from 'react-hook-form';
export default function ContactForm() {
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitSuccess, setSubmitSuccess] = useState(false);
  const [serverError, setServerError] = useState(null);
  
  const { 
    register, 
    handleSubmit, 
    reset,
    formState: { errors } 
  } = useForm();
  
  const onSubmit = async (data) => {
    setIsSubmitting(true);
    setServerError(null);
    
    try {
      // In a real app, this would be your API endpoint
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
      });
      
      if (!response.ok) {
        throw new Error('Failed to submit form');
      }
      
      // Success handling
      setSubmitSuccess(true);
      reset(); // Clear the form
    } catch (error) {
      setServerError(error.message);
    } finally {
      setIsSubmitting(false);
    }
  };
  return (
    <div className="contact-form-container">
      {submitSuccess ? (
        <div className="success-message">
          <h3>Thank you for your message!</h3>
          <p>We'll get back to you as soon as possible.</p>
          <button onClick={() => setSubmitSuccess(false)}>
            Send another message
          </button>
        </div>
      ) : (
        <form onSubmit={handleSubmit(onSubmit)} noValidate>
          {serverError && (
            <div className="server-error">
              <p>{serverError}</p>
            </div>
          )}
          
          <div className="form-group">
            <label htmlFor="name">Name</label>
            <input
              id="name"
              {...register('name', { required: 'Name is required' })}
              aria-invalid={errors.name ? 'true' : 'false'}
              aria-describedby={errors.name ? 'name-error' : undefined}
            />
            {errors.name && (
              <p id="name-error" className="error" role="alert">
                {errors.name.message}
              </p>
            )}
          </div>
          
          <div className="form-group">
            <label htmlFor="email">Email</label>
            <input
              id="email"
              type="email"
              {...register('email', { 
                required: 'Email is required',
                pattern: {
                  value: /\S+@\S+\.\S+/,
                  message: 'Please enter a valid email address'
                }
              })}
              aria-invalid={errors.email ? 'true' : 'false'}
              aria-describedby={errors.email ? 'email-error' : undefined}
            />
            {errors.email && (
              <p id="email-error" className="error" role="alert">
                {errors.email.message}
              </p>
            )}
          </div>
          
          <div className="form-group">
            <label htmlFor="subject">Subject</label>
            <input
              id="subject"
              {...register('subject', { required: 'Subject is required' })}
              aria-invalid={errors.subject ? 'true' : 'false'}
              aria-describedby={errors.subject ? 'subject-error' : undefined}
            />
            {errors.subject && (
              <p id="subject-error" className="error" role="alert">
                {errors.subject.message}
              </p>
            )}
          </div>
          
          <div className="form-group">
            <label htmlFor="message">Message</label>
            <textarea
              id="message"
              rows="5"
              {...register('message', { 
                required: 'Message is required',
                minLength: {
                  value: 20,
                  message: 'Message must be at least 20 characters'
                }
              })}
              aria-invalid={errors.message ? 'true' : 'false'}
              aria-describedby={errors.message ? 'message-error' : undefined}
            ></textarea>
            {errors.message && (
              <p id="message-error" className="error" role="alert">
                {errors.message.message}
              </p>
            )}
          </div>
          
          <button 
            type="submit" 
            disabled={isSubmitting} 
            className="submit-button"
          >
            {isSubmitting ? 'Sending...' : 'Send Message'}
          </button>
        </form>
      )}
    </div>
  );
}
Summary
Effective form error handling in Next.js involves:
- 
Client-side validation - For immediate user feedback - HTML built-in validation
- Custom JavaScript validation
- Form libraries like React Hook Form or Formik
 
- 
Server-side validation - For security and data integrity - Implementing validation in API routes
- Returning clear validation errors to the client
 
- 
User experience considerations - Clear, specific error messages
- Accessible error presentation
- Visual indication of errors
- Real-time validation where appropriate
 
- 
Combining approaches - Using both client and server validation
- Handling different types of errors consistently
 
By implementing these techniques, you'll create forms that are user-friendly, accessible, and secure.
Additional Resources
- React Hook Form Documentation
- Formik Documentation
- Yup Validation Schema
- Web Accessibility Initiative (WAI) Form Guidelines
- Next.js API Routes Documentation
Exercises
- 
Create a registration form with email, password, and password confirmation fields. Implement both client and server-side validation. 
- 
Add real-time validation feedback that shows errors as users type or when they leave a field. 
- 
Enhance the contact form example with additional fields like phone number and implement custom validation patterns. 
- 
Implement a multi-step form with validation at each step before proceeding to the next. 
- 
Create a form that handles file uploads with validation for file type and size. 
💡 Found a typo or mistake? Click "Edit this page" to suggest a correction. Your feedback is greatly appreciated!