Skip to content

An honest contact form: Server Actions, Zod, and Resend

Jaskaran Singh3 min read

For years my contact form did the classic trick: validate some fields in the browser, compose a mailto: URL, and set window.location. It works, it costs nothing, and it outsources the actual sending to whatever email client the visitor happens to have configured. On a phone with no mail app set up, it silently does nothing.

The rebuild replaces it with a Server Action that actually sends. Here is the whole thing.

The action shape

A Server Action is an async function that runs on the server, callable from a form. The signature convention for use with useActionState is previous state plus FormData:

ts
"use server";

export async function sendContactMessage(
  _previous: ContactState,
  formData: FormData,
): Promise<ContactState> {
  // validate, send, and return the next state
}

The return value is a discriminated state object — idle, success, or error with a reason. The client never guesses what happened; it renders what the server reports.

Validation with Zod

The same schema validates on the client for fast feedback and on the server for trust. Zod 4 makes the rules explicit:

ts
export const contactSchema = z.object({
  name: z.string().trim().min(1, "Please add your name.").max(80),
  email: z.email("Please add a valid email address."),
  message: z
    .string()
    .trim()
    .min(10, "Tell me a little about the project.")
    .max(2000),
});

Field errors from safeParse are flattened onto the state object, so the form can highlight exactly which input failed and focus it:

ts
const parsed = contactSchema.safeParse(values);
if (!parsed.success) {
  const fieldErrors: ContactState["fieldErrors"] = {};
  for (const issue of parsed.error.issues) {
    const key = issue.path[0];
    if ((key === "name" || key === "email" || key === "message") && !fieldErrors[key]) {
      fieldErrors[key] = issue.message;
    }
  }
  return { status: "error", code: "validation", fieldErrors, values };
}

Returning values with the error state is what lets the form repopulate instead of discarding what the visitor typed.

The honeypot

The cheapest bot defence is a field that humans never see. It is rendered hidden, excluded from the tab order, and named something bots find irresistible:

tsx
<div className="hidden" aria-hidden="true">
  <label htmlFor="cf-website">Website</label>
  <input id="cf-website" name="website" type="text" tabIndex={-1} autoComplete="off" />
</div>

The server treats a filled honeypot as a bot: it returns the success state without sending anything. The bot gets no signal that it was caught.

Honest failure states

This is the part most implementations get wrong. If the deployment has no email provider configured, the form must not say "Message sent". It must say what actually happened:

ts
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
  // Not configured yet — say so honestly rather than pretending to send.
  return { status: "error", code: "config", values };
}

The UI answers with a visible path forward: a short explanation and a direct mailto: link. The form degrades to the old behaviour — but explicitly, with the visitor informed, rather than silently.

The same is true for provider failures. A thrown error from the email API returns code: "send", and the interface presents the same manual fallback. There is no state in which the visitor is told a message was delivered when it was not.

Pending, focus, and the small things

useActionState supplies a pending flag that disables the submit button and relabels it while the request is in flight. When validation fails, an effect focuses the first invalid field instead of dumping the visitor at the top of the form. The success state replaces the fields but keeps a "Send another" button, and the values survive a failed submission because the server returns them.

None of these are large features. They are the difference between a form that looks like it works and a form that does.

Testing it without a mailbox

The verification script submits the form twice: once empty, asserting that three field errors appear, and once filled, asserting that without an API key the honest fallback message is rendered. That second assertion is the one that matters — it is a test that the site refuses to lie.

More posts.