How to share one Zod schema between a React form and a serverless handler
When I built the contact form on this site, I hit a classic problem: I needed to validate the same data in two places. The browser needs to check it so the user gets instant feedback, and the server needs to check it because you can never trust anything a browser sends you. The naive fix is to write the rules twice — but then the two copies drift apart, and a bug slips through the gap.
The fix is to write the validation once and import it in both places. Zod makes this easy because a Zod schema is just a value you can export. Let me show you how.
What Zod is
Zod is a validation library. You describe the shape of your data — a “schema” — and Zod gives you a function that checks whether some unknown input matches it. The nice bonus: Zod can infer a TypeScript type from that schema, so your types and your runtime checks never disagree.
Step 1: define the schema once
I keep the schema in its own file, src/lib/contact-schema.ts, so nothing
about React or the server leaks into it. It’s pure data validation:
// src/lib/contact-schema.ts
import { z } from "zod"
export const contactSchema = z.object({
name: z
.string()
.trim()
.min(2, "Please enter your name (at least 2 characters).")
.max(80, "That name is too long."),
email: z
.email("Please enter a valid email address.")
.trim()
.min(1, "Email is required.")
.max(254),
message: z
.string()
.trim()
.min(10, "Message should be at least 10 characters.")
.max(3000, "Message is too long (3000 characters max)."),
// Honeypot — real users leave this empty; bots tend to fill every field.
company: z.string().max(0).optional().or(z.literal("")),
})
export type ContactInput = z.infer<typeof contactSchema>A few things worth pointing out for beginners:
.trim()strips leading and trailing whitespace before the length checks run, so a name of" Al "is validated as"Al".- The string passed to
.min(),.max(), and.email()is the error message shown when that rule fails. Writing them here means both the form and the server produce identical messages. companyis a honeypot field.z.string().max(0)means “a string of length zero” — real users leave it blank, but spam bots fill in every field they find, which trips this rule. More on that in stopping form spam with a honeypot field.z.infer<typeof contactSchema>produces a TypeScript type from the schema. I never hand-write theContactInputtype; it’s derived, so it can’t fall out of sync.
Step 2: use it in the client form
On the client I use react-hook-form with its Zod adapter, zodResolver. The
full picture — including the accessibility wiring — is in building an accessible form with react-hook-form and Zod. The
resolver is the bridge — it hands form values to the schema and turns any Zod
errors into per-field form errors:
// src/components/portfolio/ContactForm.tsx
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { contactSchema, type ContactInput } from "@/lib/contact-schema"
const { register, handleSubmit, formState: { errors } } =
useForm<ContactInput>({
resolver: zodResolver(contactSchema),
mode: "onBlur",
})Because I typed the form with ContactInput — the same inferred type — the
form fields are type-checked against the schema. If I rename message in the
schema, TypeScript flags the form until I fix it too.
Step 3: use it in the serverless handler
The serverless function that actually sends the email with Resend imports the same schema
and validates the request body with safeParse, which returns a result object
instead of throwing:
// api/contact.ts
const parsed = contactSchema.safeParse(req.body)
if (!parsed.success) {
return res.status(400).json({
ok: false,
error: "Validation failed",
fieldErrors: parsed.error.flatten().fieldErrors,
})
}
const { name, email, message, company } = parsed.datasafeParse gives you { success: true, data } on a pass or
{ success: false, error } on a failure. When it fails I return HTTP 400
with parsed.error.flatten().fieldErrors — a tidy object mapping each field to
its error messages. When it passes, parsed.data is fully typed and trimmed,
ready to use.
Why this matters
flowchart LR S["contact-schema.ts<br/>(one schema)"] --> C["React form<br/>zodResolver"] S --> H["Serverless handler<br/>safeParse"]
The client validation is a UX nicety — fast, friendly, but easily bypassed by
anyone using curl. The server validation is the real gate. Because both read
from the same file, “at least 2 characters” always means the same thing on both
sides. Change the rule once, and both ends update together.
What to remember
- Put your Zod schema in its own framework-free module and export it.
- On the client, feed it to react-hook-form via
zodResolver. - On the server, validate untrusted input with
schema.safeParse(req.body). - Derive your TypeScript type with
z.inferso types can’t drift from rules. - Client validation is for UX; server validation is for safety. You need both.
Related reading: because the schema is one shared value, it’s also the easiest layer to test — see unit-testing the form and serverless handler with Vitest.