Submit a Static Website Form with Cloudflare Workers

I had a recent small project for a family member's business I took on that I did with a static website. The website needed a contact form though. Since I was already hosting the website on Cloudflare's Pages product for static websites, I thought I would try their serverless workers out for the contact form. Cloudflare's Worker documentation is a work in progress & needs some love. I submitted a few PRs. It seemed that they were possibly doing a massive overhaul as well. To help anyone interested I'm adding this blog article. Hopefully, you get some use out of it.

Free Everything

What's awesome about the tools I ended up selecting for this project is that they all offer free developer plans for personal projects. If you're running a business, you'll have to look at each one in particular. But everything in this project is easy to swap in & out.

Here's the scope of what we're building:

Project Overview

You can pull the project from GitHub & run npm install.

Build a Static Website with a Form

A sample.tsx file is in the root that shows how to use this project with a React front end. It should be simple to translate that to JS without a framework or your framework of choice. You will need to set up a free account on each provider unless you don't want to use that piece. This is the part of your website you would place on something like Cloudflare Pages.

Here are some of the critical parts. For Google's Captcha, we need to add this to the header of your page. You will need to enter your unique captcha key from Google in {GOOGLE_CAPTCHA_KEY}.

<script async src={`${GOOGLE_CAPTCHA_KEY}`}></script>

For the form submit, we can attach an onSubmit event listener like the following.

