Skip to main content
RapidDev - Software Development Agency
cursor-tutorial

How to Handle Form Validation with Cursor

Cursor generates dynamic React form validation reliably when you specify the validation library, error message shape, and trigger events in your prompt. Use .cursorrules to lock in react-hook-form plus Zod as defaults, then use Chat or Composer to scaffold complete form components with per-field real-time validation, accessible error messages, and typed submit handlers — ready to drop into any React 18 project.

What you'll learn

  • How to configure .cursorrules so Cursor always uses react-hook-form and Zod for form validation
  • How to prompt Cursor to generate a complete typed form component with per-field error messages
  • How to add dynamic cross-field validation rules using Zod's refine and superRefine
  • How to wire Cursor-generated forms into an existing API submit flow with typed error responses
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner10 min read15-20 minCursor Free+, React 18, TypeScript 5+, react-hook-form v7, Zod v3March 2026RapidDev Engineering Team
TL;DR

Cursor generates dynamic React form validation reliably when you specify the validation library, error message shape, and trigger events in your prompt. Use .cursorrules to lock in react-hook-form plus Zod as defaults, then use Chat or Composer to scaffold complete form components with per-field real-time validation, accessible error messages, and typed submit handlers — ready to drop into any React 18 project.

Generate dynamic React form validation with Cursor

Form validation is one of the most repetitive tasks in React development. Without guidance, Cursor tends to produce uncontrolled inputs with ad-hoc useState checks that miss edge cases — password confirmation mismatches, async username availability, and conditional required fields all require a proper schema-driven approach. This tutorial configures Cursor to use react-hook-form with Zod schemas, producing validation that is declarative, type-safe, and easy to extend. You will build a registration form step by step, learning exactly which Cursor prompts produce the best results.

Prerequisites

  • Cursor installed (Free plan or above)
  • React 18 project with TypeScript
  • react-hook-form and zod installed: npm install react-hook-form zod @hookform/resolvers
  • Basic understanding of Zod schemas is helpful but not required

Step-by-step guide

1

Configure .cursorrules for form validation defaults

Add a form validation section to .cursorrules. This tells Cursor which libraries to use for every form-related prompt, eliminating the need to repeat library preferences. Include rules for error message placement (below each field), trigger mode (onChange for immediate feedback), and TypeScript inference from Zod schemas using z.infer. Save the file before proceeding.

.cursorrules
1# .cursorrules
2
3## React Forms
4- Always use react-hook-form (v7) with @hookform/resolvers/zod for form state management.
5- Always define validation schemas with Zod. Derive TypeScript types using z.infer<typeof schema>.
6- Use mode: 'onChange' in useForm to show validation errors immediately as the user types.
7- Place error messages directly below each input as a <p className="text-red-500 text-sm"> element.
8- NEVER use uncontrolled inputs without react-hook-form register.
9- NEVER use yup or manual useState validation use Zod schemas only.
10- Form submit handlers must be typed: handleSubmit accepts (data: FormData) => Promise<void>

Pro tip: Adding 'NEVER use uncontrolled inputs without react-hook-form register' is important — Cursor sometimes falls back to raw useState for simple-looking forms.

Expected result: Cursor will default to react-hook-form + Zod for all future form-related prompts in this project.

2

Generate a Zod schema and typed form component

Open Cursor Chat (Cmd+L) and request a full registration form. Include the field list, validation rules, and file path in the prompt. Cursor will generate the Zod schema, derive the TypeScript type, set up useForm with the Zod resolver, and wire register to each input. The more specific your field requirements, the better the output — list each field's constraints explicitly.

Cursor Chat prompt (Cmd+L)
1Generate a React registration form at src/components/RegistrationForm.tsx.
2Fields and validation rules:
3- email: required, valid email format
4- password: required, min 8 chars, at least one uppercase letter
5- confirmPassword: required, must match password field exactly
6- username: required, min 3 chars, max 20 chars, alphanumeric only
7
8Requirements:
9- Zod schema with z.infer for TypeScript type
10- useForm with zodResolver, mode: 'onChange'
11- Each field shows its error message below the input
12- Submit button disabled while form is invalid or submitting
13- onSubmit prop typed as (data: RegistrationFormData) => Promise<void>

