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.
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.
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.
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>
)}
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.
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.
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.