Always indicate which fields are required. Users get frustrated when they experience a wasted trip to the server, just because they did not get an obvious indication of what was required first time around.
❌ Figure: Bad example - No required indicators. Users must guess which fields are mandatory
A designer can choose the most appropriate way to indicate required fields based on the layout and context. If that guidance isn’t available, use a red asterisk as a clear and widely understood default.
✅ Figure: Good example - Required fields marked with a red asterisk. Unmarked fields are optional
Clearly communicate validation states in real time. When a field fails, show a helpful message next to the field along with a clear visual cue, such as red color and an error icon, explaining what needs to be fixed.
✅ Figure: Good example - Invalid field is highlighted with a message explaining the issue
When it passes, provide positive confirmation so users know their input is valid and they can continue with confidence.
✅ Figure: Good example - Once the input is valid, the error styling clears and a subtle check confirms success
Validating only on submit means users don't discover errors until after they've filled in the whole form. Validate when the user leaves a field so they get feedback while the context is still fresh.
// ❌ Bad - default mode only fires on submitconst form = useForm({resolver: zodResolver(schema),})
❌ Figure: Bad example - Errors only appear after the user submits, creating a frustrating "wasted trip"
// ✅ Good - fires when the user leaves each fieldconst form = useForm({resolver: zodResolver(schema),mode: "onBlur",})
✅ Figure: Good example - Errors appear as soon as the user moves away from an invalid field
Keep all validation rules in a single schema co-located with the form. This provides type safety, readable error messages, and a single source of truth for both client and server validation.
// ✅ Good - Zod schema defines all rules in one placeconst contactSchema = z.object({fullName: z.string().min(1, "Name is required"),email: z.string().email("Please enter a valid email address"),password: z.string().min(8).regex(new RegExp('^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$'), {message:'Password must be at least 8 characters and contain an uppercase letter, lowercase letter, and number'})company: z.string().min(1, "Company name is required"),})
✅ Figure: Good example - Zod schema with clear, user-facing error messages
Errors must appear in the vicinity of the field that failed, not in a summary banner at the top of the form. Using a component like <FormMessage /> (from shadcn/ui or a similar library) handles this automatically.
<FormItem><FormLabel>Email <span aria-hidden="true" className="text-red-500">*</span></FormLabel><FormControl><Input type="email" placeholder="Enter your email" {...field} /></FormControl><FormMessage /> {/* renders the Zod error message inline */}</FormItem>
✅ Figure: Good example - FormMessage renders the error directly below its field with appropriate styling
Client-side validation improves UX; server-side validation enforces correctness. Both are required. Surface API validation errors as inline messages or toast notifications, never silently discard them.
Learn more about server-side validation in this rule Use Fluent Validation
Tip: The same Zod schema can be shared between your frontend and a Node.js/tRPC backend, ensuring client and server rules never drift apart.