The moment you validate the same form data three times — once in React, once in your API, once in Supabase — you've already lost. Duplicate validation rules are drift factories. Define a schema once, share it everywhere.
Most apps validate data three times: React Hook Form checks it client-side (user experience), a Netlify function validates it on arrival (security), and Supabase RLS checks it before it touches the database (defense in depth). If each validation uses different rules, you'll have silent data corruption. User A sees their form succeed, the API rejects it, and the transaction fails. Or worse: the form passes, the API passes, but Supabase silently blocks the insert and returns success. Three validation layers aren't redundant — they're essential — but they need to agree. Zod solves this: write a TypeScript schema once, parse it in the browser, parse it on the API, validate it in a Supabase RLS inline check. Same rules everywhere. Same error messages. Type-safe end to end.
The Problem: Validation Drift
Validation without a single source of truth is a slow data leak. You build a contact form with React Hook Form. Email field is required, message must be 20–500 chars. User hits submit, the form validates, they see success. But your API schema says message can be 10–1000 chars. The function accepts it and writes it. Good so far. But Supabase RLS has a check: `length(message) <= 300`. The insert returns success (the API saw success), but the row was silently rejected. User A thinks they submitted; User B never gets the message. You debug for an hour.
This happens because each layer was built independently. The form developer wrote regex. The API developer used a different regex. The database architect wrote a Postgres check constraint. None of them talk. The moment you have a second form (signup, password reset, team invites), you duplicate the rules. Now you have four places to update when email validation changes from RFC 5322 to your custom "allow corporate emails only" rule. One person updates React Hook Form, misses the Netlify function, forgets to update RLS. The cracks widen.
Zod is the single source of truth. You define a schema in TypeScript, and every validation layer — client, server, database — uses the exact same rules. Change the rule once, and all three layers pick it up. No more drift.
The Pattern: Shared Schema File
Create a `src/schemas/` directory in your Astro project. Each schema is a `.ts` file shared between client and server. Here's a contact form schema:
// src/schemas/contact.ts
import { z } from 'zod';
export const contactSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
message: z.string().min(20).max(500),
phone: z.string().optional(),
});
export type Contact = z.infer<typeof contactSchema>;
That's it. One file. Now it's available to three consumers: React Hook Form (client), Netlify function (server), and Supabase RLS (database). TypeScript automatically infers the type from the schema — `Contact` is derived from the Zod definition, so they never drift apart.
Validation 1: React Hook Form (Client)
React Hook Form integrates with Zod using `@hookform/resolvers`. Pass your schema to the form hook, and validation fires client-side in real-time:
// src/components/ContactForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { contactSchema, type Contact } from '../schemas/contact';
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Contact>({
resolver: zodResolver(contactSchema),
});
const onSubmit = async (data: Contact) => {
// data is typed and validated
const response = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<textarea {...register('message')} />
{errors.message && <span>{errors.message.message}</span>}
<button type="submit">Send</button>
</form>
);
}
The form user gets instant feedback: "Message must be at least 20 characters." The schema is the source of truth. React Hook Form handles the rest.
Validation 2: Netlify Function (API)
Never trust the client. The API re-validates using the same schema:
// netlify/functions/contact.ts
import { contactSchema } from '../../src/schemas/contact';
export default async (event: any) => {
let body;
try {
body = JSON.parse(event.body);
} catch {
return { statusCode: 400, body: 'Invalid JSON' };
}
const result = contactSchema.safeParse(body);
if (!result.success) {
return {
statusCode: 422,
body: JSON.stringify({
errors: result.error.flatten(),
}),
};
}
const { data } = result;
// data is typed and validated
// Insert into Supabase, send email, etc.
const { error } = await supabase
.from('contacts')
.insert([data]);
if (error) {
return { statusCode: 500, body: JSON.stringify(error) };
}
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
};
Notice `safeParse` instead of `parse`. `safeParse` returns `{ success: true, data }` or `{ success: false, error }`. You can handle validation errors without throwing. `parse` throws, which is fine for internal code but bad for API endpoints where you want to return structured error responses.
Validation 3: Supabase RLS (Database)
The final layer: Supabase RLS with inline Zod validation. Create a Postgres function that runs Zod in JavaScript context (using `plv8` or similar), or write the rule in SQL:
-- migrations/contacts_rls.sql
create policy "users can insert valid contacts"
on contacts
for insert
with check (
organization_id = auth.org_id()
and length(name) >= 2 and length(name) <= 100
and length(email) <= 254
and length(message) >= 20 and length(message) <= 500
);
-- For complex validation, use an inline function:
create or replace function validate_contact(
name text,
email text,
message text
) returns boolean as $$
begin
return (
length(name) >= 2 and length(name) <= 100
and email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$'
and length(message) >= 20 and length(message) <= 500
);
end;
$$ language plpgsql immutable;
create policy "validate contact schema"
on contacts
for insert
with check (
organization_id = auth.org_id()
and validate_contact(name, email, message)
);
Now the RLS policy is the third validation. If the form passes and the API passes but the data violates the RLS rule, the insert is rejected. You've got defense in depth.
Why safeParse, Not parse
`parse` throws an exception if validation fails. `safeParse` returns a union type: `{ success: true, data: T } | { success: false, error: ZodError }`. In APIs, you want `safeParse` so you can send structured error responses back to the client. In server code where you're sure data is valid, `parse` is fine — the thrown exception is a signal that something went very wrong.
// Always use safeParse in APIs
const result = contactSchema.safeParse(body);
if (!result.success) {
return { statusCode: 422, body: JSON.stringify(result.error.flatten()) };
}
const { data } = result;
// data is guaranteed to match the schema
Six FAQs
Can I share schemas between my Astro site and a separate API server?
Yes. Move schemas to a shared npm package or a monorepo package. Both projects install `@myorg/schemas`, and they import from the same source. For Velocity projects, `src/schemas/` is checked into git — point your API server to the same repo and import directly.
How do I handle conditional validation (e.g., field A is required only if field B is set)?
Use `z.refine()` or `z.superRefine()` to add cross-field validation. Zod evaluates all field-level rules first, then refinements. This way, you get individual error messages per field, plus cross-field validation at the schema level.
const schema = z.object({
accountType: z.enum(['personal', 'business']),
businessName: z.string().optional(),
}).refine(
(data) => data.accountType === 'business' ? !!data.businessName : true,
{ message: 'Business name required for business accounts', path: ['businessName'] }
);
What if my Postgres validation regex is different from Zod's email validator?
Keep them in sync. Postgres regex and JavaScript regex have different syntax. Test both. For email, use a strict pattern in both layers — Zod's `.email()` is RFC 5322 compliant; match it in SQL or accept that they won't be 100% identical (email validation is famously hard). For most use cases, Zod's built-in is fine.
Can I have different validation for create vs. update?
Yes. Create separate schemas: `createContactSchema` and `updateContactSchema`. The create schema might require all fields; the update might make them all optional. Import the right schema in each endpoint.
What if I need to transform data during validation (e.g., trim whitespace)?
Use `z.string().trim().email()` or `.transform()` for custom transformations. The transformed data is what you get from `safeParse` — safe and automatic.
How do I test schemas without hitting the database?
Test them directly. Zod schemas are functions. Call `safeParse` with test data and check the result. No database needed. For integration tests, you'd hit the API and database as usual, but unit-testing schemas is just JavaScript.
The Bottom Line
Stop validating the same data three times. Define a Zod schema once, use it in React Hook Form, parse it in your Netlify function, and enforce it in Supabase RLS. Same rules everywhere. Same type safety. Same error messages. When validation rules change, you update one file and all three layers pick it up instantly. No more drift, no more silent failures. This is how production-grade full-stack applications handle form data. Ready to build with type-safe schemas from day one? Check Aidxn Design pricing for full-stack partnerships. For more on RLS and database security, read Supabase Row-Level Security — Real Policies That Actually Hold in Production.