Pro tip: Ask for the Zod schema and the component in the same prompt. Cursor produces cleaner code when both are in one file rather than split across imports.

Expected result: Cursor creates RegistrationForm.tsx with a Zod schema, typed form data interface, and all four fields with inline validation messages.

3

Add cross-field validation with Zod refine

Select the Zod schema block in the file and press Cmd+K for an inline edit. Ask Cursor to add the password-confirmation cross-field check using Zod's .refine method. Cross-field validation cannot be expressed at the field level in Zod — it must be applied to the entire object schema. Cursor handles this correctly when the instruction is explicit about using .refine at the schema level, not on individual fields.

Cursor Cmd+K inline prompt
1Extend the registrationSchema with a .refine check:
2- Condition: data.password === data.confirmPassword
3- Error message: 'Passwords do not match'
4- Path: ['confirmPassword'] so the error appears on the confirmPassword field
5
6Also add a second .refine for the username:
7- Async check: call checkUsernameAvailable(username) which returns Promise<boolean>
8- If false, error: 'Username is already taken'
9- Place checkUsernameAvailable as an imported stub in src/api/auth.ts

Pro tip: Zod's async .refine runs on schema.parseAsync — make sure Cursor updates the resolver accordingly. If it doesn't, add 'ensure the resolver uses parseAsync for async refinements' to your prompt.

Expected result: Schema now has two refine checks. The form shows 'Passwords do not match' on the confirmPassword field and validates username availability asynchronously.

4

Add conditional required fields

Open Composer (Cmd+I) and add a 'company name' field that is only required when the user selects 'Business' from an account type dropdown. Cursor needs to see the current form file to understand the existing schema structure — reference it with @file. Zod handles conditional validation with .superRefine, which gives access to the full object and the refinement context. Ask Cursor to also watch the accountType field and show or hide the company input reactively.

Cursor Composer prompt (Cmd+I)
1Using @file src/components/RegistrationForm.tsx:
2
3Add an 'accountType' select field (options: 'Personal' | 'Business') and a 'companyName' text field.
4- companyName is required only when accountType === 'Business'
5- Use Zod .superRefine to add the conditional validation to registrationSchema
6- In the JSX, conditionally render the companyName input using watch('accountType') === 'Business'
7- Show 'Company name is required for business accounts' as the error message

Pro tip: Use watch() from react-hook-form to reactively show/hide fields. Cursor knows this pattern — just mention 'conditionally render based on watch()' in your prompt.

Expected result: Form now shows the company name field only when Business is selected. Submitting without it while Business is selected shows the validation error.

5

Wire the form to an API and handle server-side errors

Open Cursor Chat and ask it to update the onSubmit handler to call a real API endpoint, handle the server's error response shape, and display field-level server errors using setError from react-hook-form. Server-side validation errors (duplicate email, banned username) need to map back to specific form fields — Cursor can generate this mapping when you provide the API response type.

Cursor Chat prompt (Cmd+L)
1Update the RegistrationForm onSubmit to call POST /api/register with the form data.
2API error response shape: { field: keyof RegistrationFormData; message: string }[]
3
4On success: call props.onSuccess(data)
5On 4xx error: parse the JSON body, loop through errors, and call setError(error.field, { message: error.message }) for each
6On network error: call setError('root', { message: 'Network error — please try again' })
7Display root errors above the submit button

Expected result: Submitting the form calls the API. Server-side field errors appear on the correct inputs. A root error message handles network failures.

Complete working example

