Stop Validating Forms Twice: The Single-Schema Pattern
Every form in a production app needs two layers of validation: client-side for instant feedback, server-side for security. The problem is that most teams implement these separately — different code, different rules, different error messages — and they inevitably diverge.
A user submits a form. The client says it's valid. The server disagrees. The error message doesn't match what the UI expects. Now you're debugging a form.
The Drift Problem
Here's how it usually looks:
// Client-side (React Hook Form custom validation)
const validate = (values) => {
const errors = {}
if (!values.email?.includes("@")) errors.email = "Invalid email"
if (values.name?.length < 2) errors.name = "Too short"
return errors
}
// Server-side (API route)
if (!body.email || !body.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
return { error: "Please provide a valid email address" }
}
Different regex. Different error text. When you add a new field, you update one place and forget the other. When the PM asks to change the minimum name length from 2 to 3, you update the server and the client still accepts 2-character names.
One Schema, Two Validation Points
Zod lets you define the schema once and use it everywhere:
// schemas.ts — single source of truth
import { z } from "zod"
export const profileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Enter a valid email address"),
bio: z.string().max(500).optional(),
})
export type ProfileValues = z.infer<typeof profileSchema>
On the client, React Hook Form's zodResolver uses this schema for instant validation:
import { zodResolver } from "@hookform/resolvers/zod"
const form = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
})
On the server, the same schema validates incoming data:
export async function updateProfile(values: unknown) {
const parsed = profileSchema.safeParse(values)
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message }
}
// proceed with parsed.data — typed and validated
}
Same rules. Same error messages. Same types. Change the schema, both sides update.
Why safeParse, Not parse
Zod's parse() throws an exception on invalid input. safeParse() returns a discriminated union:
const result = schema.safeParse(input)
if (result.success) {
// result.data is fully typed
} else {
// result.error.issues contains structured error info
}
Exceptions are expensive and awkward to type. Return values are cheap and type-safe. In a server action, you want to return an error to the client — not throw one and hope something catches it.
Server Actions vs API Routes
Next.js server actions let you skip the API route entirely:
"use server"
export async function updateProfile(values: ProfileValues) {
const session = await auth()
if (!session) return { success: false, error: "Unauthorized" }
const parsed = profileSchema.safeParse(values)
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message }
}
await db.user.update({ where: { id: session.userId }, data: parsed.data })
return { success: true }
}
The client calls this like a function. No fetch, no URL, no serialization boilerplate. The form component handles the result:
async function onSubmit(values: ProfileValues) {
const result = await updateProfile(values)
if (result.success) {
toast.success("Saved")
} else {
toast.error(result.error)
}
}
Surfacing Server Errors in the Form UI
Sometimes the server catches errors the client can't — like "email already taken." You need these to appear in the same form UI:
const result = await updateProfile(values)
if (!result.success) {
form.setError("root", { message: result.error })
}
setError("root", ...) in React Hook Form adds a form-level error. You can also target specific fields: form.setError("email", { message: "Already taken" }). The user sees one consistent error experience regardless of whether the client or server caught the issue.
The Full Stack
| Layer | Responsibility |
|---|---|
| Zod schema | Shape, rules, error messages — defined once |
| `z.infer<typeof schema>` | TypeScript types — auto-generated, zero drift |
| React Hook Form + zodResolver | Client validation — instant feedback |
| Server action + `safeParse` | Server validation — security boundary |
| `form.setError()` | Surface server errors in the form UI |
Add a field to the schema and TypeScript lights up every file that needs updating. Change a validation rule and both client and server apply it. No drift, no duplicated logic, no mystery errors in production.