Form Patterns Basic
Copy-paste patterns for the most common forms - login, signup, and contact - with Zod validation and proper UX.
Search across all documentation pages
Copy-paste patterns for the most common forms - login, signup, and contact - with Zod validation and proper UX.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// Login schema
const LoginSchema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(1, "Password required"),
});
type LoginData = z.infer<typeof LoginSchema>;
function LoginForm({ onSubmit }: { onSubmit: (data: LoginData) => Promise<void> }) {
const { register, handleSubmit, formState: { errors, isSubmitting }, setError } = useForm<LoginData>({
resolver: zodResolver(LoginSchema),
});
async function handleLogin(data: LoginData) {
try {
await onSubmit(data);
} catch {
setError("root", { message: "Invalid email or password" });
}
}
return (
<form onSubmit={handleSubmit(handleLogin)}>
{errors.root && <p className="text-red-600">{errors.root.message}</p>}
<input {...register("email")} type="email" placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register("password")} type="password" placeholder="Password" />
{errors.password && <p>{errors.password.message}</p>}
<button disabled={isSubmitting}>{isSubmitting ? "Logging in..." : "Log in"}</button>
</form>
);
}When to reach for this: When building standard authentication or contact flows - these patterns cover 80% of forms in a typical app.
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// --- Signup Form ---
const SignupSchema = z
.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email"),
password: z
.string()
.min(8, "At least 8 characters")
.regex(/[A-Z]/, "Need an uppercase letter")
.regex(/[0-9]/, "Need a number"),
confirmPassword: z.string(),
terms: z.literal(true, { errorMap: () => ({ message: "You must accept the terms" }) }),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type SignupData = z.infer<typeof SignupSchema>;
export function SignupForm() {
const [success, setSuccess] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm<SignupData>({
resolver: zodResolver(SignupSchema),
defaultValues: { name: "", email: "", password: "", confirmPassword: "", terms: false as any },
});
async function onSubmit(data: SignupData) {
try {
const res = await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const body = await res.json();
if (body.field) {
setError(body.field, { message: body.message });
} else {
setError("root", { message: body.message || "Something went wrong" });
}
return;
}
setSuccess(true);
} catch {
setError("root", { message: "Network error. Please try again." });
}
}
if (success) {
return <p className="text-green-600 font-medium">Account created! Check your email.</p>;
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-md space-y-4">
{errors.root && (
<div className="rounded bg-red-50 p-3 text-sm text-red-700">{errors.root.message}</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium">Name</label>
<input id="name" {...register("name")} className="w-full rounded border p-2" />
{errors.name && <p className="text-sm text-red-600">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input id="email" {...register("email")} type="email" className="w-full rounded border p-2" />
{errors.email && <p className="text-sm text-red-600">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">Password</label>
<input id="password" {...register("password")} type="password" className="w-full rounded border p-2" />
{errors.password && <p className="text-sm text-red-600">{errors.password.message}</p>}
</div>
<div>
<label htmlFor="confirm" className="block text-sm font-medium">Confirm Password</label>
<input id="confirm" {...register("confirmPassword")} type="password" className="w-full rounded border p-2" />
{errors.confirmPassword && <p className="text-sm text-red-600">{errors.confirmPassword.message}</p>}
</div>
<div className="flex items-center gap-2">
<input id="terms" type="checkbox" {...register("terms")} />
<label htmlFor="terms" className="text-sm">I accept the terms and conditions</label>
</div>
{errors.terms && <p className="text-sm text-red-600">{errors.terms.message}</p>}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Creating account..." : "Create Account"}
</button>
</form>
);
}What this demonstrates:
z.literal(true) validationsetError for field-level and root errorshtmlForzodResolver connects the Zod schema to react-hook-form's validation pipelinesetError("root", ...) sets a form-level error (e.g., "Invalid credentials") not tied to a fieldsetError("email", ...) sets a field-level error from server responses (e.g., "Email already taken")isSubmitting is true while the onSubmit promise is pending - use it to disable the button and show loading textz.literal(true) for checkboxes ensures the box must be checkedContact form with subject dropdown:
const ContactSchema = z.object({
name: z.string().min(1, "Name required"),
email: z.string().email(),
subject: z.enum(["general", "support", "billing", "partnership"]),
message: z.string().min(10).max(2000),
priority: z.enum(["low", "normal", "high"]).default("normal"),
});Login with OAuth buttons:
function LoginPage() {
return (
<div className="space-y-4">
<LoginForm onSubmit={handleEmailLogin} />
<div className="relative text-center text-sm text-gray-500">
<span className="bg-white px-2">or continue with</span>
</div>
<div className="flex gap-2">
<button onClick={() => signIn("google")} className="flex-1 rounded border p-2">Google</button>
<button onClick={() => signIn("github")} className="flex-1 rounded border p-2">GitHub</button>
</div>
</div>
);
}Forgot password flow:
const ForgotSchema = z.object({ email: z.string().email() });
const ResetSchema = z
.object({
token: z.string(),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});// Server error response typing
type ApiError = { field?: keyof SignupData; message: string };
// Type-safe setError
const { setError } = useForm<SignupData>();
setError("email", { message: "Taken" }); // OK
setError("typo", { message: "..." }); // TS error
// Reusable form field component
function Field<T extends FieldValues>({
name, label, form, type = "text",
}: {
name: FieldPath<T>;
label: string;
form: UseFormReturn<T>;
type?: string;
}) {
return (
<div>
<label className="block text-sm font-medium">{label}</label>
<input type={type} {...form.register(name)} className="w-full rounded border p-2" />
{form.formState.errors[name] && (
<p className="text-sm text-red-600">{form.formState.errors[name]?.message as string}</p>
)}
</div>
);
}Generic "Invalid credentials" for login - Never reveal whether the email or password was wrong. Fix: Always show "Invalid email or password" as a root error.
Password field not preserved on error - Browsers clear password fields on form resubmission. This is expected security behavior; do not try to repopulate passwords.
Checkbox with register - register for checkboxes works but returns a string "on" or undefined. Fix: Use z.literal(true) and ensure the checkbox value maps to a boolean, or use Controller.
Rate limiting - Login and signup forms are prime targets for brute force. Fix: Add rate limiting on the server and show appropriate error messages.
| Alternative | Use When | Don't Use When |
|---|---|---|
| Server Action forms | You want progressive enhancement without client JS | You need instant field validation |
| Auth.js (NextAuth) | You need full auth with OAuth, sessions, etc. | You only need a simple login form |
| Clerk / Auth0 | You want managed auth with pre-built UI | You need full control over the auth flow |
| shadcn Form | You want polished UI with minimal effort | You are building a headless form |
z.literal(true) requires the value to be exactly true, not just truthyerrorMap: z.literal(true, { errorMap: () => ({ message: "You must accept" }) })errors.root?.messageconst body = await res.json();
if (body.field) {
setError(body.field, { message: body.message });
}setError("email", { message: "Already taken" }) attaches the error to the email field.refine() operates on the whole object, so it can compare password and confirmPasswordpath: ["confirmPassword"] to attach the error to the correct fieldisSubmitting is true while the onSubmit promise is pendingfalse when the promise resolves or rejectssetError("root", { message: "Invalid email or password" })register on a checkbox returns "on" or undefined instead of a booleanz.literal(true) expects a boolean, causing a type mismatchController for checkboxes, or ensure the value maps to a booleanfunction Field<T extends FieldValues>({
name, label, form,
}: {
name: FieldPath<T>;
label: string;
form: UseFormReturn<T>;
}) {
return <input {...form.register(name)} />;
}FieldPath<T> constrains name to valid field paths of the form typetype ApiError = { field?: keyof SignupData; message: string };field to keyof SignupData ensures only valid field names can be passed to setErroruseState(false) boolean tracks whether signup succeededtrue and render a success message instead of the formzodResolver(Schema) adapts a Zod schema to react-hook-form's resolver interfaceschema.safeParse() and maps Zod errors to RHF's FieldErrors format@hookform/resolvers/zodReviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