Validation with Zod

Validate on the Server

Browser validation (required, type="email") is for user comfort only. Real validation happens in the action. Zod is the common choice: one schema gives runtime checks and a TypeScript type.

// app/admin/products/actions.ts
'use server';

import { z } from 'zod';

const ProductSchema = z.object({
  name: z.string().trim().min(3, 'Name must be at least 3 characters'),
  price: z.coerce.number().positive('Price must be more than 0'),
  stock: z.coerce.number().int().min(0),
  category: z.enum(['mobiles', 'books', 'fashion']),
});

export type FormState = { errors?: Record<string, string[]>; message?: string };

export async function createProduct(prev: FormState, formData: FormData): Promise<FormState> {
  const parsed = ProductSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors };
  // parsed.data is { name: string; price: number; stock: number; category: 'mobiles' | 'books' | 'fashion' }
  await saveProduct(parsed.data);
  return { message: 'Product created' };
}

How It Works Inside

Example

A tiny Zod-like validator for form fields (strings in, typed values or field errors out):

type Check<T> = (raw: string) => { ok: true; value: T } | { ok: false; error: string };

const text = (min: number): Check<string> => (raw) =>
  raw.trim().length >= min ? { ok: true, value: raw.trim() } : { ok: false, error: 'at least ' + min + ' characters' };
const positive: Check<number> = (raw) => {
  const n = Number(raw);
  return raw !== '' && n > 0 ? { ok: true, value: n } : { ok: false, error: 'must be more than 0' };
};

function validate<S extends Record<string, Check<unknown>>>(schema: S, input: Record<string, string>) {
  const data: Record<string, unknown> = {};
  const errors: Record<string, string> = {};
  for (const key of Object.keys(schema)) {
    const r = schema[key](input[key] ?? '');
    if (r.ok) data[key] = r.value; else errors[key] = r.error;
  }
  return Object.keys(errors).length
    ? { success: false as const, errors }
    : { success: true as const, data: data as { [K in keyof S]: S[K] extends Check<infer T> ? T : never } };
}

const schema = { name: text(3), price: positive };
console.log(validate(schema, { name: 'Clean Code', price: '450' }));
console.log(validate(schema, { name: ' TV', price: '-5' }));

Output

{ success: true, data: { name: 'Clean Code', price: 450 } }
{
  success: false,
  errors: { name: 'at least 3 characters', price: 'must be more than 0' }
}

Showing Field Errors

'use client';
import { useActionState } from 'react';
import { createProduct } from './actions';

export default function ProductForm() {
  const [state, action, pending] = useActionState(createProduct, {});
  return (
    <form action={action}>
      <input name="name" />
      {state.errors?.name && <p className="text-red-600">{state.errors.name[0]}</p>}
      <input name="price" type="number" />
      {state.errors?.price && <p className="text-red-600">{state.errors.price[0]}</p>}
      <button disabled={pending}>Save</button>
    </form>
  );
}