DEV Community

Cover image for Practical Next.js Form Validation with @teonord/validator
Jean Duppont
Jean Duppont

Posted on

Practical Next.js Form Validation with @teonord/validator

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Features Demonstrated

1. Conditional Validation

The phone field is only required when urgency is set to "urgent":

phone: [{ rule: 'requiredIf', value: ['urgency', 'urgent'] }]
Enter fullscreen mode Exit fullscreen mode

2. Multiple Validation Rules

Single fields can have multiple rules:

email: [{ rule: 'required' }, { rule: 'email' }]
Enter fullscreen mode Exit fullscreen mode

3. Length Validation

Control minimum and maximum lengths:

message: [
  { rule: 'required' },
  { rule: 'minLength', value: [10] },
  { rule: 'maxLength', value: [500] }
]
Enter fullscreen mode Exit fullscreen mode

4. Checkbox Validation

Validate boolean values like terms agreement:

agreeToTerms: [{ rule: 'accepted' }]
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (1)

Collapse
 
hashbyt profile image
Hashbyt

This is an excellent, practical tutorial, Jean. The side-by-side client and server implementation is perfectly clear and highlights the importance of full-stack validation