# Serverless Contact Form Playbook
Production-tested pattern: Astro form + Cloudflare Pages Function + Mailgun REST API. Zero infrastructure, zero cold starts, fire-and-forget email delivery. Deployed on the ActiveWizards site handling lead intake.
Table of Contents
1. Architecture
``
User submits form
→ Browser fetch() POST /api/contact
→ CF Pages Function (functions/api/contact.ts)
→ Honeypot check (bot trap)
→ Field validation
→ Freemail filter (reject gmail, yahoo, etc.)
→ Mailgun REST API (fire-and-forget via waitUntil)
→ Return 200 JSON
→ Browser shows success state
→ GA4 generate_lead event fires
`
Key design decisions:
- No KV/database needed for low volume (5-20 leads/month). Mailgun logs are the audit trail.
- waitUntil()
sends email after response — user gets instant feedback.
- Freemail filter qualifies leads at the gate. Returns 422 with inline error on the email field.
- Honeypot catches bots without CAPTCHAs. Hidden field _gotcha
— bots fill it, humans don't.
- Dual response mode: JSON for fetch requests, redirect for non-JS form submissions.
2. Frontend: Form HTML
src/pages/contact-us.astro
`astro
import BaseLayout from '../layouts/BaseLayout.astro';
title="Contact Us" description="Get in touch for a consultation." >
`
Honeypot field rules
- style="display:none"
hides it from humans
- tabindex="-1"
prevents keyboard navigation to it
- autocomplete="off"
prevents browser autofill
- Name it something enticing to bots: _gotcha
,website,url,phone2
- If this field has any value on submission, silently succeed (don't reveal the trap)
3. Frontend: Client-Side Handling
`html
`
Key UX patterns
4. CF Pages Function
functions/api/contact.ts
`ts
/**
* Contact Form Handler — Cloudflare Pages Function
* POST /api/contact
*
* Sends notification email via Mailgun REST API.
*
* Required env vars (set as CF Pages secrets):
* MAILGUN_API_KEY, MAILGUN_DOMAIN, NOTIFICATION_EMAIL
*/
interface Env {
MAILGUN_API_KEY: string;
MAILGUN_DOMAIN: string;
NOTIFICATION_EMAIL: string;
}
// Freemail domains to reject (qualify leads at the gate)
const FREEMAIL_DOMAINS = new Set([
"gmail.com", "yahoo.com", "hotmail.com", "outlook.com",
"aol.com", "icloud.com", "mail.com", "protonmail.com",
"zoho.com", "yandex.com", "live.com", "msn.com",
"gmx.com", "fastmail.com",
]);
function isFreemailDomain(email: string): boolean {
const domain = email.split("@")[1]?.toLowerCase();
return domain ? FREEMAIL_DOMAINS.has(domain) : false;
}
export const onRequestPost: PagesFunction
const { request, env } = context;
// Parse form data
const formData = await request.formData();
const name = formData.get("name")?.toString().trim() || "";
const email = formData.get("email")?.toString().trim() || "";
const company = formData.get("company")?.toString().trim() || "";
const projectType = formData.get("project_type")?.toString() || "";
const budget = formData.get("budget")?.toString() || "";
const message = formData.get("message")?.toString().trim() || "";
// Honeypot check — silently succeed if triggered
const honeypot = formData.get("_gotcha")?.toString() || "";
if (honeypot) {
return jsonOrRedirect(request, { success: true }, "/contact-us/?submitted=true");
}
// Validation
if (!name || !email || !company || !projectType || !message) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Freemail filter
if (isFreemailDomain(email)) {
return new Response(
JSON.stringify({ error: "Please use your work email address" }),
{ status: 422, headers: { "Content-Type": "application/json" } },
);
}
// Build structured email body
const emailBody = [
New Lead from mysite.com,
========================================,
,
Contact,
Name: ${name},
Email: ${email},
Company: ${company},
,
Project,
Type: ${projectType},
Budget: ${budget || "Not specified"},
,
Message,
${message},
,
========================================,
Reply directly to ${email} to start the conversation.,
,
Submitted: ${new Date().toISOString()},
-- Lead Notification,
].join("\n");
// Send via Mailgun (fire-and-forget)
const mgDomain = env.MAILGUN_DOMAIN;
const apiKey = env.MAILGUN_API_KEY;
const notificationEmail = env.NOTIFICATION_EMAIL;
const mgFormData = new FormData();
mgFormData.append("from", Contact Form );
mgFormData.append("to", notificationEmail);
mgFormData.append("subject", New Lead: ${name} — ${company} (${projectType}));
mgFormData.append("text", emailBody);
mgFormData.append("h:Reply-To", email);
// waitUntil: send email AFTER response is returned to user
context.waitUntil(
fetch(https://api.mailgun.net/v3/${mgDomain}/messages, {
method: "POST",
headers: {
Authorization: "Basic " + btoa(api:${apiKey}),
},
body: mgFormData,
}).then(async (res) => {
if (!res.ok) {
console.error("Mailgun error:", res.status, await res.text());
}
}).catch((err) => {
console.error("Mailgun fetch failed:", err);
})
);
return jsonOrRedirect(request, { success: true }, "/contact-us/?submitted=true");
};
/** Return JSON for fetch requests, redirect for standard form submissions */
function jsonOrRedirect(
request: Request,
data: object,
redirectUrl: string,
): Response {
const accept = request.headers.get("Accept") || "";
if (accept.includes("application/json")) {
return new Response(JSON.stringify(data), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return Response.redirect(new URL(redirectUrl, request.url).toString(), 302);
}
`
Response codes
| Status | Meaning | Frontend behavior |
200 | Success | Hide form, show success message, fire GA4 event |
400 | Missing required fields | Should not happen (HTML required catches it) |
422 | Freemail rejected | Show inline error on email field |
500 | Server error | Show error on button, auto-reset after 3s |
Why waitUntil() for email
context.waitUntil() tells the CF runtime to keep the function alive after the response is sent. The user gets an instant 200, and the Mailgun API call happens in the background. If Mailgun is slow (500ms+), the user doesn't wait.
Why plain text email (not HTML)
- Plain text is guaranteed to render in every email client
- No template maintenance
- The structured format is easy to scan
- h:Reply-To
header lets you reply directly to the lead's email
5. Mailgun Setup
Create account
Verify sending domain
(use a subdomain, not your root domain)| Type | Name | Content |
| TXT | mg | v=spf1 include:mailgun.org ~all |
| TXT | smtp._domainkey.mg | (DKIM key from Mailgun) |
| CNAME | email.mg | mailgun.org |
| MX | mg | mxa.mailgun.org (priority 10) |
| MX | mg | mxb.mailgun.org (priority 10) |
Get API key
)Test from command line
`bash
curl -s --user "api:key-XXXXXXXXXX" \
https://api.mailgun.net/v3/mg.mysite.com/messages \
-F from="Test
-F to="your@email.com" \
-F subject="Mailgun Test" \
-F text="It works."
`
6. CF Secrets Configuration
Set secrets via Wrangler CLI
`bash
# Each command prompts for the secret value (never visible in CLI history)
npx wrangler pages secret put MAILGUN_API_KEY --project-name=my-site
npx wrangler pages secret put MAILGUN_DOMAIN --project-name=my-site
npx wrangler pages secret put NOTIFICATION_EMAIL --project-name=my-site
`
Values:
- MAILGUN_API_KEY
: Your Mailgun private API key (key-XXXXXXXXXX)
- MAILGUN_DOMAIN
: Your verified Mailgun domain (mg.mysite.com)
- NOTIFICATION_EMAIL
: Where leads should be sent (you@company.com)
Set for specific environments
`bash
# Production only
npx wrangler pages secret put MAILGUN_API_KEY --project-name=my-site --env=production
# Preview/staging only
npx wrangler pages secret put MAILGUN_API_KEY --project-name=my-site --env=preview
`
Verify secrets are set
`bash
# List secrets (values are hidden)
npx wrangler pages secret list --project-name=my-site
`
7. Testing Checklist
Local testing
`bash
# Build the site
npm run build
# Run with CF Pages dev server (supports Functions)
npx wrangler pages dev dist
# → Open http://localhost:8788/contact-us/
`
Note: Secrets aren't available in local dev. Create a .dev.vars file for local testing:
`
MAILGUN_API_KEY=key-XXXXXXXXXX
MAILGUN_DOMAIN=mg.mysite.com
NOTIFICATION_EMAIL=you@test.com
`
Test scenarios
- [ ] Happy path: Fill all required fields with valid work email, submit. Verify: form hides, success message shows, email arrives.
- [ ] Freemail rejection: Enter test@gmail.com
. Verify: email field turns red, shows "Please use your work email address". Fix email, verify error clears on input.
- [ ] Honeypot: Use browser DevTools to un-hide the _gotcha
field, enter a value, submit. Verify: form appears to succeed (200 response) but no email is sent.
- [ ] Missing required fields: Remove required
attribute in DevTools, submit empty form. Verify: 400 response, no email sent.
- [ ] Non-JS fallback: Disable JavaScript, submit form. Verify: redirects to /contact-us/?submitted=true
and shows success message on page load.
- [ ] Network error: Disconnect internet, submit form. Verify: button shows "ERROR — TRY AGAIN", resets after 3 seconds.
- [ ] GA4 event: Open GA4 DebugView (GA4 Admin > DebugView), submit form. Verify: generate_lead
event fires with correct parameters.
- [ ] Email format: Submit a lead, check the notification email. Verify: structured format, Reply-To header set to submitter's email, subject includes name + company + project type.
- [ ] Double submit prevention: Click submit rapidly. Verify: button disables on first click, only one email sent.
Production verification
After deploying:
Monitoring
- Mailgun dashboard: Check delivery rate, bounces, complaints
- CF Pages Function logs: Check for errors in function execution
- GA4: Track generate_lead` conversion count over time