Skip to content
Draft, under reviewUpdated 2026-06-13
On this page

A contact form looks like the most boilerplate feature there is, and that is exactly why it ships broken so often. The version that "works" in a demo has no validation, no spam defense, no error UX, and emails sent with an API key sitting in client code. Then it goes live and becomes a spam funnel into your inbox, or it silently drops a real customer's message because the email send failed and nobody noticed. This page is the form pattern that handles the boring-but-important parts: a server action does the work, the input is validated, bots are turned away, real users get clear feedback, and the message actually arrives.

The shape in Next.js 16: the form posts to a server action (so no API key ever touches the client), the action validates with a schema, checks a honeypot and a rate limit, sends the email, and returns a typed state the form renders as inline errors or a success message through useActionState.

Server action, not an API key in the browser

The single most common contact-form mistake is calling the email provider from the client, which means shipping the provider's API key in the bundle for anyone to steal. The fix is structural: the form's action is a server function. The browser sends form fields; the secret-holding code runs on the server.

app/(marketing)/contact/contact-form.tsx
"use client";
import { useActionState } from "react";
import { sendContactAction } from "./actions";
 
export function ContactForm() {
  const [state, action, pending] = useActionState(sendContactAction, { ok: false });
  return (
    <form action={action}>
      {/* fields + state.fieldErrors rendering */}
      <button disabled={pending}>{pending ? "Sending…" : "Send"}</button>
    </form>
  );
}

The rules

Whenyou handle any form that sends mail, writes data, or calls a paid API

Dodo it in a server action, not a client fetch to a third party. Keys and provider calls stay on the server; the client only sends field values.

app/(marketing)/contact/actions.ts
"use server";
export async function sendContactAction(_: State, formData: FormData): Promise<State> {
  // provider key is read here, on the server, never shipped to the browser
}

Whenyou accept form input

Dovalidate it with a zod schema before using any field, and return field-level errors the form can show inline. Never trust lengths, formats, or presence from the client.

const schema = z.object({
  name: z.string().min(1).max(120),
  email: z.string().email(),
  message: z.string().min(10).max(5000),
});
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };

Whena form is publicly reachable

Doadd a honeypot field hidden from humans and rejected when filled, plus a rate limit by IP. Together they stop the bulk of automated spam without a captcha.

// Honeypot: a field CSS-hidden from users; bots fill it.
company: z.string().max(0, "Bot detected.").optional(),
// Then: const ok = await rateLimit(`contact:ip:${ip}`, 5, 3600_000);

Whenthe email send can fail (provider down, bad address, quota)

Docheck the send result and surface a real error instead of pretending success. A form that says 'sent' but dropped the message is worse than one that says 'try again'.

const sent = await sendEmail({ to: SUPPORT, subject, replyTo: parsed.data.email, body });
if (!sent.ok) {
  log.error("contact.send_fail", { reason: sent.error });
  return { ok: false, error: "We couldn't send that. Please try again." };
}
return { ok: true, message: "Thanks, we'll reply soon." };

Whenyou render form errors and status

Dodrive the UI from the server action's returned state via useActionState, tie each error to its input with aria-describedby, and disable the submit button while pending.

<input name="email" aria-invalid={!!state.fieldErrors?.email}
  aria-describedby="email-error" />
{state.fieldErrors?.email && <p id="email-error" role="alert">{state.fieldErrors.email[0]}</p>}

Whena user submits a contact message you will reply to

Doset the email's reply-to to their address (not the from), so hitting reply in your inbox reaches the user. Keep the from on your own verified domain.

await sendEmail({
  from: "Support <support@yourdomain.com>", // verified sender
  to: "you@yourdomain.com",
  replyTo: parsed.data.email,               // reply goes to the user
});