TL;DR: Form validation is checking that user input is correct before you process it. There are two kinds — client-side (browser, instant feedback) and server-side (backend, actually secure). AI tools often generate only one kind, use regex patterns that block valid inputs, and write error messages nobody understands. This article shows you all five of the most common places AI-generated forms break, and exactly how to fix each one.
Why AI Coders Need to Know This
Forms are everywhere. Contact pages, sign-up flows, checkout screens, settings panels — if your app takes input from users, you have forms. And every form needs validation.
When Chuck prompts an AI to build a contact form or a sign-up page, the AI generates something that looks complete. Fields, labels, a submit button, error messages. It looks like it works. Then he ships it and one of three things happens:
- A user submits garbage data that breaks something downstream
- A user submits a perfectly valid email address and gets told it's wrong
- A malicious user bypasses the validation entirely because it only runs in the browser
None of these are catastrophic on their own, but they erode trust fast. And they're all caused by the same handful of validation mistakes that AI makes over and over.
You don't need to become a validation expert. You need to know what validation does, where AI gets it wrong, and which prompts fix it. That's what this article gives you.
Real Scenario
Prompt I Would Type
Build a contact form in React with these fields:
- Name (required)
- Email (required, must be valid email format)
- Phone (optional, US format)
- Message (required, 20-500 characters)
Show inline error messages under each field. Disable the submit
button while the form is submitting. Show a success message after
the form submits. Use fetch to POST to /api/contact.
What AI Generated
Here's a representative example of what AI tools produce for this prompt. It looks solid. Read it once, then we'll break down what's actually happening:
import { useState } from 'react';
export default function ContactForm() {
const [formData, setFormData] = useState({
name: '', email: '', phone: '', message: ''
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const validate = (data) => {
const errs = {};
if (!data.name.trim()) errs.name = 'Name is required';
if (!data.email.trim()) {
errs.email = 'Email is required';
} else if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(data.email)) {
errs.email = 'Invalid email address';
}
if (data.phone && !/^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/.test(data.phone)) {
errs.phone = 'Invalid phone number';
}
if (!data.message.trim()) {
errs.message = 'Message is required';
} else if (data.message.length < 20) {
errs.message = 'Message must be at least 20 characters';
} else if (data.message.length > 500) {
errs.message = 'Message must be less than 500 characters';
}
return errs;
};
const handleSubmit = async (e) => {
e.preventDefault();
const errs = validate(formData);
if (Object.keys(errs).length > 0) {
setErrors(errs);
return;
}
setIsSubmitting(true);
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (res.ok) setSuccess(true);
} catch (err) {
console.error(err);
} finally {
setIsSubmitting(false);
}
};
if (success) return <p>Thanks! We'll be in touch.</p>;
return (
<form onSubmit={handleSubmit}>
<div>
<label>Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
{errors.name && <span>{errors.name}</span>}
</div>
<div>
<label>Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
{errors.email && <span>{errors.email}</span>}
</div>
<div>
<label>Phone (optional)</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
{errors.phone && <span>{errors.phone}</span>}
</div>
<div>
<label>Message</label>
<textarea
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
/>
{errors.message && <span>{errors.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
That's a reasonable contact form. The validation logic is there, the error display works, the submit button disables. What's missing is invisible in the component itself — and that's exactly the problem.
Understanding Validation Types: Client-Side vs Server-Side
Before we get to what AI gets wrong, you need to understand this distinction. It's the single most important thing in this article.
Client-Side Validation
Client-side validation runs in the browser, before the form data is sent anywhere. It's the validate() function in the example above. It's also HTML attributes like required and type="email".
What it's good for: Instant feedback. The user types a malformed email and sees the error immediately, without waiting for a network round-trip. Good UX.
What it's not good for: Security. Anyone can open their browser's dev tools, find your JavaScript, and comment out the validation. Or they can skip your form entirely and send a POST request directly to your API endpoint using curl or Postman. Your client-side validation never runs. The bad data hits your server anyway.
Server-Side Validation
Server-side validation runs on your backend — in a Next.js Server Action, an API route, or an Express handler — after the form data arrives. It cannot be bypassed because the user doesn't have access to your server code.
What it's good for: Actual security. You control this code entirely. A bad actor can't remove it.
What it's not good for: Instant feedback. The user has to wait for a round-trip to your server to find out their input was wrong.
The Rule
Client-side validation is for user experience. Server-side validation is for security. You need both. Never rely on client-side validation alone to protect your data.
The contact form AI generated above has zero server-side validation. The /api/contact route receives whatever it receives. We'll fix this below.
The 5 Most Common Validations
These cover 90% of what you'll encounter in AI-generated forms.
1. Required Fields
The simplest validation: a field must not be empty. In HTML you get this for free with the required attribute. In JavaScript you check if (!value.trim()) — the .trim() strips whitespace so a user can't submit a field containing only spaces.
// JavaScript check
if (!formData.name.trim()) {
errors.name = 'Name is required';
}
// HTML attribute (browser handles it automatically)
<input type="text" required />
2. Email Format
An email address must contain an @ symbol with something before and after it, plus a dot in the domain. That's essentially it. The HTML type="email" attribute handles basic validation automatically — the browser checks the format before submission.
// Simple regex — good enough for most uses
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
errors.email = 'Please enter a valid email address';
}
// HTML handles it for free
<input type="email" required />
Pro Tip
The only truly reliable email validation is sending a verification email. Any regex can be fooled. a@b.c passes most regex checks but is probably not a real inbox. If it matters, verify by sending a link.
3. Password Strength
Password validation typically checks minimum length (8+ characters), and may require uppercase, lowercase, numbers, or symbols. Be careful here — AI often generates rules that are stricter than necessary, frustrating users without meaningfully improving security.
// Reasonable password validation
const validatePassword = (password) => {
if (password.length < 8) {
return 'Password must be at least 8 characters';
}
if (!/[A-Z]/.test(password)) {
return 'Password must include at least one uppercase letter';
}
if (!/[0-9]/.test(password)) {
return 'Password must include at least one number';
}
return null; // null means valid
};
4. Length Constraints
Minimum and maximum character counts. The HTML minlength and maxlength attributes handle these in the browser. JavaScript handles them for custom messages.
// JavaScript
if (message.length < 20) {
errors.message = 'Message must be at least 20 characters';
}
if (message.length > 500) {
errors.message = 'Message must be 500 characters or fewer';
}
// HTML attributes
<textarea minlength="20" maxlength="500"></textarea>
5. Pattern Matching
Regex patterns for structured inputs: phone numbers, zip codes, credit card numbers, URLs. The HTML pattern attribute takes a regex directly. In JavaScript you use regex.test(value).
// Phone number — accepts common US formats
const phoneRegex = /^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4}$/;
if (phone && !phoneRegex.test(phone)) {
errors.phone = 'Please enter a valid US phone number';
}
// HTML pattern attribute
<input type="tel" pattern="[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4}" />
What AI Gets Wrong: 5 Mistakes to Watch For
These are the patterns that show up constantly in AI-generated form code.
Mistake 1: Client-Side Only, No Server Validation
This is by far the most common and the most dangerous. AI generates a nice validate() function, you ship it, and your API route accepts anything.
The fix: Add validation to your API route or Server Action. Here's what a Next.js API route should look like:
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const body = await req.json();
const { name, email, message } = body;
// Server-side validation — always runs, can't be bypassed
const errors: Record<string, string> = {};
if (!name?.trim()) errors.name = 'Name is required';
if (!email?.trim()) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = 'Invalid email address';
}
if (!message?.trim()) {
errors.message = 'Message is required';
} else if (message.length < 20 || message.length > 500) {
errors.message = 'Message must be 20–500 characters';
}
if (Object.keys(errors).length > 0) {
return NextResponse.json({ errors }, { status: 400 });
}
// Safe to process the data now
// await sendEmail({ name, email, message });
return NextResponse.json({ success: true });
}
For Server Actions, see the guide on what Server Actions are and how to add validation inside them.
Mistake 2: Overly Strict Regex That Rejects Valid Inputs
AI loves complex regex. It also loves regex that rejects perfectly valid real-world input. Classic examples:
- An email regex that rejects subdomains like
user@mail.company.com - A phone regex that only accepts
(555) 123-4567but rejects555-123-4567or5551234567 - A name field that rejects hyphens, apostrophes, or accented characters (bad news for O'Brien and José)
- A URL regex that rejects valid URLs with query strings or fragments
The fix: Be more permissive. If someone's input is vaguely reasonable, let it through client-side and validate more carefully server-side where you can apply business logic. When in doubt, simplify the regex. The name field? Just check it's not empty.
// Too strict — rejects O'Brien, Mary-Jane, José
if (!/^[a-zA-Z ]+$/.test(name)) {
errors.name = 'Name can only contain letters';
}
// Better — just check it's not empty
if (!name.trim()) {
errors.name = 'Name is required';
}
Mistake 3: Useless Error Messages
AI generates messages like "Invalid input", "Field is required", and "Value is not valid". These tell users nothing. What's invalid? What format do you want? What does valid look like?
The fix: Error messages should say exactly what's wrong and how to fix it.
// Bad — gives no information
errors.email = 'Invalid email';
errors.phone = 'Invalid phone number';
errors.password = 'Password does not meet requirements';
// Good — tells users what to do
errors.email = 'Please enter a valid email address (example: you@domain.com)';
errors.phone = 'Enter a 10-digit US phone number (example: 555-123-4567)';
errors.password = 'Password must be at least 8 characters and include a number';
Good error messages are part of error handling more broadly — the same principle applies: be specific, be helpful, tell users what to do next.
Mistake 4: Validating Before the User Has Typed Anything
This is the "yell at the user before they've done anything wrong" problem. AI sometimes sets up validation that runs on every keystroke from the very first character. The user clicks into the name field, types "J", and immediately gets "Name must be at least 2 characters."
The fix: Use a "touched" state. Only show errors after the user has interacted with a field and left it (the onBlur event), or after they've attempted to submit.
const [touched, setTouched] = useState({});
// Mark field as touched when user leaves it
const handleBlur = (field) => {
setTouched(prev => ({ ...prev, [field]: true }));
};
// Only show error if field has been touched
{touched.name && errors.name && (
<span className="error">{errors.name}</span>
)}
// In your input
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onBlur={() => handleBlur('name')}
/>
Mistake 5: No Feedback on Server Errors
The fetch call in the AI-generated form has this pattern:
try {
const res = await fetch('/api/contact', { ... });
if (res.ok) setSuccess(true);
} catch (err) {
console.error(err); // only visible in dev tools
}
If the server returns a 400 with validation errors, nothing happens. If the network is down, nothing happens. The user is left staring at a spinning button with no feedback.
The fix: Handle non-OK responses explicitly. Parse the server's error response and display it.
const [serverError, setServerError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
const clientErrors = validate(formData);
if (Object.keys(clientErrors).length > 0) {
setErrors(clientErrors);
return;
}
setIsSubmitting(true);
setServerError('');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (res.ok) {
setSuccess(true);
return;
}
// Handle server validation errors
const data = await res.json();
if (data.errors) {
setErrors(data.errors); // Map server errors back to fields
} else {
setServerError('Something went wrong. Please try again.');
}
} catch (err) {
setServerError('Network error. Check your connection and try again.');
} finally {
setIsSubmitting(false);
}
};
// Render server error above the submit button
{serverError && <p className="error">{serverError}</p>}
How to Debug Validation Issues
When a form validation breaks in production, here's how to track it down.
Step 1: Check the Network Tab
Open browser dev tools (F12), go to the Network tab, submit the form, and look at the request. Check:
- Is the request being sent at all? (If not, client-side validation is blocking it)
- What status code came back? (400 = bad request / validation error, 200 = success, 500 = server error)
- What's in the response body? (Your server's error message)
Step 2: Log the Error Object
In your catch block, log the full error, not just the message:
catch (err) {
console.error('Full error:', err);
console.error('Error message:', err.message);
// Also log the response if it was a non-ok fetch
}
Step 3: Test Your Validation in Isolation
Copy your validate function into the browser console and run it directly with test data. This isolates whether the problem is in your validation logic or somewhere else:
// In the browser console
validate({ name: '', email: 'not-an-email', phone: '', message: 'hi' })
// Should return: { name: 'Name is required', email: 'Invalid email...', message: '...' }
Step 4: Check for Regex False Positives
If a user reports their valid input is being rejected, test the regex directly against their input:
// In the browser console
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test("user@subdomain.company.co.uk")
// If this returns false, your regex is the problem
Step 5: Bypass Client-Side and Test the Server Directly
To test your server-side validation, send a request directly without going through the form. Use the browser console or a tool like curl:
// Browser console
fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '', email: 'bad', message: 'x' })
}).then(r => r.json()).then(console.log)
// If this returns { success: true }, you have no server-side validation
Better Libraries for Complex Forms
If you're building forms with more than 4–5 fields, or forms that need conditional validation, consider React Hook Form. It handles touched state, validation timing, error display, and submission state with far less code than rolling it by hand. Pair it with Zod for schema-based validation that works on both client and server.
Putting It Together: The Fixed Contact Form
Here's the same contact form from earlier, corrected for all five mistakes. The client-side component now tracks touched state and shows helpful errors. The API route validates on the server:
// components/ContactForm.tsx
import { useState } from 'react';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRegex = /^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4}$/;
function validate(data) {
const errors = {};
if (!data.name.trim()) errors.name = 'Name is required';
if (!data.email.trim()) {
errors.email = 'Email is required';
} else if (!emailRegex.test(data.email)) {
errors.email = 'Enter a valid email (example: you@domain.com)';
}
if (data.phone && !phoneRegex.test(data.phone)) {
errors.phone = 'Enter a 10-digit US number (example: 555-123-4567)';
}
if (!data.message.trim()) {
errors.message = 'Message is required';
} else if (data.message.length < 20) {
errors.message = `Message too short — add ${20 - data.message.length} more characters`;
} else if (data.message.length > 500) {
errors.message = 'Message must be 500 characters or fewer';
}
return errors;
}
export default function ContactForm() {
const [formData, setFormData] = useState({ name: '', email: '', phone: '', message: '' });
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [serverError, setServerError] = useState('');
const handleBlur = (field) => setTouched(prev => ({ ...prev, [field]: true }));
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Re-validate on change only if field has been touched
if (touched[field]) {
const newErrors = validate({ ...formData, [field]: value });
setErrors(prev => ({ ...prev, [field]: newErrors[field] }));
}
};
const handleSubmit = async (e) => {
e.preventDefault();
// Mark all fields as touched on submit attempt
setTouched({ name: true, email: true, phone: true, message: true });
const clientErrors = validate(formData);
if (Object.keys(clientErrors).length > 0) {
setErrors(clientErrors);
return;
}
setIsSubmitting(true);
setServerError('');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (res.ok) { setSuccess(true); return; }
const data = await res.json();
if (data.errors) setErrors(data.errors);
else setServerError('Something went wrong. Please try again.');
} catch {
setServerError('Network error. Check your connection and try again.');
} finally {
setIsSubmitting(false);
}
};
if (success) return <p>Thanks! We'll be in touch within 1 business day.</p>;
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">Name *</label>
<input
id="name" type="text" value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
onBlur={() => handleBlur('name')}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{touched.name && errors.name && <span id="name-error" role="alert">{errors.name}</span>}
</div>
{/* ... other fields follow the same pattern ... */}
{serverError && <p role="alert">{serverError}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
Security note: Form validation is the front line for input validation — the broader practice of making sure data is safe before it touches your database or gets sent anywhere. Validation rules like "must be an email" are about correctness. Security-focused input validation also sanitizes for SQL injection, XSS, and other attack vectors. The server-side validation you write here is the start of that story.
Frequently Asked Questions
Form validation is the process of checking that user input meets your rules before you process or save it. Required fields must not be empty, email addresses must contain an @ symbol, passwords must be long enough. Validation can happen in the browser (client-side) or on your server (server-side) — ideally both.
Client-side validation runs in the browser using JavaScript or HTML attributes. It gives instant feedback but can be bypassed — anyone can open browser dev tools and remove it. Server-side validation runs on your backend after the form is submitted and cannot be bypassed. You need both: client-side for good UX, server-side for actual security.
AI tools like Cursor and GitHub Copilot generate code that looks correct but commonly has these issues: validation only on the client with nothing on the server, overly strict regex patterns that reject valid inputs, vague error messages that don't tell users what to fix, no validation at all on some fields, and showing all errors at once before the user has even typed anything.
The simplest approach is using the HTML input type="email" attribute, which browsers validate automatically. For JavaScript, a basic check is: const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email). Avoid overly complex regex — it tends to reject valid email formats. For thorough server-side validation, send a verification email instead.
React Hook Form is the most popular choice for React. It handles registration, validation rules, error messages, and submission state with minimal code. For schema-based validation (defining all your rules in one place), pair it with Zod. For simple forms, HTML built-in validation attributes (required, minlength, pattern, type="email") are often enough without any library.