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-invalid
to mark invalid fields - Using
aria-describedby
to 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.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)