Next.js Form Best Practices
Forms are essential components in web applications, serving as the primary means of collecting user input. In Next.js applications, implementing forms correctly not only improves user experience but also enhances security, accessibility, and maintainability of your code. This guide explores best practices for creating forms in Next.js applications.
Introduction to Forms in Next.js
Next.js, built on React, offers several approaches to handling forms. While React itself is unopinionated about form handling, Next.js introduces server-side capabilities that can enhance form submission and validation processes. Implementing forms in Next.js requires understanding both client-side and server-side concepts.
Client-Side Form Handling
Controlled Components
The React way of handling forms is through controlled components, where form elements are controlled by React state.
'use client';
import { useState } from 'react';
export default function ControlledForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
console.log({ name, email });
// Process form data here
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name:</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<button type="submit">Submit</button>
</form>
);
}
Form Libraries
For complex forms, consider using form libraries like Formik, React Hook Form, or Final Form. These libraries handle form state, validation, and submission, reducing boilerplate code.
Example using React Hook Form:
'use client';
import { useForm } from 'react-hook-form';
export default function HookForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => {
console.log(data);
// Process form data here
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Name:</label>
<input
id="name"
{...register("name", { required: "Name is required" })}
/>
{errors.name && <p>{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
{...register("email", {
required: "Email is required",
pattern: {
value: /^\S+@\S+$/i,
message: "Invalid email format"
}
})}
/>
{errors.email && <p>{errors.email.message}</p>}
</div>
<button type="submit">Submit</button>
</form>
);
}
Server-Side Form Handling in Next.js
Next.js 13+ introduced server components and server actions, which provide new ways to handle forms.
Using Server Actions (Next.js 13.4+)
Server actions allow you to handle form submissions directly on the server:
// app/actions.js
'use server';
export async function submitForm(formData) {
const name = formData.get('name');
const email = formData.get('email');
// Validate data
if (!name || !email) {
return { error: 'Name and email are required' };
}
// Process data (e.g., save to database)
try {
// Save to database or third-party service
return { success: true };
} catch (error) {
return { error: 'Failed to submit form' };
}
}
// app/form-page.js
import { submitForm } from './actions';
export default function FormPage() {
return (
<form action={submitForm}>
<div>
<label htmlFor="name">Name:</label>
<input id="name" name="name" required />
</div>
<div>
<label htmlFor="email">Email:</label>
<input id="email" name="email" type="email" required />
</div>
<button type="submit">Submit</button>
</form>
);
}
Form Validation Best Practices
Client-Side Validation
Client-side validation provides immediate feedback to users:
- HTML5 Validation Attributes: Use built-in attributes like
required
,minlength
,pattern
, etc. - JavaScript Validation: Implement custom validation logic for complex rules.
'use client';
import { useState } from 'react';
export default function ValidatedForm() {
const [formData, setFormData] = useState({
username: '',
password: '',
});
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
if (!formData.username) {
newErrors.username = 'Username is required';
} else if (formData.username.length < 3) {
newErrors.username = 'Username must be at least 3 characters';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
return newErrors;
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value
});
};
const handleSubmit = (e) => {
e.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length === 0) {
console.log('Form submitted:', formData);
// Process form data here
} else {
setErrors(validationErrors);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username">Username:</label>
<input
id="username"
name="username"
value={formData.username}
onChange={handleChange}
/>
{errors.username && <p className="error">{errors.username}</p>}
</div>
<div>
<label htmlFor="password">Password:</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
/>
{errors.password && <p className="error">{errors.password}</p>}
</div>
<button type="submit">Submit</button>
</form>
);
}
Server-Side Validation
Server-side validation is crucial for security:
// app/actions.js
'use server';
export async function registerUser(formData) {
const username = formData.get('username');
const password = formData.get('password');
const errors = {};
// Validate username
if (!username || username.length < 3) {
errors.username = 'Username must be at least 3 characters';
}
// Validate password
if (!password || password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
// If there are validation errors, return them
if (Object.keys(errors).length > 0) {
return { errors };
}
try {
// Process registration (e.g., hash password, save to database)
// ...
return { success: true };
} catch (error) {
return { errors: { general: 'Registration failed' } };
}
}
Accessibility Best Practices
Ensuring your forms are accessible is essential for all users:
- Use Proper Labels: Always associate labels with form controls using the
htmlFor
attribute. - Group Related Fields: Use
fieldset
andlegend
for related form inputs. - Provide Error Messages: Make error messages clear and associate them with their respective fields.
- Keyboard Navigation: Ensure forms can be navigated and submitted using only the keyboard.
'use client';
import { useState } from 'react';
export default function AccessibleForm() {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: ''
});
const [errors, setErrors] = useState({});
// Form handling logic here...
return (
<form onSubmit={handleSubmit} noValidate>
<fieldset>
<legend>Personal Information</legend>
<div>
<label htmlFor="firstName">First Name:</label>
<input
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
aria-invalid={errors.firstName ? "true" : "false"}
aria-describedby={errors.firstName ? "firstName-error" : undefined}
/>
{errors.firstName && (
<p id="firstName-error" className="error" role="alert">
{errors.firstName}
</p>
)}
</div>
<div>
<label htmlFor="lastName">Last Name:</label>
<input
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
aria-invalid={errors.lastName ? "true" : "false"}
aria-describedby={errors.lastName ? "lastName-error" : undefined}
/>
{errors.lastName && (
<p id="lastName-error" className="error" role="alert">
{errors.lastName}
</p>
)}
</div>
</fieldset>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
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}
</p>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
Security Best Practices
Security is paramount when handling user input:
- Validate on Both Sides: Validate data on both client and server sides.
- Sanitize Inputs: Always sanitize user input to prevent XSS attacks.
- CSRF Protection: Use CSRF tokens to protect against cross-site request forgery.
- Rate Limiting: Implement rate limiting for form submissions to prevent abuse.
Next.js includes built-in CSRF protection when using server actions:
// app/actions.js
'use server';
import { sanitize } from 'some-sanitization-library';
export async function submitContactForm(formData) {
// Sanitize inputs
const name = sanitize(formData.get('name'));
const message = sanitize(formData.get('message'));
// Validate inputs
// ...
// Process data
// ...
}
Real-World Example: Contact Form with Feedback
Here's a complete example of a contact form with client validation, server-side processing, and user feedback:
// app/actions.js
'use server';
import { z } from 'zod';
import { sendEmail } from '../lib/email';
// Define schema for validation
const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters')
});
export async function submitContactForm(formData) {
// Extract data from form
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message')
};
// Validate data
const result = contactSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors
};
}
// Process data
try {
await sendEmail({
to: '[email protected]',
subject: `Contact form: ${result.data.name}`,
text: `
Name: ${result.data.name}
Email: ${result.data.email}
Message: ${result.data.message}
`
});
return { success: true };
} catch (error) {
return {
success: false,
errors: { general: 'Failed to send message. Please try again later.' }
};
}
}
// app/contact/page.js
'use client';
import { useState } from 'react';
import { submitContactForm } from '../actions';
export default function ContactPage() {
const [formStatus, setFormStatus] = useState({
submitted: false,
success: false,
errors: {}
});
async function handleSubmit(formData) {
setFormStatus({ submitted: true, success: false, errors: {} });
const result = await submitContactForm(formData);
setFormStatus({
submitted: true,
success: result.success,
errors: result.errors || {}
});
}
if (formStatus.submitted && formStatus.success) {
return (
<div className="success-message">
<h2>Thank you for your message!</h2>
<p>We'll get back to you as soon as possible.</p>
<button onClick={() => setFormStatus({ submitted: false, success: false, errors: {} })}>
Send another message
</button>
</div>
);
}
return (
<div className="contact-form-container">
<h1>Contact Us</h1>
{formStatus.errors.general && (
<div className="error-message" role="alert">
{formStatus.errors.general}
</div>
)}
<form action={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Name:</label>
<input
id="name"
name="name"
required
/>
{formStatus.errors.name && (
<p className="field-error">{formStatus.errors.name}</p>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input
id="email"
name="email"
type="email"
required
/>
{formStatus.errors.email && (
<p className="field-error">{formStatus.errors.email}</p>
)}
</div>
<div className="form-group">
<label htmlFor="message">Message:</label>
<textarea
id="message"
name="message"
rows={5}
required
></textarea>
{formStatus.errors.message && (
<p className="field-error">{formStatus.errors.message}</p>
)}
</div>
<button
type="submit"
disabled={formStatus.submitted && !formStatus.success}
>
{formStatus.submitted && !formStatus.success ? 'Sending...' : 'Send Message'}
</button>
</form>
</div>
);
}
Form State Management and Feedback
Provide clear feedback to users throughout the form interaction:
- Loading States: Show loading indicators during form submission.
- Success Messages: Display clear success messages after successful submissions.
- Error Handling: Provide meaningful error messages for failed submissions.
- Field Validation Feedback: Show validation feedback as users type or after they complete a field.
Performance Considerations
Optimize form performance for a better user experience:
- Debounce Input Handlers: For input fields that trigger operations on change.
- Lazy Loading: Lazy load form components for complex forms.
- Optimistic Updates: Implement optimistic UI updates for a snappier feel.
'use client';
import { useState, useCallback } from 'react';
import debounce from 'lodash.debounce';
export default function SearchForm() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
// Debounced search function
const debouncedSearch = useCallback(
debounce(async (searchQuery) => {
if (searchQuery.length < 2) return;
setLoading(true);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`);
const data = await response.json();
setResults(data.results);
} catch (error) {
console.error('Search failed:', error);
} finally {
setLoading(false);
}
}, 300),
[]
);
const handleInputChange = (e) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
};
return (
<div>
<form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="search">Search:</label>
<input
id="search"
type="search"
value={query}
onChange={handleInputChange}
placeholder="Start typing to search..."
/>
</form>
{loading && <p>Loading results...</p>}
<ul className="search-results">
{results.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</div>
);
}
Summary
Creating effective forms in Next.js requires attention to several key areas:
- Form Handling: Choose between controlled components, form libraries, or server actions based on your needs.
- Validation: Implement both client-side and server-side validation for security and user experience.
- Accessibility: Ensure your forms are accessible to all users with proper labels, error messages, and keyboard navigation.
- Security: Protect against common vulnerabilities like XSS and CSRF attacks.
- User Experience: Provide clear feedback, loading states, and helpful error messages.
- Performance: Optimize forms for performance using techniques like debouncing.
By following these best practices, you'll create forms that are user-friendly, secure, and maintainable, enhancing the overall quality of your Next.js applications.
Additional Resources
- Next.js Documentation on Server Actions
- React Hook Form - A popular form library for React
- Zod Documentation - A TypeScript-first schema validation library
- Web Accessibility Initiative (WAI) Form Guidelines
- OWASP Input Validation Cheat Sheet
Exercise: Build a Multi-Step Form
Try creating a multi-step form with the following features:
- Multiple steps/pages for collecting different types of information
- Form state persistence between steps
- Validation at each step
- A summary page before final submission
- Proper error handling and success messaging
- Accessibility considerations
This exercise will help you apply the best practices covered in this guide while building a common real-world form pattern.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)