Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Form validation in Next.js App Router with React Hook Form and Zod

June 5, 2026
nextjsreact-hook-formzodformssaas

The pattern

Every form in this boilerplate follows the same three-step structure: Zod schema first, TypeScript type inferred from it, then useForm configured with the Zod resolver. The schema is the single source of truth -- the type and the form both derive from it.

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8, "Password must be at least 8 characters"),
});
 
type FormData = z.infer<typeof schema>;

This keeps validation logic in one place and eliminates drift between client-side rules and TypeScript types.

Setting up the form

Install the Zod resolver for React Hook Form if it is not already in the project:

npm install @hookform/resolvers

Then wire up the form:

"use client";
 
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
 
const schema = z.object({
  email: z.string().email("Enter a valid email"),
  password: z.string().min(8, "Minimum 8 characters"),
});
 
type FormData = z.infer<typeof schema>;
 
export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm<FormData>({
    resolver: zodResolver(schema),
  });
 
  async function onSubmit(data: FormData) {
    // call your API here
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} type="email" />
      {errors.email && <p>{errors.email.message}</p>}
 
      <input {...register("password")} type="password" />
      {errors.password && <p>{errors.password.message}</p>}
 
      <button type="submit" disabled={isSubmitting}>
        Sign in
      </button>
    </form>
  );
}

handleSubmit only calls onSubmit when all fields pass Zod validation. The errors object is populated automatically -- no manual validation logic needed.

Shadcn UI form components

The boilerplate ships with Shadcn Form components that wrap React Hook Form with accessible labels and error messages. Use them for new forms instead of raw <input> elements:

import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
 
export function LoginForm() {
  const form = useForm<FormData>({ resolver: zodResolver(schema) });
 
  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" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit" disabled={form.formState.isSubmitting}>
          Sign in
        </Button>
      </form>
    </Form>
  );
}

<FormMessage /> renders errors.email.message automatically. No conditional rendering needed.

Handling server-side errors

Client-side validation catches format errors, but some errors only exist on the server -- duplicate email, wrong password, expired token. Map those back to form fields using setError:

async function onSubmit(data: FormData) {
  const res = await fetch("/api/auth/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
 
  if (!res.ok) {
    const body = await res.json();
 
    if (res.status === 409) {
      setError("email", { message: "Email already in use" });
      return;
    }
 
    if (res.status === 401) {
      setError("password", { message: "Wrong password" });
      return;
    }
 
    setError("root", { message: body.error ?? "Something went wrong" });
    return;
  }
 
  // success path
}

setError("root", ...) attaches an error to the form itself rather than a specific field. Render it above the submit button:

{form.formState.errors.root && (
  <p className="text-sm text-destructive">
    {form.formState.errors.root.message}
  </p>
)}

Reusable Zod schemas

Validation schemas live in modules/*/[name].validation.ts -- never inline in components. This lets services and routes reuse the same schema for server-side validation:

// modules/user/user.validation.ts
import { z } from "zod";
 
export const updateProfileSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters").max(100),
  bio: z.string().max(500).optional(),
});
 
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;

The API route uses safeParse on the same schema:

// app/api/profile/route.ts
import { updateProfileSchema } from "@/modules/user/user.validation";
import { handleError } from "@/lib/errors";
 
export async function PUT(req: Request) {
  try {
    const body = await req.json();
    const result = updateProfileSchema.safeParse(body);
 
    if (!result.success) {
      return Response.json(
        { error: result.error.errors[0].message },
        { status: 400 }
      );
    }
 
    // result.data is fully typed here
  } catch (error: unknown) {
    return handleError(error);
  }
}

Always use safeParse -- never parse. It returns a result object instead of throwing, which keeps error handling explicit.

Optimistic loading state

Disable the submit button while the request is in-flight using isSubmitting from formState:

<Button type="submit" disabled={form.formState.isSubmitting}>
  {form.formState.isSubmitting ? "Saving..." : "Save"}
</Button>

isSubmitting is true while handleSubmit's async callback is running. It resets automatically when the promise resolves or rejects -- no manual useState flag needed.

Takeaway

The schema-first pattern -- Zod schema, inferred type, zodResolver -- eliminates the drift between validation rules and TypeScript types that plagues forms built without this structure. Put schemas in modules/*/[name].validation.ts, reuse them in both the form and the API route, and use setError to surface server-side errors back to specific fields. That pattern covers 95% of the form work you will encounter building a SaaS with this stack.