You built a form in React Hook Form. Validation looks solid — email is required, password is 12+ chars. User fills it out, hits submit, and the form says success. Then you check the database and find a row with an empty email and a 3-character password. The form never even sent that data — it was crafted by someone posting raw JSON to your API with curl. Client-side validation is a UX layer. It's not security. The moment you ship code that trusts the browser, you've already lost. Every API endpoint needs to validate input independently with Zod, return 400 if it fails, and refuse to process invalid data. This is non-negotiable.
The Threat: Never Trust the Client
Client-side validation catches typos and helps users fix their input faster. But it's not a security boundary. Someone with DevTools open can delete your validation code. Someone with curl or Postman can bypass your form entirely. Someone who knows your API schema can craft requests that pass your form but violate your business logic. If your API accepts whatever the client sends without re-checking, three things happen: bad data gets into your database, your app breaks in ways you didn't anticipate, and you have no audit trail of what went wrong.
The classic mistake: the form says "password must be 12+ characters", so you ship an API that doesn't check password length. A script kiddie crafts a JSON payload with a 3-character password, posts it directly to your endpoint, and creates an account. Later, a user tries to log in with a weak password they thought was validated. Or worse: your form says "email is required", but someone POSTs null. Your database accepts it because you never checked. Now you have orphaned rows with no contact info, and your email newsletter crashes.
The pattern is simple: validate on the client for UX, validate on the server for security. Use the same schema for both. Parse input with Zod on every API endpoint. Return 400 if it fails. Move on.
The Pattern: Zod on Every Netlify Function
Create a `src/schemas/` directory. Define your schema once — this is the same file your form imports. Then, in every Netlify function, parse the incoming body before you touch it:
// src/schemas/signup.ts
import { z } from 'zod';
export const signupSchema = z.object({
email: z.string().email('Must be a valid email'),
password: z.string().min(12, 'Password must be at least 12 characters'),
name: z.string().min(2).max(100),
});
export type Signup = z.infer<typeof signupSchema>;
Now, in your Netlify function:
// netlify/functions/signup.ts
import { signupSchema } from '../../src/schemas/signup';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export default async (event: any) => {
// 1. Parse JSON
let body;
try {
body = JSON.parse(event.body || '{}');
} catch {
return { statusCode: 400, body: JSON.stringify({ error: 'Invalid JSON' }) };
}
// 2. Parse and validate with Zod
const result = signupSchema.safeParse(body);
if (!result.success) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'Validation failed',
details: result.error.flatten(),
}),
};
}
const { data } = result;
// 3. data is guaranteed to match the schema — type-safe from here
const { error, data: user } = await supabase.auth.admin.createUser({
email: data.email,
password: data.password,
user_metadata: { name: data.name },
});
if (error) {
return { statusCode: 500, body: JSON.stringify(error) };
}
return {
statusCode: 201,
body: JSON.stringify({ user: user.user }),
};
};
Three steps. Parse JSON. Validate with Zod. If validation fails, return 400. If it passes, proceed. That's the shield. No exceptions.
Why safeParse, Not parse
Zod has two methods: `parse` throws if validation fails. `safeParse` returns a discriminated union: `{ success: true, data } | { success: false, error }`. In API endpoints, always use `safeParse` so you can return a proper error response. If you use `parse` and validation fails, the exception propagates and your endpoint crashes (or returns a 500). Your users see a server error instead of a clear "your email is invalid" message.
// ❌ Don't do this in an API
const data = signupSchema.parse(body); // throws if invalid
// ✅ Do this
const result = signupSchema.safeParse(body);
if (!result.success) {
return { statusCode: 400, body: JSON.stringify({ error: result.error.flatten() }) };
}
const { data } = result;
Integration with React Hook Form
Your React form uses the same schema with `@hookform/resolvers`. Client-side validation fires in real-time. Server-side validation catches anything that slipped through (or was tampered with):
// src/components/SignupForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signupSchema, type Signup } from '../schemas/signup';
import { useState } from 'react';
export function SignupForm() {
const [error, setError] = useState('');
const { register, handleSubmit, formState: { errors } } = useForm<Signup>({
resolver: zodResolver(signupSchema),
});
const onSubmit = async (data: Signup) => {
const response = await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify(data),
});
if (!response.ok) {
const { error } = await response.json();
setError(error);
return;
}
// Success
window.location.href = '/dashboard';
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
{error && <span className="text-red-600">{error}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
User types, the form validates instantly. They submit. If it passes client validation but fails server validation, the error displays below the form. If someone tampers with the request, the server catches it and returns a 400. The form is the UX layer. The API is the security layer.
Six FAQs
Should I also validate on the database (RLS or constraints)?
Yes. Three layers of validation is the gold standard: client (UX), API (security boundary), database (defense in depth). Supabase RLS policies and Postgres check constraints should enforce the same rules. If the API somehow lets invalid data through, the database rejects it. For details, see Zod Schemas as Single Source of Truth.
What if validation is slow (e.g., checking if an email is already registered)?
Zod validates schema (type, length, format). For business logic checks (email uniqueness, inventory availability), add a second step after Zod passes. Call your database or API, check the rule, return 400 if it fails. Keep Zod for schema only; use runtime checks for state-dependent rules.
How do I return detailed error messages to the client?
Use `result.error.flatten()` to get a structured error object mapping field names to messages. On the client, display them next to the relevant inputs. Or return a custom error object with your own formatting.
Can I have different validation for create vs. update endpoints?
Yes. Create two schemas: `createUserSchema` (all fields required) and `updateUserSchema` (all fields optional). Import the right schema in each endpoint.
What if the form submits from JavaScript and I need the error to update state?
Return 400 with a JSON body containing the error. The fetch response has `ok: false`, so you catch it, parse the JSON, and update state. Never auto-refresh or redirect on client-side validation failure — let the user fix it inline.
Do I need to sanitize input after Zod validation?
Zod validates structure and format. For security, also sanitize: use parameterized queries (never string concatenation), escape HTML in user display names, and use libraries like `sanitize-html` if storing HTML. Validation ≠ sanitization. Do both.
The Bottom Line
Your API is the security boundary. Client validation is a courtesy. Every Netlify function must parse input with Zod first, return 400 on failure, and refuse to proceed. Reuse the same schema in React Hook Form so the client and server agree on what's valid. Same rules, same error messages, same type safety everywhere. This is the pattern that scales from side projects to production apps. For guidance on deploying validated endpoints at scale, check Aidxn Design partnerships or read more on shared schemas across full-stack projects.