Back to blog
Dev Education
10 min read
Shadcn UI
Forms

Shadcn UI Form Handling: React Hook Form + Zod Validation

A complete guide to building validated forms with Shadcn UI, React Hook Form, and Zod. Learn schema-based validation, error display, and server action submission using real contact and login form patterns.

SB

SERP Blocks Team

Product

Shadcn UI Form Handling: React Hook Form + Zod Validation cover

Forms are the backbone of web applications. Login pages, contact forms, checkout flows, settings panels — every meaningful user interaction eventually passes through a form. Getting form handling right means balancing developer experience, user experience, and data integrity all at once.

Shadcn UI's form components are built on top of React Hook Form and designed to work seamlessly with Zod for schema-based validation. This combination has become the standard approach in the React and Next.js ecosystem, and for good reason: it gives you type-safe validation, minimal re-renders, accessible error messages, and full control over every aspect of the form behavior.

This guide walks through the entire setup from scratch — installing the dependencies, creating Zod schemas, wiring up React Hook Form with Shadcn components, displaying validation errors, and handling submission with Next.js server actions.

Why This Stack Works

Before jumping into code, it's worth understanding why React Hook Form + Zod + Shadcn UI has become the dominant pattern.

React Hook Form manages form state using uncontrolled components and refs under the hood. This means your form inputs don't trigger re-renders on every keystroke — only when validation runs or the form submits. For forms with many fields (like the multi-section contact forms you see in production apps), this performance advantage matters.

Zod provides runtime type validation with full TypeScript inference. You define a schema once and get both the runtime validation logic and the TypeScript type from the same source. No more keeping your validation rules and TypeScript interfaces in sync manually.

Shadcn UI's Form components wrap React Hook Form's Controller and FormProvider patterns into clean, composable components — Form, FormField, FormItem, FormLabel, FormControl, FormDescription, and FormMessage. They handle accessibility attributes (like linking error messages to inputs via aria-describedby) automatically.

Setting Up the Dependencies

Assuming you already have a Next.js project with Shadcn UI initialized, add the form component and its peer dependencies:

npx shadcn@latest add form

This installs react-hook-form, @hookform/resolvers, and zod automatically, and copies the Form component files into your project. If you also need specific input components, add those too:

npx shadcn@latest add input label select textarea checkbox

After running these commands, you'll have the component source files in your components/ui directory — fully editable, no black-box dependencies.

Building a Contact Form with Zod Validation

Contact forms are one of the most common form patterns on the web. The SERP Blocks contact collection includes 33 different contact form designs — from simple single-column layouts to split-screen designs with maps and business info. Let's build the validation layer that could power any of them.

Step 1: Define the Zod Schema

Create your validation schema in a separate file so it can be shared between client and server:

// lib/schemas/contact.ts
import { z } from "zod";

export const contactFormSchema = z.object({
  name: z
    .string()
    .min(2, "Name must be at least 2 characters")
    .max(100, "Name must be under 100 characters"),
  email: z
    .string()
    .email("Please enter a valid email address"),
  subject: z
    .string()
    .min(1, "Please select a subject"),
  message: z
    .string()
    .min(10, "Message must be at least 10 characters")
    .max(2000, "Message must be under 2000 characters"),
});

export type ContactFormValues = z.infer<typeof contactFormSchema>;

Notice the z.infer at the bottom — this derives the TypeScript type directly from the schema. The ContactFormValues type is fully typed as:

{
  name: string;
  email: string;
  subject: string;
  message: string;
}

No manual interface required. If you add or change a field in the schema, the type updates automatically.

Step 2: Create the Form Component

Here's the complete contact form component using Shadcn UI's Form primitives:

"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { contactFormSchema, type ContactFormValues } from "@/lib/schemas/contact";

export function ContactForm() {
  const form = useForm<ContactFormValues>({
    resolver: zodResolver(contactFormSchema),
    defaultValues: {
      name: "",
      email: "",
      subject: "",
      message: "",
    },
  });

  async function onSubmit(values: ContactFormValues) {
    // values is fully typed and validated at this point
    console.log(values);
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Name</FormLabel>
              <FormControl>
                <Input placeholder="Your name" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" placeholder="you@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="subject"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Subject</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="Select a subject" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="general">General Inquiry</SelectItem>
                  <SelectItem value="support">Support</SelectItem>
                  <SelectItem value="billing">Billing</SelectItem>
                  <SelectItem value="partnership">Partnership</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="message"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Message</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="Tell us what you need..."
                  className="min-h-[120px]"
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit" className="w-full">
          Send Message
        </Button>
      </form>
    </Form>
  );
}

