John Dalesandro

Contact Form for Static Sites Using Cloudflare Workers and Airtable

If you’re running a static site, adding a contact form can be challenging since there’s no backend to handle form submissions. Sure, you can list your e-mail address or link to social profiles, but a contact form offers a better user experience. My goal was to set up a simple, spam-protected contact form at no cost for a low traffic static site. In this guide, I’ll show you how to build one using:

Instructions

Step 1: Create Contact Form

Start by building a basic HTML form with the following fields (all required): Name, Email, Subject, and Message. Add a honeypot field, which can be hidden with CSS, to help detect bots (which often fill in all available fields, including hidden ones).

For simplicity, no styling is applied to the form.

<form id="contactForm">
  <label for="name">Name</label>
  <input type="text" id="name" name="name" placeholder="" value="" required>
  <label for="email">Email</label>
  <input type="email" id="email" name="email" placeholder="" value="" required>
  <label for="subject">Subject</label>
  <input type="text" id="subject" name="subject" placeholder="" value="" required>
  <label for="message">Message</label>
  <textarea id="message" name="message" placeholder="" required></textarea>
  <div>
    <label for="honeypot">Do Not Use</label>
    <textarea id="honeypot" name="honeypot"></textarea>
  </div>
  <button id="submit-button" type="submit">Send Message</button>
</form>
<div id="response"></div>

The <form> element does not include action or method attributes. Instead, use JavaScript to:

let isSubmitted = false;

document.addEventListener('DOMContentLoaded', function () {
  document.getElementById('contactForm').addEventListener('submit', async (e) => {
    e.preventDefault();

    if (!isSubmitted) {
      isSubmitted = true;
      document.getElementById('submit-button').disabled = true;
      document.getElementById('submit-button').classList.add('disabled');

      const formData = new FormData(document.getElementById('contactForm'));

      try {
        const response = await fetch('/contact/submit/', {
          method: 'POST',
          body: formData
        });

        if (!response.ok) {
          throw new Error(response.status);
        }

        const responseText = await response.text();

        document.getElementById('response').textContent = responseText;
      } catch (error) {
        document.getElementById('response').textContent = `ERROR: ${error.message}`;
      }
    }

    return;
  });

  return;
});

Step 2: Set Up Airtable (Workspace, Base, Table, and API Keys)

We’ll use Airtable to collect form data and send e-mail notifications.

  1. Sign in or create an Airtable account.
  2. Create a new workspace (e.g., Cloudflare Workers).
Screenshot of the Airtable workspaces screen.
Airtable: Workspaces Screen
  1. Add a base named Messages, and create a table also called Messages.
Screenshot of a new Airtable base and table.
Airtable: New Base and Table Screen
  1. Add these fields to the table: Name, Email, Subject, and Message.
Screenshot of the Airtable table fields configuration screen.
Airtable: Table Fields Configuration Screen
  1. Click the Help button and open API documentation. In the Introduction section, note the ID for the Messages base — this will be referred to as AIRTABLE_BASE_ID in a later step.
Screenshot of the Airtable API documentation for base screen.
Airtable: API Documentation for Base Screen
  1. In the MESSAGES TABLE section, note the ID for the Messages table — this will be referred to as AIRTABLE_TABLE_ID in a later step.
Screenshot of the Airtable API documentation for table screen.
Airtable: API Documentation for Table Screen
  1. Go to the Personal access tokens page and Create new token. Name the token and add the scope data.records:write with access to the appropriate base. Click the Create token button and note the TOKEN ID — this will be referred to as AIRTABLE_ACCESS_TOKEN in a later step.
Screenshot of the Airtable Builder Hub Personal Access Token screen.
Airtable: Builder Hub Personal Access Token Screen
  1. Optional but recommended: Return to the Messages base and click Automations. Create an automation to send a notification e-mail to a verified e-mail address each time a new entry is created in the Messages table.
Screenshot of the Airtable Automation screen.
Airtable: Automation Screen

In the Send an email action, configure the notification to include the appropriate subject line and fields from the Messages table as well as automation timing.

Screenshot of the Airtable Automation screen to send an e-mail notification when a new record is created.
Airtable: Automation To Send E-mail When New Message Record Is Created

Step 3: Create a Cloudflare Worker