const handleSubmit = (e: SyntheticEvent<HTMLElement>) => {
        window.grecaptcha.ready(async function () {
            const token = await window.grecaptcha.execute(GOOGLE_CAPTCHA_KEY, { action: 'submit' });
            const body = {
                from: {
                    name: name,
                    email: email,
                message: message,
                token: token
            try {
                const request = await fetch(CLOUDFLARE_WORKER_URL, {
                    method: "POST",
                    body: JSON.stringify(body),
                    headers: {
                        "Content-Type": "application/json"
                if (request.status === 202) {
                } else {
                    const text = await request.text();
            } catch (error) {

Using onSubmit, this handler can catch the button with type="submit" events & when a user presses the enter key in a form. The first step is to prevent the default action value of the form. Then use Google's grecaptcha to get a token to pass along with our request in an attempt to verify that a person submitted the form. After that, the method formats the body of the request to send to our Cloudflare Worker & then sends it as a POST request. Some of the variables being passed are set outside of this method, so checkout the sample.tsx for more info. This method is also using a modal to show an error or success message based on the response from the Cloudflare Worker.

Cloudflare Worker Code

The src directory has the most important parts. Starting with the index.tsx file.

import { initSentry } from "./logger";
import { handleRequest } from './handler'

addEventListener("fetch", (event) => {
  const sentry = initSentry(event);
  event.respondWith(handleRequest(event.request, sentry));

This is the event listener that Cloudflare Workers will run when it receives a HTTP request. The Sentry logging tool gets initialized here & gets passed in to the custom handleRequest method. The handleRequest method will generate the response once we're done.

The .handle.tsx file has the bulk of our logic, including the handleRequest method.

export async function handleRequest(request: Request, sentry: Toucan): Promise<Response> {
  try {
    if (isOptionsRequest(request)) return handleOptionsRequest(request);
    const {isValid, msg, contactRequest} = await validateRequest(request, sentry);
    if (!isValid) {
      sentry.captureMessage(`Invalid Request: ${msg}`);
      return invalidRequestResponse(msg);
    const emailRequest = await sendEmail(contactRequest);
    if (emailRequest.status !== 202) {
      const msg = await emailRequest.text();
      sentry.captureMessage(`Sendgrid request failed.
      Request: ${JSON.stringify(contactRequest)}
      Response: ${emailRequest.status} ${msg}`);

      return invalidRequestResponse(sendEmailFailedMsg);

    return acceptedResponse();

  } catch (error) {
    return invalidRequestResponse();

The first step is to check if we're receiving an Options HTTP request. If that's the case, we respond with what types of HTTP requests we're accepting & end the task.

Then we check if this is a valid request. This lives in the validate.ts. It contains a bunch of simple checks looking to make sure it's a POST request & the necessary fields exist to complete this task. The Google Captcha validation lives in here as well. Let's look closer at that.

export const validateCaptcha = async (request: ContactRequest, sentry: Toucan): Promise<StatusMsg> => { 
    if (!request.token) {
        sentry.captureMessage('Missing captcha token. Request:' + JSON.stringify(request));
        return Promise.resolve({ status: false, msg: sendEmailFailedMsg });

    const captchaUrl = `${RECAPTCHA_SECRET}&response=${request.token}`;
    const result = await fetch(captchaUrl);
    const json = await result.json() as CaptchaResponse;

    if (!json.success) {
        sentry.captureMessage('Invalid captcha response.' + getCaptchaResponseAndOriginalRequest(json, request));
        return Promise.resolve({ status: false, msg: sendEmailFailedMsg });

    if (captchaScoreIsNotValid(json.score)) {
        sentry.captureMessage('Low Captcha Score. ' + getCaptchaResponseAndOriginalRequest(json, request));
        return Promise.resolve({ status: false, msg: sendEmailFailedMsg });

    return { status: true, msg: '' };

The captcha method makes sure there is a token passed by Google into the request. If not, it's logged via the Sentry logger & a response is sent back to the client. If the token exists, it gets verified by Google. Google then sends back a score from 0.0 to 1.0. In captchaScoreIsNotValid I've set it to make sure the score is equal to or above 0.5. If everything looks good in the captcha, we move on to sending an e-mail in the sendEmail.ts file.

export const sendEmail = (contactRequest: ContactRequest): Promise<Response> => {
    const emailRequest =
        new Request('', {
            method: 'POST',
            body: formatSendGridRequest(contactRequest),
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${SENDGRID_API_KEY}`

    return fetch(emailRequest);

This is a simple request to SendGrid. Using a tool like SendGrid or MailChimp allows an easy way to send & track e-mail success rates. SendGrid will send us a success or a failure message with an error. If the message is a success our handleRequest method is done & can reply with a success message. If an error occurs, it gets logged via Sentry.

Sentry is a very nice logging tool. If you pay for it, you can get integrations with Asana, GitHub, or a ton of other issue trackers. Sentry also has nice issue tracking.

GitHub Workflow

A workflow file exists .github\workflows\node.js.yml to build, run tests & deploy to Cloudflare. This makes deployments easy. For each place, you see a ${{ secrets.CF_VARIABLE_NAME }} you will need to add a corresponding 'Action Secret' in your GitHub repository. To do that, go to Settings and click Secrets (under Security), and then Actions. GitHub will replace your variables in the node.js.yml file with the values you set here.

It should be noted, I do have two variables that are not 'secrets' stored in the node.js.yml file.


These two you can set a user secret for or you can just hard code into the config file. I like to hard code these because it allows me to easily look up the value & modify it. I also don't mind if someone else finds these values.

Cloudflare Worker

When you create the Cloudflare Worker, you will need to go to settings and add the same variables that are in the GitHub Workflow.


You will also need to generate an API token to use in your GitHub workflow to use with the CF_API_TOKEN secret. You can generate a token by clicking on the 'My Profile' link once you're logged in on the upper right corner. Then go to API Tokens & generate a token with access to the following: Workers Tail:Read, Workers KV Storage:Edit, Workers Scripts:Edit.


I wrote this tutorial rather swiftly & months after implementing the actual code. Please let me know if you found it valuable or if you have questions or found something I could improve on. I wouldn't mind spending more time building small apps with tutorials but I want to make sure people are finding value first.

One Last Thing...

If you have a question or see a mistake, please comment below.

If you found this post helpful, please share it with others. It's the best thanks I can ask for & it gives me momentum to keep writing!

Matt Ferderer
Software Developer focused on making great user experiences. I enjoy learning, sharing & helping others make amazing things.
Let's Connect