Static sites can't process form submissions because there's no server-side runtime to handle POST requests, validate input, or send emails. That was a choice I made to avoid maintaining a server. If you want a contact form on a static site you need to call out to something else.

I've solved this problem twice: first on AWS in 2018, then on Vercel in 2026. Same outcome, completely different approaches. This post compares the two.

The AWS Version (2016)

The original site was hosted on S3 with CloudFront. For the contact form I wired together five AWS services: API Gateway to receive the POST request, Lambda to process it, SES to send the email, and Google reCAPTCHA to keep bots out.

Browser HTML form + jQuery AJAX API Gateway POST /myform Lambda Python function Google reCAPTCHA Bot verification AWS SES Send email S3 + CloudFront Static hosting + CDN

The flow was: the HTML form collected input, jQuery serialised it as JSON, and an AJAX POST hit the API Gateway endpoint. API Gateway passed the payload to a Lambda function, which first verified the reCAPTCHA token with Google, then called SES to send the email.

Here's the core of the Lambda function. It's Python 2-era code; urllib2 and iteritems() give that away immediately:

def website_email(event, context):
    for each_key, value in event.iteritems():
        if not value.strip():
            return 'Please complete all form fields and try again.'

    captcha = event['g-recaptcha']
    email_from = 'noreply@[DOMAIN]'
    email_to = 'me@j[DOMAIN]'
    email_subject = event['subject'].strip()
    email_body = "From {} <{}>\n\n{}".format(
        event['name'].strip(),
        event['email'].strip(),
        event['message'].strip()
    )

    if confirm_captcha(captcha):
        ses.send_email(
            Source=email_from,
            ReplyToAddresses=[event['email'].strip()],
            Destination={'ToAddresses': [email_to]},
            Message={
                'Subject': {'Data': email_subject},
                'Body': {'Text': {'Data': email_body}}
            }
        )

It worked, but there were a lot of moving parts. API Gateway needed a mapping template to pass the JSON through correctly. SES required a verified domain and email address. The reCAPTCHA secret had to be hardcoded in the function (no Secrets Manager back then; or at least I didn't know about it). And Lambda's IAM role needed SES send permissions.

Getting all of that wired up took about a week of head-scratching and tinkering.

The Vercel Version (2026)

When I rebuilt the site on Vercel in 2026, the contact form was dramatically simpler. Vercel supports serverless functions out of the box: put a file in the api/ directory and it becomes an endpoint.

Browser HTML form + fetch() Vercel Hosting + CDN + DNS api/contact.js Serverless function Cloudflare Turnstile Bot verification Mailgun EU api.eu.mailgun.net

The entire contact form is a single file: api/contact.js. It validates the input, verifies a Cloudflare Turnstile token for bot protection, then calls the Mailgun EU API to send the email. All credentials are stored as Vercel environment variables.

Here's the core of the Vercel function, trimmed for clarity:

export default async function handler(req, res) {
  const { name, email, message, token } = req.body;

  // Verify Turnstile token
  const verifyRes = await fetch(
    'https://challenges.cloudflare.com/turnstile/v0/siteverify',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        secret: process.env.TURNSTILE_SECRET_KEY,
        response: token || '',
      }),
    }
  );

  // Send via Mailgun EU
  const formData = new URLSearchParams();
  formData.append('from', `${name} `);
  formData.append('to', CONTACT_EMAIL);
  formData.append('subject', `Message from ${name}`);
  formData.append('text', `Name: ${name}\nEmail: ${email}\n\n${message}`);
  formData.append('h:Reply-To', email);

  await fetch(`https://api.eu.mailgun.net/v3/${MAILGUN_DOMAIN}/messages`, {
    method: 'POST',
    headers: {
      Authorization: 'Basic ' + Buffer.from('api:' + MAILGUN_API_KEY).toString('base64'),
    },
    body: formData,
  });
}

No API Gateway, no IAM roles, no SES domain verification. The function is just JavaScript calling two REST APIs.

What Changed

The biggest difference is the number of services involved. The AWS version needed five distinct services configured and wired together: API Gateway, Lambda, SES, IAM, and reCAPTCHA. The Vercel version needs three: a serverless function, Cloudflare Turnstile and Mailgun.

The front-end simplified too. jQuery and AJAX were replaced with a native fetch() call. No library needed.

There were a couple of DNS lessons on the Vercel side. Mailgun requires SPF and DKIM records for outbound email verification. I already had an SPF record for another provider, so I needed to merge them into a single TXT record rather than creating a duplicate. The MAILGUN_DOMAIN environment variable also needs to be the root domain (jamescarty.co.uk), not a subdomain which caught me out initially.

Bot protection swapped from Google reCAPTCHA v2 to Cloudflare Turnstile, which is invisible to the user. A nicer experience and fewer dependencies.

What Stayed the Same

The core desire and pattern is identical: avoid maintaining servers or applications; use a static front-end that calls a serverless function, which validates the request and forwards it to an email service for delivery. QUIT.