Now we’ll create the backend service that connects your HTML form to Airtable.

  1. Log in to Cloudflare.
  2. Click Compute (Workers) and go to Workers & Pages and click the Create button.
  3. Under the Workers tab, click the Get started button for Start with Hello World!.
  4. On the Create an application page, rename the project to something more meaningful and click the Deploy button.
  5. After deployment, click the Continue to project button.
  6. On the dashboard, click the Settings tab.
  7. Configure Domains & Routes. Disable workers.dev and Preview URLs routes. Define a custom route (non-wildcard) specific to your site (e.g., /contact/submit/).
Screenshot of a the Cloudflare Workers Domains and Routes configuration screen.
Cloudflare Workers: Domains & Routes Configuration
  1. Under Variables and Secrets, define three new environment variables of type Text using the three API key names and their associated values noted from Airtable: AIRTABLE_ACCESS_TOKEN, AIRTABLE_BASE_ID, and AIRTABLE_TABLE_ID. Click the Deploy button.
Screenshot of a the Cloudflare Workers Variables and Secrets configuration screen.
Cloudflare Workers: Variables and Secrets Configuration
  1. Returning to the dashboard and open the code editor.
  2. Replace the template code with the following code. Adjust the conditions for requestURL (which should match the enabled route defined in Domains & Routes) and refererURL (which should match the URL of the HTML form that will post to this worker). These two conditions are included for basic spam detection and can be removed if you enjoy receiving spam. Click the Deploy button.
async function createRecord(env, body) {
  try {
    const result = fetch(
      `https://api.airtable.com/v0/${env.AIRTABLE_BASE_ID}/${encodeURIComponent(env.AIRTABLE_TABLE_ID)}`,
      {
        method: 'POST',
        body: JSON.stringify(body),
        headers: {
          Authorization: `Bearer ${env.AIRTABLE_ACCESS_TOKEN}`,
          'Content-Type': 'application/json',
        },
      },
    );

    return result;
  } catch (error) {
  }
}

export default {
  async fetch(request, env) {
    if (request.method !== 'POST') {
      return new Response('Message Failed: Method Not Allowed', { status: 200 });
    }

    const requestURL = new URL(request.url);

    if (requestURL.toString() !== 'https://johndalesandro.com/contact/submit/') {
      return new Response('Message Failed: Endpoint Not Found', { status: 200 });
    }

    const refererURL = new URL(request.headers.get('referer'));

    if (refererURL.toString() !== 'https://johndalesandro.com/contact/') {
      return new Response('Message Failed: Flagged As Spam (Invalid Referer)', { status: 200 });
    }

    try {
      const body = await request.formData();
      const { name, email, subject, message, honeypot } = Object.fromEntries(body);

      if (honeypot) {
        return new Response('Message Failed: Flagged As Spam (Honeypot)', { status: 200 });
      }

      if (!name || !email || !subject || !message) {
        return new Response('Message Failed: Invalid Input', { status: 200 });
      }

      const reqBody = {
        fields: {
          Name: name,
          Email: email,
          Subject: subject,
          Message: message,
        },
      };

      const response = await createRecord(env, reqBody);

      if (!response.ok) {
        return new Response(`Message Failed: ${response.statusText}`, { status: 200 });
      }

      return new Response('Message Sent Successfully', { status: 200 });
    } catch (error) {
      return new Response(`Message Failed: ${error.message}`, { status: 200 });
    }
  }
};

Results

Test the form by submitting a message.

Screenshot of a completed contact form with test data.
Contact Form Test Data

A confirmation response like Message Sent Successfully appears under the form.

Screenshot of a contact form submission with a successful response message.
Contact Form Success Message

Check your Airtable base — a new record is created in the Messages table.

Screenshot from Airtable of the Messages table containing a new record.
Airtable: New Messages Record Created

If automation is enabled, you will receive an e-mail notification with the submitted details.

Screenshot of the e-mail notification received from the Airtable automation.
Airtable E-mail Notification

Summary

This guide walks you through setting up a free, spam-protected contact form for a static site using HTML, JavaScript, Cloudflare Workers, and Airtable. It’s a lightweight and reliable solution that lets you collect messages and receive e-mail notifications — all without a dedicated server.