Form validation doesn’t need to be complicated. In this tutorial, I’ll show you how to implement clean, efficient form validation in Next.js using @teonord/validator with a real-world example.
Setting Up Our Project
First, install the package:
npm install @teonord/validator
Building a Contact Form with Smart Validation
Let’s create a contact form that demonstrates the most common validation scenarios you’ll encounter in real projects.
// components/ContactForm.tsx
'use client';
import { useState } from 'react';
import { Validator } from '@teonord/validator';
export default function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
subject: '',
message: '',
urgency: 'normal',
agreeToTerms: false
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validationRules = {
name: [{ rule: 'required' }],
email: [
{ rule: 'required' },
{ rule: 'email' }
],
phone: [
{ rule: 'requiredIf', value: ['urgency', 'urgent'] }
],
subject: [
{ rule: 'required' },
{ rule: 'minLength', value: [5] }
],
message: [
{ rule: 'required' },
{ rule: 'minLength', value: [10] },
{ rule: 'maxLength', value: [500] }
],
urgency: [{ rule: 'required' }],
agreeToTerms: [
{ rule: 'accepted' }
]
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const validator = new Validator(formData);
const result = validator.validateRules(validationRules);
if (!result.isValid) {
// Convert array errors to simple object
const errorMap: Record<string, string> = {};
Object.keys(result.errors).forEach(key => {
if (result.errors[key].length > 0) {
errorMap[key] = result.errors[key][0];
}
});
setErrors(errorMap);
return;
}
setErrors({});
// Handle form submission
console.log('Form submitted:', formData);
};
const handleChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
return (
<div className="max-w-lg mx-auto p-6 bg-white rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-6">Contact Us</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name Field */}
<div>
<label className="block text-sm font-medium mb-1">Full Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
className={`w-full p-2 border rounded ${
errors.name ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter your full name"
/>
{errors.name && <p className="text-red-500 text-sm mt-1">Name is required</p>}
</div>
{/* Email Field */}
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className={`w-full p-2 border rounded ${
errors.email ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="your@email.com"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">
{errors.email.includes('email') ? 'Invalid email format' : 'Email is required'}
</p>
)}
</div>
{/* Phone Field - Conditionally Required */}
<div>
<label className="block text-sm font-medium mb-1">
Phone Number {formData.urgency === 'urgent' && <span className="text-red-500">*</span>}
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className={`w-full p-2 border rounded ${
errors.phone ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="+1 (555) 123-4567"
/>
{errors.phone && <p className="text-red-500 text-sm mt-1">Phone is required for urgent requests</p>}
</div>
{/* Urgency Selection */}
<div>
<label className="block text-sm font-medium mb-1">Urgency</label>
<select
value={formData.urgency}
onChange={(e) => handleChange('urgency', e.target.value)}
className="w-full p-2 border border-gray-300 rounded"
>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="urgent">Urgent</option>
</select>
</div>
{/* Subject Field */}
<div>
<label className="block text-sm font-medium mb-1">Subject</label>
<input
type="text"
value={formData.subject}
onChange={(e) => handleChange('subject', e.target.value)}
className={`w-full p-2 border rounded ${
errors.subject ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="What is this regarding?"
/>
{errors.subject && (
<p className="text-red-500 text-sm mt-1">
{errors.subject.includes('minLength') ? 'Subject must be at least 5 characters' : 'Subject is required'}
</p>
)}
</div>
{/* Message Field */}
<div>
<label className="block text-sm font-medium mb-1">Message</label>
<textarea
value={formData.message}
onChange={(e) => handleChange('message', e.target.value)}
rows={4}
className={`w-full p-2 border rounded ${
errors.message ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Please describe your inquiry..."
/>
{errors.message && (
<p className="text-red-500 text-sm mt-1">
{errors.message.includes('minLength') && 'Message must be at least 10 characters'}
{errors.message.includes('maxLength') && 'Message must be less than 500 characters'}
{errors.message.includes('required') && 'Message is required'}
</p>
)}
<div className="text-sm text-gray-500 text-right">
{formData.message.length}/500
</div>
</div>
{/* Terms Agreement */}
<div className="flex items-center">
<input
type="checkbox"
id="agreeToTerms"
checked={formData.agreeToTerms}
onChange={(e) => handleChange('agreeToTerms', e.target.checked)}
className="mr-2"
/>
<label htmlFor="agreeToTerms" className="text-sm">
I agree to the terms and conditions
</label>
</div>
{errors.agreeToTerms && <p className="text-red-500 text-sm">You must agree to the terms</p>}
{/* Submit Button */}
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 transition-colors"
>
Send Message
</button>
</form>
</div>
);
}
Server-Side Validation in API Route
For complete security, always validate on the server too:
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Validator } from '@teonord/validator';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validationRules = {
name: [{ rule: 'required' }],
email: [{ rule: 'required' }, { rule: 'email' }],
phone: [{ rule: 'requiredIf', value: ['urgency', 'urgent'] }],
subject: [{ rule: 'required' }, { rule: 'minLength', value: [5] }],
message: [{ rule: 'required' }, { rule: 'minLength', value: [10] }],
urgency: [{ rule: 'required' }],
agreeToTerms: [{ rule: 'accepted' }]
};
const validator = new Validator(body);
const result = validator.validateRules(validationRules);
if (!result.isValid) {
return NextResponse.json(
{
success: false,
errors: result.errors
},
{ status: 400 }
);
}
// Process the form data (send email, save to database, etc.)
console.log('Processing contact form:', body);
// Simulate processing delay
await new Promise(resolve => setTimeout(resolve, 1000));
return NextResponse.json({
success: true,
message: 'Thank you for your message! We will get back to you soon.'
});
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Invalid JSON data' },
{ status: 400 }
);
}
}
Key Features Demonstrated
1. Conditional Validation
The phone field is only required when urgency is set to “urgent”:
phone: [{ rule: 'requiredIf', value: ['urgency', 'urgent'] }]
2. Multiple Validation Rules
Single fields can have multiple rules:
email: [{ rule: 'required' }, { rule: 'email' }]
3. Length Validation
Control minimum and maximum lengths:
message: [
{ rule: 'required' },
{ rule: 'minLength', value: [10] },
{ rule: 'maxLength', value: [500] }
]
4. Checkbox Validation
Validate boolean values like terms agreement:
agreeToTerms: [{ rule: 'accepted' }]
Real-Time User Experience
The form provides immediate feedback:
- Clear visual indicators for invalid fields
- Dynamic error messages that disappear when users start correcting
- Conditional requirements that change based on user selections
- Character counting for length-limited fields
Best Practices for Production
- Consistent Rules: Use the same validation rules on client and server
- User-Friendly Messages: Provide clear, actionable error messages
- Progressive Enhancement: Validate as users type, but always validate on submit
- Security First: Never trust client-side validation alone
Conclusion
This approach gives you:
- ✅ Clean, readable validation rules
- ✅ Conditional logic without complex code
- ✅ Consistent client and server validation
- ✅ Great user experience with immediate feedback
- ✅ Type-safe validation with TypeScript
The @teonord/validator package makes form validation straightforward and maintainable, letting you focus on building great features instead of writing complex validation logic.
Ready to implement? Copy the code above and adapt it to your Next.js forms today!
What form validation challenges have you faced in your projects? Share your experiences in the comments!