React

Server Actions with validation: where the check belongs

A shared schema, safeParse and per-field errors: how a Server Action rejects bad input without ever trusting the browser.

Key points

  • Every Server Action is a public POST endpoint: the authoritative validation lives on the server.
  • One schema shared from `lib/validation.ts` stops client and server from drifting apart.
  • `safeParse` returns per-field errors; `parse` throws and leaves you with a generic message.

Validate a Server Action on the server, before the write: the check in the browser is a courtesy to the person typing, not a defence. A Server Action is a public POST endpoint and you treat it as one.

The check that counts lives on the server

The caller is not necessarily your form. A fetch against the same page is enough to send empty fields, a 10 MB message or an id that belongs to somebody else. The client can show errors while someone types, but the decision to accept data happens where data is written.

npm install zod
// lib/validation.ts
import { z } from "zod";

export const contactSchema = z.object({
  name: z.string().trim().min(2, "too short"),
  email: z.string().trim().email("invalid email"),
  message: z.string().trim().min(20, "at least 20 characters"),
});

export type ContactInput = z.infer;

`safeParse` does not throw: it returns a result you read. Build a field-to-message map and send it back to the form. An unhandled exception inside the action reaches the client as a generic error, and the reader loses the one useful piece of information: which field to fix.

One schema, two sides

The schema lives in lib/validation.ts and both the server and the client import it. The client uses it to enable the button and show the message early, the server uses it as the only source of truth. Two copies of the rules diverge on the first edit.

The action receives FormData, not an object: the browser sends strings. Convert once, validate once, write once.

// app/contact/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { contactSchema } from "@/lib/validation";

export type FormState = { errors?: Record; ok?: boolean };

export async function submitContact(_prev: FormState, formData: FormData): Promise {
  const parsed = contactSchema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  });

  if (!parsed.success) {
    const errors: Record = {};
    for (const issue of parsed.error.issues) errors[String(issue.path[0])] = issue.message;
    return { errors };
  }

  await db.contact.create({ data: parsed.data });
  revalidatePath("/contact");
  return { ok: true };
}

What to return to the form

  • Return per-field errors, not one shared message: whoever fills the form must know where to act.
  • Never return the database error text: it leaks schema, table names and constraints.
  • Keep ok as a boolean and clear the form in a client effect, not inside the action.
  • If the action needs a session, check it before reading the payload: an anonymous visitor should not make the schema work.
"use client";

import { useActionState } from "react";
import { submitContact } from "./actions";

export function ContactForm() {
  const [state, action, pending] = useActionState(submitContact, {});
  return (
    
      
      Send
    
  );
}

A ready-made block helps, but validates nothing

The contact and form blocks bring markup, error states and spacing. The Glassmorphism Login Form record shows how a catalogue block arrives already composed. None of the three carries your rules: the code lands in the repository and validation stays yours.

Where a Server Action is not enough

A Server Action is not an API: it has no stable URL to document, it does not answer GET and it serves no external client. If another application consumes the same data, write a Route Handler and keep the action for the form. The comparison is in Route Handlers vs Server Actions.

Mistakes you see in production

  • Validating only with required and type="email": two clicks in DevTools switch them off.
  • Putting redirect() inside a try/catch: a redirect works by throwing, and you catch it by accident.
  • Returning the Zod object to the client: it contains the schema and the internal messages.
  • Calling revalidatePath before the write: the route is regenerated with the old data.

Where to keep actions and schemas without bloating components is covered in structuring an App Router project; if the schema grows, React form libraries explain what state management adds.

Frequently asked questions

Can I validate in the client only?

No. The client improves the experience, but anyone can post the same payload without going through the form: the validation that protects the database belongs on the server.

Zod or hand-written validation?

Zod when the schema is shared between client and server and keeps growing; hand-written checks are enough for two fields and no composed rules.

Where do I keep the shared schema?

In lib/validation.ts, outside app/. Server and client import the same path and never duplicate the rules.

How do I show per-field errors?

The action returns a field-to-message object and the client component reads it with useActionState, next to the pending state.

Do Server Actions need CSRF protection?

Next.js compares the Origin header with the host for actions submitted by forms. If you call the action from an external domain, go through a Route Handler with your own check.