The key pattern here is the FormField render prop. Each field gets:

  • FormLabel — automatically linked to the input via htmlFor

  • FormControl — wraps the actual input and passes necessary props

  • FormMessage — displays validation errors when they exist, hidden when the field is valid

Step 3: Understanding the Validation Flow

When a user clicks "Send Message," here's what happens:

  1. form.handleSubmit intercepts the native form submission

  2. React Hook Form runs the Zod resolver against all field values

  3. If validation fails, errors are set on the corresponding fields and FormMessage components render the error strings

  4. If validation passes, your onSubmit function receives the fully validated and typed data

  5. The form never submits invalid data to your handler

By default, React Hook Form validates on submit. You can change this to validate on blur or on change:

const form = useForm<ContactFormValues>({
  resolver: zodResolver(contactFormSchema),
  mode: "onBlur", // validates when user leaves a field
  // or: mode: "onChange" — validates on every keystroke
  defaultValues: {
    name: "",
    email: "",
    subject: "",
    message: "",
  },
});

For contact forms, onBlur is usually the best balance — users get feedback after filling each field without the distraction of real-time validation on every keystroke.

Building a Login Form

Login and authentication pages are another critical form pattern. The SERP Blocks login collection includes 17 login page designs — centered cards, split layouts, forms with social auth buttons, and more. Here's how the validation side works.

Login Schema

// lib/schemas/auth.ts
import { z } from "zod";

