---
name: build-contact-form-with-email
description: Builds a contact form that actually emails you, with a Next.js server action, server-side Zod validation, an email API such as Resend, a spam honeypot and success and error states. Use when the user wants contact form messages delivered to their inbox, an enquiry form, or a form that sends email.
license: CC-BY-4.0
metadata:
  category: backend
  tags: "email,forms,nextjs,security"
  author: johnny-bravo
  version: "1.0"
  updated: "2026-10-31"
  status: published
---

# Build a contact form that emails you

## When to use
- A website needs an enquiry or contact form whose messages land in the owner's inbox.
- An existing form "submits" but nothing arrives.
- For client-side field validation patterns on any React form, see `build-react-form-with-validation`; this skill is about delivery.

## Handoff
- Reads: `docs/website-brief.md` (which fields the form asks for, the owner's email) and `lib/site.ts`.
- Writes: `app/contact/page.tsx`, `app/contact/ContactForm.tsx`, `app/contact/actions.ts`, and `.env.example` listing `RESEND_API_KEY` and `CONTACT_TO` (real values go in `.env.local` only).

## Steps
1. Install: `npm install resend zod`. Create a Resend account, add and verify the business domain at resend.com/domains (it gives you DNS records to add), and create an API key.
2. `.env.local` (never committed): `RESEND_API_KEY=...`, `CONTACT_TO=owner@business.com.au`, `CONTACT_FROM="Website <hello@your-domain.com.au>"`. Copy the names, not the values, into `.env.example`.
3. `app/contact/actions.ts` with `"use server"` at the top:
   - A Zod schema: `name` (1-100 chars), `email` (`z.email()` in Zod 4, `z.string().email()` in Zod 3), `message` (10-5000 chars), plus any fields from the brief.
   - `export async function sendContact(prev: State, formData: FormData): Promise<State>`.
   - Honeypot: if the hidden `company` field has a value, return `{ ok: true }` without sending, so bots learn nothing.
   - `safeParse` the fields; on failure return `{ ok: false, errors: z.flattenError(result.error).fieldErrors }` (Zod 3: `result.error.flatten().fieldErrors`).
   - Send with `new Resend(process.env.RESEND_API_KEY).emails.send({ from: process.env.CONTACT_FROM, to: process.env.CONTACT_TO, replyTo: email, subject: \`Website enquiry from ${name}\`, text })`. The SDK returns `{ data, error }`; check `error` rather than relying on try/catch.
   - On error, log it on the server and return `{ ok: false, message: "Sorry, that didn't send. Please email us directly at …" }`.
4. `app/contact/ContactForm.tsx` (`"use client"`): `const [state, formAction, pending] = useActionState(sendContact, { ok: false })`. A `<form action={formAction}>` with a visible `<label>` for each input, `type="email"` and `autoComplete` on email and name, and `required`.
5. The honeypot: `<div aria-hidden="true" className="hidden"><label>Company<input name="company" tabIndex={-1} autoComplete="off" /></label></div>`.
6. States: disable the button and show "Sending…" while `pending`; on success replace the form with a thank-you message in a `role="status"` region; show field errors next to each field (`aria-describedby`) and the general error in `role="alert"`.
7. A one-line collection notice under the button, e.g. "We use your details only to reply to you. See our privacy page." with a link to `/privacy`.
8. Test locally: a valid message arrives, the reply goes to the sender, an invalid email shows the error, and a filled honeypot sends nothing. Add the same environment variables in the host's settings before deploying.

## Gotchas
- Validate on the server even if the browser validates too; a bot can post anything to a server action.
- `onboarding@resend.dev` is Resend's testing sender only. Production mail must come from your verified domain, or it will bounce or land in spam.
- Never put the visitor's address in `from`; use `replyTo`. Sending "from" their domain fails SPF/DMARC checks.
- Don't put the API key in a client component or any `NEXT_PUBLIC_` variable: it would ship to every visitor.
- Treat the message as plain text. If you build HTML, escape the visitor's input first.
- A honeypot stops simple bots. If spam continues, add rate limiting or a CAPTCHA such as Cloudflare Turnstile.

## Example prompt
"My contact form doesn't send anything. Make it email the enquiries to me with Resend and show a thank-you message."
