ehsan.blog
~/blog/how-to-share-one-zod-schema-between-client-and-server — zsh
cat how-to-share-one-zod-schema-between-client-and-server.md

How to share one Zod schema between a React form and a serverless handler

·4 min read

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:

ts
// 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.
  • company is 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 the ContactInput type; 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:

tsx
// 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:

ts
// 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.data

safeParse 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.infer so 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.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.