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.
The flow
Section titled “The flow”<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.
Server Action and form state
Section titled “Server Action and form state”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.
Validation: Zod 4
Section titled “Validation: Zod 4”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),});Turnstile
Section titled “Turnstile”- Client: load
https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicitwithnext/script, callturnstile.render()on mount andturnstile.remove()on unmount. Inside a<form>, the widget adds a hiddencf-turnstile-responseinput. 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/siteverifywith the secret, the visitor’s IP and anidempotency_key. Then check the response’shostnameandaction.
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.
Email: Resend
Section titled “Email: Resend”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 isreply_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.
Configuration and secrets
Section titled “Configuration and secrets”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_keyTURNSTILE_SECRET_KEY_FILE=/run/secrets/turnstile_secret_keyCONTACT_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.
Client IP
Section titled “Client IP”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.