How do I add a working contact form to a hand-coded static site?
Add four files: package.json with the Resend dependency, vercel.json with CORS locked to your production origin, api/send.js as the serverless handler, and js/forms.js as the client script. The handler validates origin, honeypot, base64 timestamp, email format, then sends via Resend. The client script injects the honeypot and timestamp on page load. Set RESEND_API_KEY via vercel env add, deploy, and verify with a curl POST before pointing real forms at it.
The Spec
What this is
A drop-in form pipeline for hand-coded static sites deployed on Vercel. Replaces Gravity Forms, Contact Form 7, Formspree, etc. Costs nothing on Vercel's hobby tier and Resend's free email tier (3,000/month). Defends against the bots that find unprotected form endpoints within hours.
Files you'll add
{site-name}/
package.json # private project, Node 20, "resend" dep
vercel.json # CORS lockdown
api/
send.js # serverless handler
js/
forms.js # client-side script
index.html # your form lives here
1. package.json
{
"private": true,
"engines": { "node": "20.x" },
"dependencies": { "resend": "^4.0.0" }
}
Run npm install locally to generate package-lock.json. Commit both.
2. vercel.json
{
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "https://yourdomain.com" },
{ "key": "Access-Control-Allow-Methods", "value": "POST, OPTIONS" },
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type" }
]
}
]
}
Replace https://yourdomain.com with your real production origin. Don't use * — that defeats the purpose.
3. api/send.js
Validate everything before calling Resend. The four checks below catch the vast majority of bot traffic:
- Origin — request must come from your domain
- Honeypot — hidden field that bots fill, humans skip
- Timestamp — base64-encoded epoch from page load. Must be at least 3 seconds old (humans don't submit in <3s).
- Email format — basic regex, plus a "no obvious gibberish" check on the name field
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const ALLOWED = /(^|//)(www.)?yourdomain.com(/|$)/i;
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).json({ error: 'method' });
const origin = req.headers.origin || '';
const referer = req.headers.referer || '';
if (!ALLOWED.test(origin) && !ALLOWED.test(referer)) {
return res.status(403).json({ error: 'origin' });
}
const { name, email, message, leit_cf_hp, leit_cf_ts } = req.body || {};
// Honeypot — must be empty
if (leit_cf_hp) return res.status(200).json({ ok: true });
// Timestamp — must be at least 3 seconds old
const ts = parseInt(Buffer.from(leit_cf_ts || '', 'base64').toString(), 10);
if (!ts || (Date.now() / 1000 - ts) < 3) {
return res.status(400).json({ error: 'timestamp' });
}
// Email format
if (!email || !/^[^s@]+@[^s@]+.[^s@]+$/.test(email)) {
return res.status(400).json({ error: 'email' });
}
// Required fields
if (!name || !message) return res.status(400).json({ error: 'fields' });
await resend.emails.send({
from: 'Site <hello@yourdomain.com>',
to: 'you@yourdomain.com',
reply_to: email,
subject: `New contact form: ${name}`,
text: `Name: ${name}nEmail: ${email}nn${message}`
});
return res.status(200).json({ ok: true });
}
4. js/forms.js
document.querySelectorAll('form[data-leit-form]').forEach((form) => {
// Inject honeypot
const hp = document.createElement('input');
hp.type = 'text';
hp.name = 'leit_cf_hp';
hp.tabIndex = -1;
hp.autocomplete = 'off';
hp.setAttribute('aria-hidden', 'true');
hp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;opacity:0';
form.appendChild(hp);
// Inject timestamp
const ts = document.createElement('input');
ts.type = 'hidden';
ts.name = 'leit_cf_ts';
ts.value = btoa(String(Math.floor(Date.now() / 1000)));
form.appendChild(ts);
form.addEventListener('submit', async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(form));
try {
const r = await fetch('/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const j = await r.json();
if (j.ok) {
form.reset();
form.dataset.state = 'sent';
} else {
form.dataset.state = 'error';
}
} catch (err) {
form.dataset.state = 'error';
}
});
});
5. The form markup
<form data-leit-form>
<label>Name <input name="name" required></label>
<label>Email <input name="email" type="email" required></label>
<label>Message <textarea name="message" required></textarea></label>
<button type="submit">Send</button>
<p data-form-state="sent">Sent. We'll be in touch.</p>
<p data-form-state="error">Something broke. Email us directly: hello@yourdomain.com</p>
</form>
The JS sets data-state="sent" or data-state="error" on the <form>. Pair it with this CSS so only the matching message shows:
[data-form-state] { display: none; }
form[data-state="sent"] [data-form-state="sent"] { display: block; }
form[data-state="error"] [data-form-state="error"] { display: block; }
6. Set the env var
printf "re_yourkey" | vercel env add RESEND_API_KEY production
printf via stdin keeps the key out of your terminal history. Don't paste it on the command line.
7. Deploy
vercel deploy --prod --yes
8. End-to-end test
TS=$(echo -n $(($(date +%s) - 5)) | base64)
curl -s -X POST https://yourdomain.com/api/send
-H "Content-Type: application/json"
-H "Origin: https://yourdomain.com"
-H "Referer: https://yourdomain.com/"
-d '{"name":"Test","email":"test@example.com","message":"hi","leit_cf_ts":"'"$TS"'","leit_cf_hp":""}'
Response should be {"ok":true}. Confirm the email arrived. Then submit through the actual form and verify again.
Conditions
When this works
- Static site deployed on Vercel
- You have a domain you can verify in Resend (for the From address)
- Form volume fits in Resend's free tier (3,000/month) or you're willing to upgrade
- You can set environment variables on the host
When it doesn't
- You need file uploads — handle separately, multipart parsing in serverless is painful
- You need the form to write to a database, CRM, or trigger workflows — extend the handler or use a real backend
- You're hosting purely static (no functions) — use a third-party endpoint like Formspree instead
- Your traffic is high enough that bots actively work to bypass passive defenses — graduate to an interactive challenge (canvas slide puzzle, etc.)
Outcome
The four passive defenses (origin, honeypot, timestamp, email format) catch nearly all automated form submissions. Sophisticated bots can bypass any one, but rarely all four. If your traffic gets targeted, graduate to an interactive challenge.
Specs provided as-is. chadworks isn't responsible for how you use these prompts or any effects they may have on your code, content, infrastructure, or business. Review and test before applying.