src/components/RegistrationForm.tsx
1import { useForm } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4
5const registrationSchema = z
6 .object({
7 email: z.string().email('Enter a valid email address'),
8 password: z
9 .string()
10 .min(8, 'Password must be at least 8 characters')
11 .regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),
12 confirmPassword: z.string().min(1, 'Please confirm your password'),
13 username: z
14 .string()
15 .min(3, 'Username must be at least 3 characters')
16 .max(20, 'Username must be 20 characters or fewer')
17 .regex(/^[a-zA-Z0-9]+$/, 'Username can only contain letters and numbers'),
18 accountType: z.enum(['Personal', 'Business']),
19 companyName: z.string().optional(),
20 })
21 .refine((data) => data.password === data.confirmPassword, {
22 message: 'Passwords do not match',
23 path: ['confirmPassword'],
24 })
25 .superRefine((data, ctx) => {
26 if (data.accountType === 'Business' && !data.companyName) {
27 ctx.addIssue({
28 code: z.ZodIssueCode.custom,
29 message: 'Company name is required for business accounts',
30 path: ['companyName'],
31 });
32 }
33 });
34
35type RegistrationFormData = z.infer<typeof registrationSchema>;
36
37interface RegistrationFormProps {
38 onSuccess: (data: RegistrationFormData) => void;
39}
40
41export function RegistrationForm({ onSuccess }: RegistrationFormProps) {
42 const {
43 register,
44 handleSubmit,
45 watch,
46 setError,
47 formState: { errors, isSubmitting, isValid },
48 } = useForm<RegistrationFormData>({
49 resolver: zodResolver(registrationSchema),
50 mode: 'onChange',
51 defaultValues: { accountType: 'Personal' },
52 });
53
54 const accountType = watch('accountType');
55
56 const onSubmit = async (data: RegistrationFormData) => {
57 try {
58 const res = await fetch('/api/register', {
59 method: 'POST',
60 headers: { 'Content-Type': 'application/json' },
61 body: JSON.stringify(data),
62 });
63 if (!res.ok) {
64 const errs: { field: keyof RegistrationFormData; message: string }[] =
65 await res.json();
66 errs.forEach(({ field, message }) => setError(field, { message }));
67 return;
68 }
69 onSuccess(data);
70 } catch {
71 setError('root', { message: 'Network error — please try again' });
72 }
73 };
74
75 return (
76 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md">
77 {errors.root && (
78 <p className="text-red-500 text-sm">{errors.root.message}</p>
79 )}
80 <div>
81 <input {...register('email')} placeholder="Email" className="input" />
82 {errors.email && (
83 <p className="text-red-500 text-sm">{errors.email.message}</p>
84 )}
85 </div>
86 <div>
87 <input
88 {...register('password')}
89 type="password"
90 placeholder="Password"
91 className="input"
92 />
93 {errors.password && (
94 <p className="text-red-500 text-sm">{errors.password.message}</p>
95 )}
96 </div>
97 <div>
98 <input
99 {...register('confirmPassword')}
100 type="password"
101 placeholder="Confirm password"
102 className="input"
103 />
104 {errors.confirmPassword && (
105 <p className="text-red-500 text-sm">{errors.confirmPassword.message}</p>
106 )}
107 </div>
108 <div>
109 <input
110 {...register('username')}
111 placeholder="Username"
112 className="input"
113 />
114 {errors.username && (
115 <p className="text-red-500 text-sm">{errors.username.message}</p>
116 )}
117 </div>
118 <select {...register('accountType')} className="input">
119 <option value="Personal">Personal</option>
120 <option value="Business">Business</option>
121 </select>
122 {accountType === 'Business' && (
123 <div>
124 <input
125 {...register('companyName')}
126 placeholder="Company name"
127 className="input"
128 />
129 {errors.companyName && (
130 <p className="text-red-500 text-sm">{errors.companyName.message}</p>
131 )}
132 </div>
133 )}
134 <button type="submit" disabled={!isValid || isSubmitting}>
135 {isSubmitting ? 'Registering...' : 'Register'}
136 </button>
137 </form>
138 );
139}

Common mistakes when handling Form Validation with Cursor

Why it's a problem: Prompting Cursor for 'form validation' without specifying react-hook-form and Zod

