Skip to content
Back to Build Tutorials

Form Pipeline

Build forms with schema validation, accessible inputs, and progressive enhancement.

Why

Forms are the primary way users give you data — sign up, checkout, settings — and a bad form experience directly costs conversions. A form pipeline with zod + react-hook-form gives you: schema-driven validation (one source of truth for client and server), accessible by default (label, aria, error association), and progressive enhancement (works without JS, validates on blur, submits on enter). Without this, forms are a pile of useState + manual validation + ad-hoc error messages that are inconsistent, inaccessible, and don't match server validation. The schema-driven approach means adding a field is a one-line schema change, not a 5-file edit.

When

When the app has ≥1 user-input form. Takes 1-2 h to set up the pipeline. Skip for read-only apps.

How

1. Define the form schema with zod — this is the single source of truth
   - const schema = z.object({ email: z.string().email(), password: z.string().min(8) })
   - Add refine() for cross-field validation (password confirm matches)

2. Use react-hook-form with zodResolver to wire the schema to the form
   - const form = useForm({ resolver: zodResolver(schema) })
   - register("email") gives you onChange, onBlur, ref, and error state

3. Build accessible input components: label + input + error message
   - <label htmlFor="email"> + <input id="email" aria-invalid={!!error} aria-describedby="email-error" />
   - Error: <p id="email-error" role="alert">{error.message}</p>

4. Submit with a server action that re-validates with the same schema
   - Never trust client validation alone — the server is the authority
   - Return typed errors (field → message) that the form can display

5. Add progressive enhancement: form works without JS
   - Use a native <form action={serverAction}> so it submits without JS
   - JS enhances with inline validation, but the fallback is a normal POST

Example

import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const schema = z.object({
  email: z.string().email("Enter a valid email"),
  password: z.string().min(8, "At least 8 characters"),
});
type FormData = z.infer<typeof schema>;

export function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({ resolver: zodResolver(schema) });
  return (
    <form action={signupAction} onSubmit={handleSubmit(onSubmit)}>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" {...register("email")} aria-invalid={!!errors.email} aria-describedby="email-err" />
      {errors.email && <p id="email-err" role="alert">{errors.email.message}</p>}
      <button type="submit">Sign up</button>
    </form>
  );
}

Common Problems

  • ⚠Validating only on submit, so the user fills the whole form before learning the first field is wrong.
  • ⚠Not associating labels with inputs (no htmlFor/id), so screen readers announce 'edit text' with no context.
  • ⚠Different validation messages on client and server, so the user fixes a client error and gets a different server error.
  • ⚠Disabling the submit button during validation, so users with JS disabled can never submit.

Best Practices

  • ✓Validate on blur (not on every keystroke) to give immediate feedback without being annoying.
  • ✓Use the same zod schema on client and server so validation is identical by construction.
  • ✓Show success state clearly (green check, confirmation message) — users need to know the form worked.
  • ✓For long forms, split into steps with a progress indicator and per-step validation.