Skip to content

The contact form

The page shows no email address. Visitors write through a form; the message lands in a private inbox, and the sender gets a short confirmation.

<form> ── useActionState ──▶ Server Action sendContactMessage(prev, formData)
1. hidden trap field filled? → pretend success
2. Zod schema → field errors
3. per-IP limit (3 / 10 min) → "try later"
4. Turnstile siteverify → "check again"
5. Resend: message to owner (awaited)
6. after(): confirmation (sent after response)

A Server Action is a public POST endpoint — its ID is not a secret — so all of these checks happen inside it, in that order, before anything is sent.

useActionState passes the previous state first, so the action’s signature is (prevState, formData). React 19 resets an uncontrolled form after its action runs, so on failure the action returns the submitted values and the inputs use them as defaultValue — the visitor never loses what they typed.

export const contactSchema = z.object({
name: z.string().trim().min(1).max(100),
email: z.email().max(254), // Zod 4: z.email(), not z.string().email()
message: z.string().trim().min(10).max(5000),
});
  • Client: load https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit with next/script, call turnstile.render() on mount and turnstile.remove() on unmount. Inside a <form>, the widget adds a hidden cf-turnstile-response input. Tokens are single-use, so the form re-keys after every attempt and a fresh widget renders.
  • Server: POST the token to https://challenges.cloudflare.com/turnstile/v0/siteverify with the secret, the visitor’s IP and an idempotency_key. Then check the response’s hostname and action.

Local development uses Cloudflare’s published test keys — site key 1x00000000000000000000AA and secret 1x0000000000000000000000000000000AA always pass. Skip the hostname/action checks only when the configured secret is one of those test keys, never based on anything the request sends.

await resend.emails.send(
{ from: CONTACT_FROM, to: CONTACT_TO, replyTo: input.email, subject, text },
{ idempotencyKey: `contact-owner-${submissionId}` },
);
  • The Node SDK uses camelCase replyTo (the REST API’s field is reply_to).
  • An idempotency key per submission means a retry can’t deliver twice.
  • Create the Resend API key with Sending access, restricted to your domain.

src/lib/env.ts imports server-only (any import from a Client Component fails the build) and parses process.env with Zod at request time, so the build never needs the secrets. For each secret, <NAME>_FILE pointing at a mounted file wins over <NAME>:

RESEND_API_KEY_FILE=/run/secrets/resend_api_key
TURNSTILE_SECRET_KEY_FILE=/run/secrets/turnstile_secret_key
CONTACT_TO_EMAIL=<private inbox>

The Turnstile site key is different: it is public, NEXT_PUBLIC_*, and inlined at build time — so it’s a Docker build argument, not a runtime secret.

Without RESEND_API_KEY, sends are only logged in development, and in a production build only when CONTACT_DRY_RUN=1 — which the end-to-end tests set.

The rate limiter needs the real visitor IP. Behind Cloudflare, use cf-connecting-ip — trustworthy only because the firewall admits nothing but Cloudflare (Firewall). The limiter is in-memory, which is fine for one container; more instances would need shared storage.