Form input through two validation boundaries
The browser gives a person quick feedback. The server parses the same request shape again before it performs any privileged action.
React Hook Form and Zod work well together when they share the job cleanly. The browser helps a person correct a form. The server still treats every request as untrusted input.
A shared schema reduces drift between the two places. It does not turn client validation into security. Authentication, permissions, rate limits, database rules, and business decisions remain server work.
Define constraints once
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const contactSchema = z.object({
email: z.string().email('Enter a valid email address.'),
message: z.string().min(10, 'Tell us a little more.'),
subscribe: z.boolean().default(false),
});
type ContactInput = z.input<typeof contactSchema>;
type Contact = z.output<typeof contactSchema>;
export function ContactForm() {
const { register, handleSubmit, formState: { errors } } =
useForm<ContactInput, unknown, Contact>({
resolver: zodResolver(contactSchema),
mode: 'onBlur',
defaultValues: { subscribe: false },
});
return (
<form onSubmit={handleSubmit(sendContact)} noValidate>
<label htmlFor='email'>Email</label>
<input id='email' type='email' aria-invalid={!!errors.email} {...register('email')} />
{errors.email && <p role='alert'>{errors.email.message}</p>}
<button type='submit'>Send</button>
</form>
);
}
z.input describes what arrives before Zod applies defaults or transforms. z.output describes the parsed result. Using both makes the type boundary honest when a schema changes data on the way through.
Parse the request again on the server
export async function POST(request: Request) {
const body = await request.json();
const result = contactSchema.safeParse(body);
if (!result.success) {
return Response.json({ error: 'Invalid form data.' }, { status: 400 });
}
const contact = result.data;
// Check identity, permissions, rate limits, and business rules here.
return Response.json({ ok: true });
}
Someone can skip your React page and send a request directly. Server parsing catches a malformed body. It does not replace the checks that decide whether this person may perform the action.
Choose validation timing on purpose
mode: 'onBlur' is a calm default for many contact forms. It avoids shouting while a person is still typing. Use instant feedback only where it helps, such as a password strength indicator. For a large or expensive schema, avoid re parsing every field on every key press without measuring it.
Test the boundaries that matter
- An invalid email exposes a programmatic error message and focus can reach it.
- An unchecked optional boolean has a deliberate default.
- A request that bypasses the browser receives a server rejection.
- Cross field rules produce one understandable message.
That is enough to turn form validation from duplicated busywork into a maintained contract.
Browse Velocity Components for reusable interface patterns that start with clear states, accessibility, and real failure handling.