How to avoid: Always name the libraries explicitly in your prompt and add them to .cursorrules so the preference is permanent across all sessions.

Why it's a problem: Putting cross-field validation (password match) on an individual Zod field

How to avoid: Apply .refine or .superRefine to the top-level object schema and set the path option to point the error at the correct field.

Why it's a problem: Using mode: 'onSubmit' instead of mode: 'onChange' when asking for real-time validation

How to avoid: Include 'mode: onChange for real-time field validation' in your Cursor prompt. Add it to .cursorrules so it applies by default.

Why it's a problem: Forgetting to handle server-side validation errors from the API response

How to avoid: Always ask Cursor to generate both client-side and server-side error handling in the same prompt, mapping API error fields to setError calls.

Best practices

  • Define Zod schemas in a separate src/schemas/ file and import them into form components — this lets you reuse the same schema on the server for API validation.
  • Use z.infer<typeof schema> to derive the TypeScript type from the schema rather than maintaining a separate interface that can drift out of sync.
  • Add the mode: 'onChange' and resolver: zodResolver(schema) to .cursorrules so you never need to repeat these in individual prompts.
  • For async validations (username availability), debounce the API call using useDebounce to avoid hammering the server on every keystroke.
  • Keep error messages in the Zod schema, not in JSX — this makes them easy to localise and keeps component code clean.
  • Always test the form's disabled submit state: the button should be disabled while isSubmitting is true to prevent double-submissions.
  • Reference the form component with @file when extending it in Cursor so the AI sees the existing schema and does not regenerate it from scratch.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

Write a React 18 TypeScript registration form using react-hook-form v7 and Zod. Fields: email (valid email), password (min 8 chars, one uppercase), confirmPassword (must match password), username (3-20 chars, alphanumeric). Use zodResolver, mode onChange. Show inline error messages below each field. Type the submit handler as (data: z.infer<typeof schema>) => Promise<void>.

Cursor Prompt

In Cursor Chat (Cmd+L), type: 'Generate a React registration form at src/components/RegistrationForm.tsx with Zod schema and react-hook-form zodResolver. Fields: email (valid email), password (min 8 + one uppercase), confirmPassword (must match password via .refine), username (3-20 alphanumeric). mode: onChange. Inline error messages below each field. Typed onSubmit prop. Named export.'

Frequently asked questions

Can I use Yup instead of Zod with these Cursor prompts?

Yes. Replace 'Zod' with 'Yup' in your prompt and .cursorrules. The @hookform/resolvers package supports both. Zod is preferred because it provides TypeScript inference via z.infer, which eliminates the need for a separate type definition.

How do I get Cursor to generate a multi-step wizard form?

Open Composer (Cmd+I) and prompt: 'Convert RegistrationForm.tsx into a 3-step wizard. Step 1: email and username. Step 2: password and confirmPassword. Step 3: account type and company name. Validate each step on Next click using trigger() from react-hook-form. Show a progress indicator.' Reference the current file with @file.

Why does Cursor keep adding value and onChange props manually instead of using register?

This happens when Cursor generates a controlled input pattern from scratch instead of using react-hook-form's register. Add 'Always use the register spread from useForm — never use value/onChange props directly' to .cursorrules.

Can Cursor generate accessible form markup with ARIA attributes?

Yes. Add 'Each input must have an associated <label> and an aria-describedby attribute pointing to its error message element' to your prompt. Cursor will generate the id and htmlFor attributes to link labels, inputs, and error messages correctly.

How do I prevent Cursor from regenerating the Zod schema when I extend the form?

Reference the existing file with @file src/components/RegistrationForm.tsx in your Composer prompt. Cursor will read the existing schema and extend it with new fields rather than regenerating everything from scratch.

Does this approach work with React Native forms?

react-hook-form and Zod both work in React Native. Replace HTML inputs with TextInput components and remove Tailwind class names. Add 'This is a React Native project — use TextInput, Text, and View instead of HTML elements' to your Cursor prompt.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.