Here's a problem every full-stack team hits: the form validation rules live in three places. React Hook Form validates on the client. Your API handler validates the same shape again. Your database schema enforces constraints a third time. When the form changes, you update all three. When you forget one, you ship a bug. When two disagree, debugging takes three hours.
Zod solves this with a single schema you write once and reuse everywhere. Define the shape, the rules, and the error messages in one file. Use it to validate form input in React, validate request bodies on the server, and match it against Supabase-generated types. One source of truth. Better error messages. Type inference across all three layers. This is how Velocity X handles form validation end-to-end.
The Problem: Validation Rules Scattered Everywhere
Before Zod, a typical feature looked like this:
{`// Client-side validation in React Hook Form
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { email: '', plan: 'pro' }
});
// Server-side validation (duplicated logic)
if (!email || !email.includes('@')) {
return { error: 'Invalid email' };
}
if (!['pro', 'enterprise'].includes(plan)) {
return { error: 'Invalid plan' };
}
// Database schema (duplicated again)
CREATE TABLE subscriptions (
email VARCHAR(255) NOT NULL,
plan VARCHAR(50) NOT NULL
);`}
Three sources of truth. Three places to make mistakes. Three places to update when requirements change.
Zod's Single-Source Pattern: Define Once, Reuse Everywhere
The pattern is simple: write the schema in a shared file that both client and server can import.
{`// lib/schemas.ts — shared schema
import { z } from 'zod';
export const subscriptionSchema = z.object({
email: z.string().email('Invalid email address'),
plan: z.enum(['pro', 'enterprise']),
billingCycle: z.enum(['monthly', 'annual']).default('monthly')
});
export type Subscription = z.infer;`}
Now use it on the client:
{`// components/SubscriptionForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { subscriptionSchema } from '@/lib/schemas';
export function SubscriptionForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(subscriptionSchema)
});
return (
);
}`}
And on the server:
{`// pages/api/subscribe.ts
import { subscriptionSchema } from '@/lib/schemas';
export async function POST(req: Request) {
const body = await req.json();
// Parse and validate in one call
const result = subscriptionSchema.safeParse(body);
if (!result.success) {
return Response.json({ errors: result.error.flatten() }, { status: 400 });
}
const { email, plan, billingCycle } = result.data;
// Insert into database...
}`}
The same schema is parsed on both sides. If the client sends malformed data, the server catches it. If the requirements change, you update the schema once and both sides stay in sync.
Matching Supabase Generated Types
Velocity X sites use Supabase for the database. Supabase's TypeScript client can generate types from your schema:
{`// Start with your Supabase schema
CREATE TABLE subscriptions (
id BIGINT PRIMARY KEY,
email TEXT NOT NULL,
plan TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
// Run: npx supabase gen types typescript --local > lib/database.types.ts
// Resulting type
export type Subscription = {
id: number;
email: string;
plan: string;
created_at: string;
};`}
Your Zod schema should mirror this shape. Define it once, use it to validate incoming data, and the result matches what Supabase expects:
{`// lib/schemas.ts
export const subscriptionSchema = z.object({
email: z.string().email(),
plan: z.enum(['pro', 'enterprise'])
});
// Use with Supabase insert
const { data, error } = await supabase
.from('subscriptions')
.insert(subscriptionSchema.parse(body));`}
The Zod schema defines the validation rules. Supabase types define the shape. They stay aligned because you update them in lockstep.
Better Error Messages
Zod error messages are built-in and customizable. When validation fails, you get structured errors the client can display without writing custom error-handling logic:
{`const result = subscriptionSchema.safeParse({
email: 'not-an-email',
plan: 'free'
});
if (!result.success) {
console.log(result.error.flatten());
// {
// fieldErrors: {
// email: ['Invalid email address'],
// plan: ['Invalid enum value. Expected \'pro\' | \'enterprise\'']
// }
// }
}`}
Return this shape from your API and the client can bind errors to form fields without guessing what went wrong.
Six Common Questions
Won't Zod validation on the client make the form slow?
Zod runs on every keystroke and is designed for that — it's fast enough for real-time feedback. If you're parsing gigabytes of data, you have other problems. For forms, Zod is instant.
What if the client and server get out of sync?
They won't if you import from the same shared file. Use a monorepo or a shared package so both the React component and the API handler import from @/lib/schemas.ts. TypeScript will error if they diverge.
Can I use Zod with TypeScript interfaces I already have?
Build the schema first, then infer the type. Don't write the interface by hand and then write the schema separately — that duplicates the definition. Use z.infer<typeof schema> as the source of truth.
Does Zod work with nested objects?
Yes. Use .object() for nested shapes, .array() for lists, .optional() for fields that might be absent. Zod schemas compose like TypeScript types.
What about conditional validation (e.g., "if plan is enterprise, require a company name")?
Use .refine() to add custom logic. Example: `schema.refine((data) => data.plan !== 'enterprise' || data.company, { message: 'Company required for enterprise', path: ['company'] })`.
Do I need a separate schema for updates vs inserts?
Often yes. Create .pick() and .omit() variants. Example: insertSchema = subscriptionSchema but updateSchema = subscriptionSchema.partial() to make all fields optional.
The Bottom Line
Zod as the shared source of truth eliminates a whole class of bugs. One schema, parsed on the client (React Hook Form), validated on the server (API handlers), and matched to your database shape (Supabase types). Better error messages. Type inference everywhere. When requirements change, you update one file and both sides stay in sync. This is table-stakes for Velocity X forms in 2026.
Start with the schema. Everything else flows from that.