← Back to Guides
7 min readIntermediate
Share

Form Validation with AI-Generated Code

AI-generated forms nail the happy path and skip everything else. Here's how to add client validation, server validation, async checks, and accessible errors.

Form Validation with AI-Generated Code

Ask an assistant for "a signup form" and you'll get working inputs, a submit handler, and probably a required attribute or two. What you usually won't get, unless you ask specifically: validation that survives a user pasting garbage into a field, a server that double-checks what the client already checked, or error messages a screen reader can actually announce. Forms are a place where "it works when I click through it once" and "it's actually correct" diverge fast — this guide covers the gaps.

Client validation is UX, not security

The first thing to internalize: client-side validation exists to give the user fast feedback, not to protect your backend. Anyone can call your API directly with a tool like curl, bypassing your form entirely. Every check that matters for data integrity or security has to be re-run on the server, full stop — no exceptions for "the form already validates this."

// A shared schema used on both sides — one source of truth, not two.
import { z } from "zod";

export const signupSchema = z.object({
  email: z.string().email("Enter a valid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
  username: z
    .string()
    .min(3, "Username must be at least 3 characters")
    .regex(/^[a-z0-9_]+$/i, "Letters, numbers, and underscores only"),
});

export type SignupInput = z.infer<typeof signupSchema>;

Defining the schema once and importing it in both the client form and the API route means the two can never silently drift apart — a common AI-generated-code failure mode where the client checks password.length >= 8 and the server checks >= 6 because they were written in two separate requests to the assistant.

// app/api/signup/route.ts
import { signupSchema } from "@/lib/schemas/signup";

export async function POST(req: Request) {
  const body = await req.json();
  const result = signupSchema.safeParse(body);

  if (!result.success) {
    return Response.json(
      { errors: result.error.flatten().fieldErrors },
      { status: 400 }
    );
  }

  // result.data is now typed and validated — safe to use.
  const { email, password, username } = result.data;
  // ...create the user
}

Client-side: validate on blur, not on every keystroke

Validating on every keystroke feels responsive in a demo and feels hostile in practice — nobody wants to see "Password too short" while they're three characters into typing it. Validate on blur (when the user leaves the field) and on submit, and only switch to on-change validation for a field after it's already shown an error once, so corrections get immediate feedback.

function useFieldValidation<T>(schema: z.ZodType<T>, value: T) {
  const [touched, setTouched] = useState(false);
  const [hasErrored, setHasErrored] = useState(false);

  const result = schema.safeParse(value);
  const error = !result.success ? result.error.issues[0]?.message : undefined;

  // Only show the error once the user has left the field at least once,
  // then keep validating live so corrections resolve immediately.
  const showError = (touched || hasErrored) && !!error;

  return {
    error: showError ? error : undefined,
    onBlur: () => {
      setTouched(true);
      if (error) setHasErrored(true);
    },
  };
}

Async validation needs a debounce and a race guard

Checking "is this username taken" against your backend on every keystroke will hammer your API and can return results out of order — a slow response for an earlier keystroke can arrive after a fast response for a later one and overwrite the correct state with stale data.

function useUsernameAvailability(username: string) {
  const [status, setStatus] = useState<"idle" | "checking" | "available" | "taken">("idle");

  useEffect(() => {
    if (username.length < 3) {
      setStatus("idle");
      return;
    }

    let cancelled = false;
    setStatus("checking");

    const timeout = setTimeout(async () => {
      const res = await fetch(`/api/username-available?u=${encodeURIComponent(username)}`);
      const { available } = await res.json();
      if (!cancelled) setStatus(available ? "available" : "taken");
    }, 400); // debounce — don't fire on every keystroke

    return () => {
      cancelled = true; // guards against an in-flight request resolving out of order
      clearTimeout(timeout);
    };
  }, [username]);

  return status;
}

The cancelled flag is doing the real work here: without it, a stale in-flight request from three keystrokes ago can resolve after the current one and flip the status back to a wrong answer.

Accessible errors aren't optional

An error message that only appears as red text near an input is invisible to a screen reader unless it's wired up correctly. Two attributes do almost all the work:

function FormField({ label, error, ...inputProps }: FieldProps) {
  const errorId = `${inputProps.id}-error`;

  return (
    <div>
      <label htmlFor={inputProps.id}>{label}</label>
      <input
        {...inputProps}
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
      />
      {error && (
        <p id={errorId} role="alert" className="text-red-400 text-sm">
          {error}
        </p>
      )}
    </div>
  );
}

aria-invalid tells assistive tech the field is currently in an error state. aria-describedby links the input to the error text so a screen reader announces it when the field receives focus, not just when the error first appears. role="alert" makes the message announced immediately when it shows up, even if focus hasn't moved.

A checklist for reviewing AI-generated forms

  • [ ] Is every client-side check duplicated on the server, from a shared schema if possible — not re-implemented separately?
  • [ ] Does validation trigger on blur/submit rather than every keystroke, for fields that haven't errored yet?
  • [ ] Do async checks (username/email availability) debounce input and guard against out-of-order responses?
  • [ ] Is every error linked to its input via aria-describedby, with aria-invalid set?
  • [ ] Does the server return field-level errors (not just a generic 400), so the client can show them next to the right input?
  • [ ] Are error messages specific ("Password must be at least 8 characters") rather than generic ("Invalid input")?

The takeaway

The gap between a demo-ready form and a production-ready one is almost entirely in the parts that don't show up when you fill it out once successfully: server-side re-validation, debounced async checks, and accessible error wiring. None of it is hard — it's just easy for a single-pass AI generation to skip, because the happy path looks identical with or without it.

Stay in the flow

Get vibecoding tips, new tool announcements, and guides delivered to your inbox.

No spam, unsubscribe anytime.