export const loginFormSchema = z.object({
  email: z
    .string()
    .email("Please enter a valid email address"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters"),
});

export type LoginFormValues = z.infer<typeof loginFormSchema>;

export const signUpFormSchema = z
  .object({
    name: z.string().min(2, "Name must be at least 2 characters"),
    email: z.string().email("Please enter a valid email address"),
    password: z
      .string()
      .min(8, "Password must be at least 8 characters")
      .regex(
        /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
        "Password must include uppercase, lowercase, and a number"
      ),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords don't match",
    path: ["confirmPassword"],
  });

export type SignUpFormValues = z.infer<typeof signUpFormSchema>;

The sign-up schema demonstrates two important Zod features:

  1. Regex validation — the password field uses .regex() to enforce complexity rules

  2. Cross-field validation with .refine() — the confirmPassword field is validated against the password field. The path option tells Zod which field to attach the error to

These patterns apply to the 11 sign up page designs in SERP Blocks as well.

Login Form Component

"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { loginFormSchema, type LoginFormValues } from "@/lib/schemas/auth";

export function LoginForm() {
  const form = useForm<LoginFormValues>({
    resolver: zodResolver(loginFormSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

  async function onSubmit(values: LoginFormValues) {
    // Handle authentication
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" placeholder="you@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input type="password" placeholder="Enter your password" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit" className="w-full">
          Sign In
        </Button>
      </form>
    </Form>
  );
}

Handling Submission with Server Actions

In Next.js 15, server actions are the recommended way to handle form submissions that need to interact with a database or external API. Here's how to wire up a Shadcn form to a server action while keeping client-side validation intact.

The Server Action

// app/actions/contact.ts
"use server";

import { contactFormSchema, type ContactFormValues } from "@/lib/schemas/contact";

export async function submitContactForm(data: ContactFormValues) {
  // Always re-validate on the server — never trust client data
  const parsed = contactFormSchema.safeParse(data);

  if (!parsed.success) {
    return {
      success: false,
      errors: parsed.error.flatten().fieldErrors,
    };
  }

  // Now parsed.data is validated and typed
  try {
    // Save to database, send email, call API, etc.
    await saveContactSubmission(parsed.data);

    return { success: true };
  } catch (error) {
    return {
      success: false,
      errors: { _form: ["Something went wrong. Please try again."] },
    };
  }
}

The critical pattern here is re-validating with the same Zod schema on the server. Because the schema is defined in a shared file (lib/schemas/contact.ts), you use the exact same validation rules on both client and server. The client validation provides instant feedback; the server validation provides security.

Connecting the Client Form to the Server Action

Update the onSubmit handler to call the server action and handle the response:

import { submitContactForm } from "@/app/actions/contact";
import { useTransition } from "react";
import { toast } from "sonner";

export function ContactForm() {
  const [isPending, startTransition] = useTransition();

  const form = useForm<ContactFormValues>({
    resolver: zodResolver(contactFormSchema),
    defaultValues: {
      name: "",
      email: "",
      subject: "",
      message: "",
    },
  });

  async function onSubmit(values: ContactFormValues) {
    startTransition(async () => {
      const result = await submitContactForm(values);

      if (result.success) {
        toast.success("Message sent successfully!");
        form.reset();
      } else if (result.errors) {
        // Map server errors back to form fields
        Object.entries(result.errors).forEach(([field, messages]) => {
          if (field === "_form") {
            toast.error(messages?.[0]);
          } else {
            form.setError(field as keyof ContactFormValues, {
              message: messages?.[0],
            });
          }
        });
      }
    });
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        {/* ...form fields... */}

        <Button type="submit" className="w-full" disabled={isPending}>
          {isPending ? "Sending..." : "Send Message"}
        </Button>
      </form>
    </Form>
  );
}

Key points in this pattern:

  • useTransition tracks the pending state of the server action so you can disable the button and show loading text

  • If the server returns field-level errors, form.setError maps them back to the correct fields and FormMessage displays them automatically

  • General form errors (like a database failure) use toast for a global notification

  • form.reset() clears all fields on success

Advanced Zod Patterns for Forms

Conditional Validation

Sometimes fields are only required based on other field values. For example, a contact form that asks for a phone number only if the user selects "Phone" as their preferred contact method:

const contactSchema = z
  .object({
    contactMethod: z.enum(["email", "phone"]),
    email: z.string().email().optional(),
    phone: z.string().optional(),
  })
  .refine(
    (data) => {
      if (data.contactMethod === "email") return !!data.email;
      if (data.contactMethod === "phone") return !!data.phone;
      return true;
    },
    {
      message: "Please provide your contact info",
      path: ["email"], // or dynamically determine path
    }
  );

Array Fields

For forms with dynamic lists — like adding multiple team members or line items — use z.array() in the schema and useFieldArray from React Hook Form:

const orderSchema = z.object({
  items: z
    .array(
      z.object({
        name: z.string().min(1, "Item name is required"),
        quantity: z.number().min(1, "Quantity must be at least 1"),
        price: z.number().min(0, "Price must be positive"),
      })
    )
    .min(1, "At least one item is required"),
});

This kind of schema backs the more complex form patterns you find in payment form designs (15 blocks) and order confirmation pages (10 blocks) in SERP Blocks.

Custom Error Messages with Context

Zod lets you customize error messages at every level:

const schema = z.object({
  age: z
    .number({
      required_error: "Age is required",
      invalid_type_error: "Age must be a number",
    })
    .min(18, "You must be at least 18 years old")
    .max(120, "Please enter a valid age"),
});

Handling Form State and UX Details

Showing a Loading State

Always disable the form during submission and provide visual feedback:

<Button type="submit" disabled={isPending}>
  {isPending ? (
    <>
      <Loader2 className="mr-2 h-4 w-4 animate-spin" />
      Submitting...
    </>
  ) : (
    "Submit"
  )}
</Button>

Resetting the Form

After successful submission:

form.reset(); // resets to defaultValues
// or reset to specific values:
form.reset({ name: "", email: "", subject: "", message: "" });

Watching Field Values

If you need to react to field changes (like showing a character count for the message field):

const messageValue = form.watch("message");

// In JSX:
<p className="text-sm text-muted-foreground">
  {messageValue?.length || 0} / 2000 characters
</p>

Setting Field Values Programmatically

Useful for "fill from profile" buttons or pre-populating from URL params:

form.setValue("email", user.email, { shouldValidate: true });

Putting It All Together

The React Hook Form + Zod + Shadcn UI pattern handles everything from a two-field login form to a multi-step checkout with dynamic line items. The approach is always the same:

  1. Define a Zod schema for your form data

  2. Create a form with useForm and zodResolver

  3. Use Shadcn FormField components with the render prop pattern

  4. Handle submission with a server action that re-validates server-side

  5. Map server errors back to form fields with form.setError

If you want to skip the form-building phase and start with production-ready designs, SERP Blocks includes over 1,200 blocks across 50+ categories. The 33 contact form designs, 17 login pages, 11 sign up pages, 15 payment forms, and 10 forgot password pages all follow the patterns covered in this guide — with the markup, layout, and styling already done. You get the visual design out of the box, then wire in your Zod schemas and server actions to match your backend.

60 blocks are free to use. The full Pro library is a one-time purchase with lifetime access to every block and every future addition.

    Shadcn UI Form Handling: React Hook Form + Zod Validation | SERP BLOCKS