============================================================================== FORM VALIDATION & SECURITY GUIDE ============================================================================== CashPlanet Academy · Resource Center | Security | about 8 min read HTML edition: https://academy.cashplanet.io/form-validation-and-security.html A comprehensive template for implementing form validation and security in React and Next.js applications. CONTENTS 1. Overview 2. Dependencies 3. 1. Frontend Validation (Zod Schemas) 4. 2. XSS Sanitization 5. 3. React Form Component Example 6. 4. API Endpoint (Vercel Serverless / Next.js API Route) - For Vercel Serverless Functions - For Next.js App Router (API Routes) 7. 5. Security Checklist - Frontend - API/Backend - Validation Rules Reference 8. 6. Common Patterns - Optional Fields with Empty String Fallback - Enum Validation - Numeric Validation (Assessment Scores) - Conditional Validation OVERVIEW ======== This guide covers a defence-in-depth approach to form security: 1. Frontend Validation - Zod schemas for real-time validation 2. XSS Sanitization - Strip malicious content before submission 3. API Validation - Server-side re-validation (never trust the client) 4. CORS Configuration - Restrict allowed origins DEPENDENCIES ============ npm install zod xss 1. FRONTEND VALIDATION (ZOD SCHEMAS) ==================================== Create src/lib/validation.ts: import { z } from 'zod' // =========================================== // HELPER: UK Phone Number Validation // =========================================== const validateUKPhone = (phone: string): boolean => { const digits = phone.replace(/\D/g, '') if (digits.startsWith('44')) { return digits.length === 12 && (digits.startsWith('447') || digits.startsWith('441') || digits.startsWith('442')) } else if (digits.startsWith('0')) { return digits.length === 11 && (digits.startsWith('07') || digits.startsWith('01') || digits.startsWith('02') || digits.startsWith('03')) } else if (digits.startsWith('7')) { return digits.length === 10 } return false } // =========================================== // CONTACT/ENQUIRY FORM SCHEMA // =========================================== export const enquiryFormSchema = z.object({ name: z .string() .min(2, 'Name must be at least 2 characters') .max(100, 'Name must be less than 100 characters') .regex(/^[a-zA-Z\s\-'\.]+$/, 'Name can only contain letters, spaces, hyphens, apostrophes and dots'), email: z .string() .email('Please enter a valid email address') .max(254, 'Email must be less than 254 characters') .toLowerCase(), phone: z .string() .min(10, 'Phone number must be at least 10 digits') .max(20, 'Phone number must be less than 20 characters') .refine(validateUKPhone, 'Please enter a valid UK phone number (e.g., 07900 123456 or +44 7900 123456)'), message: z .string() .min(10, 'Message must be at least 10 characters') .max(2000, 'Message must be less than 2000 characters'), agreedToTerms: z .boolean() .refine(val => val === true, 'You must agree to the terms and privacy policy'), }) export type EnquiryFormData = z.infer // =========================================== // NEWSLETTER SCHEMA // =========================================== export const newsletterSchema = z.object({ email: z .string() .email('Please enter a valid email address') .max(254, 'Email must be less than 254 characters') .toLowerCase(), }) export type NewsletterData = z.infer // =========================================== // BOOKING/DETAILED FORM SCHEMA // =========================================== export const bookingDetailsSchema = z.object({ parentName: z .string() .min(2, 'Name must be at least 2 characters') .max(100, 'Name must be less than 100 characters') .regex(/^[a-zA-Z\s\-'\.]+$/, 'Name can only contain letters, spaces, hyphens, apostrophes and dots'), parentEmail: z .string() .email('Please enter a valid email address') .max(254, 'Email must be less than 254 characters') .toLowerCase(), parentPhone: z .string() .max(20, 'Phone number must be less than 20 characters') .refine( (val) => !val || validateUKPhone(val), 'Please enter a valid UK phone number' ) .optional() .or(z.literal('')), school: z .string() .min(2, 'School name must be at least 2 characters') .max(200, 'School name must be less than 200 characters'), yearGroup: z .string() .min(1, 'Please select a year group'), subjects: z .array(z.string()) .min(1, 'Please select at least one subject'), referralSource: z .string() .min(1, 'Please tell us how you heard about us'), comments: z .string() .max(2000, 'Comments must be less than 2000 characters') .optional() .or(z.literal('')), }) export type BookingDetailsData = z.infer // =========================================== // HELPER: Get first validation error message // =========================================== export const getFirstError = (error: z.ZodError): string => { return error.issues[0]?.message || 'Validation failed' } // =========================================== // HELPER: Get all validation errors as object // =========================================== export const getFieldErrors = (error: z.ZodError): Record => { const errors: Record = {} error.issues.forEach((err: z.ZodIssue) => { const path = err.path.join('.') if (!errors[path]) { errors[path] = err.message } }) return errors } 2. XSS SANITIZATION =================== Create src/lib/sanitize.ts: import xss, { IFilterXSSOptions } from 'xss' const xssOptions: IFilterXSSOptions = { whiteList: {}, stripIgnoreTag: true, stripIgnoreTagBody: ['script', 'style'], } export const sanitize = (input: string): string => { if (!input || typeof input !== 'string') return '' return xss(input.trim(), xssOptions) } export const sanitizeObject = >(obj: T): T => { const sanitized: Record = {} for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string') { sanitized[key] = sanitize(value) } else if (Array.isArray(value)) { sanitized[key] = value.map((item) => typeof item === 'string' ? sanitize(item) : item ) } else if (value !== null && typeof value === 'object') { sanitized[key] = sanitizeObject(value as Record) } else { sanitized[key] = value } } return sanitized as T } export const sanitizeEmail = (email: string): string => { if (!email || typeof email !== 'string') return '' return email.trim().toLowerCase() } export const sanitizePhone = (phone: string): string => { if (!phone || typeof phone !== 'string') return '' return phone.replace(/[^\d\s\+\-]/g, '').trim() } 3. REACT FORM COMPONENT EXAMPLE =============================== import { useState } from 'react' import { enquiryFormSchema, type EnquiryFormData } from '@/lib/validation' import { sanitize, sanitizeEmail, sanitizePhone } from '@/lib/sanitize' export function EnquiryForm() { const [formData, setFormData] = useState({ name: '', email: '', phone: '', message: '', agreedToTerms: false, }) const [errors, setErrors] = useState>({}) const [isSubmitting, setIsSubmitting] = useState(false) const validateField = (field: keyof EnquiryFormData, value: unknown) => { const fieldSchema = enquiryFormSchema.shape[field] const result = fieldSchema.safeParse(value) if (!result.success) { setErrors(prev => ({ ...prev, [field]: result.error.issues[0]?.message || 'Invalid' })) } else { setErrors(prev => { const newErrors = { ...prev } delete newErrors[field] return newErrors }) } } const handleChange = (e: React.ChangeEvent) => { const { name, value, type } = e.target const newValue = type === 'checkbox' ? (e.target as HTMLInputElement).checked : value setFormData(prev => ({ ...prev, [name]: newValue })) validateField(name as keyof EnquiryFormData, newValue) } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setIsSubmitting(true) const result = enquiryFormSchema.safeParse(formData) if (!result.success) { const fieldErrors: Record = {} result.error.issues.forEach((err) => { const path = err.path.join('.') if (!fieldErrors[path]) { fieldErrors[path] = err.message } }) setErrors(fieldErrors) setIsSubmitting(false) return } const sanitizedData = { name: sanitize(result.data.name), email: sanitizeEmail(result.data.email), phone: sanitizePhone(result.data.phone), message: sanitize(result.data.message), agreedToTerms: result.data.agreedToTerms, } try { const response = await fetch('/api/enquiry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sanitizedData), }) if (!response.ok) { const data = await response.json() if (data.errors) { setErrors(data.errors) } return } setFormData({ name: '', email: '', phone: '', message: '', agreedToTerms: false }) setErrors({}) alert('Form submitted successfully!') } catch (error) { console.error('Submission error:', error) } finally { setIsSubmitting(false) } } return (
{errors.name &&

{errors.name}

}
{errors.email &&

{errors.email}

}
{errors.phone &&

{errors.phone}

}