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.
"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.
"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
});