Features

Contact Form

# 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

  • Architecture
  • Frontend: Form HTML
  • Frontend: Client-Side Handling
  • CF Pages Function
  • Mailgun Setup
  • CF Secrets Configuration
  • Testing Checklist

  • 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."

    >

    id="contact-form"

    action="/api/contact"

    method="POST"

    >

    type="text"

    name="_gotcha"

    style="display:none"

    tabindex="-1"

    autocomplete="off"

    />

    type="text"

    id="name"

    name="name"

    required

    autocomplete="name"

    placeholder="Jane Doe"

    />

    type="email"

    id="email"

    name="email"

    required

    autocomplete="email"

    placeholder="jane@company.com"

    />

    type="text"

    id="company"

    name="company"

    required

    autocomplete="organization"

    placeholder="Acme Corp"

    />

    id="message"

    name="message"

    rows="5"

    required

    placeholder="What are you building, what's broken, and what does the timeline look like?"

    >

    No sales SDRs. A principal engineer reviews every inquiry.

    `

    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

  • Button state feedback: Disable button and change text during submission
  • Inline error for freemail: Show error directly on the email field, not a generic alert
  • Clear error on input: Remove red border when user starts typing again
  • Auto-reset error text: Restore button text after 3 seconds on error
  • Non-JS fallback: Form action + method POST works without JavaScript, redirects to ?submitted=true

  • 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 = async (context) => {

    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

    StatusMeaningFrontend behavior
    200SuccessHide form, show success message, fire GA4 event
    400Missing required fieldsShould not happen (HTML required catches it)
    422Freemail rejectedShow inline error on email field
    500Server errorShow 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

  • Sign up at mailgun.com
  • Free tier: 100 emails/day (sufficient for contact forms)
  • Verify sending domain

  • Mailgun Dashboard > Sending > Domains > Add Domain
  • Add domain: mg.mysite.com (use a subdomain, not your root domain)
  • Add DNS records in Cloudflare:
  • TypeNameContent
    TXTmgv=spf1 include:mailgun.org ~all
    TXTsmtp._domainkey.mg(DKIM key from Mailgun)
    CNAMEemail.mgmailgun.org
    MXmgmxa.mailgun.org (priority 10)
    MXmgmxb.mailgun.org (priority 10)
  • Click "Verify DNS" in Mailgun dashboard
  • Wait for verification (usually 1-5 minutes with Cloudflare)
  • Get API key

  • Mailgun Dashboard > Settings > API Keys
  • Copy the "Private API Key" (starts with 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:

  • Submit a test lead with a real work email
  • Check email delivery (check spam folder too)
  • Check CF Pages Function logs: Dashboard > Pages > Project > Functions > Logs
  • Check Mailgun logs: Dashboard > Sending > Logs
  • Verify GA4 event: GA4 Realtime report
  • 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