Contact Form with Resend
The contact form is usually the first real backend a site needs. A mailto: link is not one: it hands your address to every scraper that reads your HTML, and on half the phones on earth it opens nothing at all. The grown-up version is small: a form, a serverless function, and an email API. Resend is the API here because the free tier covers any normal contact form many times over and the integration is one POST request.
Spam is the part people get backwards. The instinct is a CAPTCHA, which taxes every legitimate visitor to inconvenience bots that increasingly solve them anyway. Start with the cheap tricks instead: a honeypot field that humans never see and bots dutifully fill, and a rate limit so one IP can't submit four hundred times a minute. That combination kills the overwhelming majority of form spam and costs your actual visitors nothing. Escalate only if reality demands it.
The one rule you cannot bend: the Resend API key lives in an environment variable on the server. The moment it appears in browser-side code it belongs to the internet, and the internet will use your domain to send things you will have to apologize for. That's §6.4, and this template is where a lot of people meet it for the first time.
Hand the spec below to your agent from inside your site's repo.
Prerequisites
- A Resend account (free tier) and an API key.
- A domain verified in Resend, or their onboarding sender for testing.
- A site deployed where serverless functions run: Vercel, Netlify, or similar.
# Build: contact form -> Resend -> my inbox
## What to build
A contact form on [page, e.g. /contact] that submits to a serverless
API route, which validates the input and emails it to me via Resend.
This project is [framework, e.g. Next.js App Router on Vercel]. Match
the project's existing conventions.
## The form
Fields: name, email, message. Plus one honeypot field (see Spam).
On success: show "Thanks, I'll get back to you" inline. No redirect.
On failure: show a plain error and KEEP the user's text in the fields.
Eating a visitor's message teaches them not to write a second one.
## The API route
POST [/api/contact], server-side only. In order:
1. Honeypot check (below). Fail -> respond 200 and do nothing.
Bots that see errors retry; bots that see success move on.
2. Validate: all fields present, email matches a basic pattern,
message between 10 and 5000 chars. Fail -> 400 with a field-level
message.
3. Rate limit: max [5] submissions per IP per hour (below).
Over -> 429.
4. Send via Resend. Sketch:
```typescript
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: "Contact form <form@[your-verified-domain]>",
to: "[your inbox address]",
replyTo: submitterEmail, // so I can just hit reply
subject: `Contact form: ${name}`,
text: `From: ${name} <${submitterEmail}>\n\n${message}`,
});
```
Send plain text. Do not interpolate the message into HTML: that
turns my contact form into an HTML injection vector aimed at my
own inbox.
5. Resend errors -> log server-side, return a generic 500. Never leak
provider error details to the client.
## Spam protection
- Honeypot: a text input named [company_website], visually hidden with
CSS (not `type="hidden"`; bots skip those). Humans never fill it.
Any submission with a value there is a bot.
- Rate limit: in-memory map of IP -> timestamps is fine to start.
Note in a comment that serverless instances reset this map, and name
the upgrade path ([Upstash Redis or the platform's rate limiter]).
## Secrets
- RESEND_API_KEY comes from an environment variable. It must never
appear in client-side code, in the repo, or in the browser bundle.
- Add RESEND_API_KEY to `.env.example` with a placeholder value, and
confirm `.env.local` (or equivalent) is gitignored.
## Done means
- I can fill the form on the deployed site and the email arrives with
reply-to set to the submitter.
- A submission with the honeypot filled sends nothing and returns 200.
- The 6th submission from one IP within an hour returns 429.
- `git grep RESEND_API_KEY` shows only env access and `.env.example`.Adaptation notes:
- The spec is provider-shaped, not provider-locked. Postmark or SES slot in with the send call changed and nothing else; the validation, honeypot, and secrets rules are universal.
- More fields (phone, budget, "how did you hear about us") go in the validate step and the email body. Resist making any of them required; every required field costs you real submissions.
- If spam still gets through after honeypot plus rate limit, add Cloudflare Turnstile before you reach for a CAPTCHA. It's the least hostile of the challenge options.
- The in-memory rate limit is honest about being temporary. When the site grows past one region or you start seeing bursts, do the Redis upgrade the comment names; don't pretend the map was ever durable.
- The mistake people make: testing only the happy path. Send the form empty, send it with a fake email, fill the honeypot, hammer it six times. The failure branches are where the spam protection actually lives, and your agent claimed to build them.