Skip to content

React

React Hook Form and Zod. One Contract. Two Checks.

Client guidance. Server authority.

Aidxn
Written by Aiden Wood Founder and Lead Design Engineer
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. SOURCEdata in DECIDElogic RESULTaction out

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.

Browse Velocity Components

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.

Sources and further reading

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.