Using Cloudflare Workers and reCAPTCHA v3 for a Static Site Contact Form
Introduction
I recently wrote a blog post that involved using a Cloudflare Worker to serve Brotli 11 compressed HTML rather than the standard uncompressed HTML file. Off the back of this post, I’ve decided to write one about my Cloudflare Worker setup that I use for my Contact Page. In order to stop the spam, I also integrated reCAPTCHA v3 into the form functionality. The form is pretty simple, it asks a visitor for their full name, email address and the message they with to send to me. The Worker then pulls together this information and sends an email to a custom email address that points to my personal email address. This allows me to easily filter messages that come from the site, which I then have the option to reply to if I so wish.
It’s worth noting that this post was heavily inspired by Sia Karamalegos’s post from 2024 about migrating from Netlify to Cloudflare. I had the absolute please of meeting Sia in Amsterdam when I spoke at Performance.now() 2023, she was MC for my talk and I really appreciated her support when I was (quite frankly) freaking out with nerves before the talk!
So why use a serverless endpoint for the contact form? Well, as I’ve mentioned many times before, this blog is a static site built using 11ty and hosted on the Pro plan of Cloudflare Pages. There simply is no backend to point the POST method too, which is required to send the email.
Previous solutions I’ve used
This isn’t the first time I’ve changed the way the contact form works on this blog. I’ve counted 5 alternatives that I have used over the years! Isn’t Git great for tracking history! I certainly wouldn’t be able to remember them all with my terrible memory! So, if you aren’t on Cloudflare, or you would rather not use a Worker for this, then there’s a list of perfectly viable alternatives below:
Netlify Forms
- URL: Netlify Forms
- Cost: Free+ (depending on the Netlify tier you choose)
Netlify Forms is a built-in form handling service for sites hosted on Netlify. It allows you to collect and manage form submissions from static websites without building or maintaining a backend. Submissions can be viewed in the Netlify dashboard or forwarded via email or webhook. I still think it’s a real shame that Cloudflare doesn't offer a form setup that is as easy to integrate and use as Netlify Forms, although, if it did, I wouldn’t need to be writing this blog post!
Cloudflare Pages Functions + MailChannels
- URL: https://developers.cloudflare.com/pages/functions/ & https://www.mailchannels.com/
- Cost: Free plan available ($10+ depending on volume-based pricing)
When I initially migrated from Netlify to Cloudflare for hosting I used this setup, that was until MailChannels sunset its free email sending service for Cloudflare Workers users (me), at that point I started looking for other solutions. As you will see below.
Formspree.io
- URL: https://formspree.io/
- Cost: Free tier available ($15+ a month for paid plans)
Formspree is a hosted form service that lets you collect form submissions without building your own backend server. You simply point your form at Formspree, and it handles sending submissions to your email address or other API endpoints, it comes with built-in spam protection and integrations for various workflows.
Formspark.io (previously Submit Form)
- URL: https://formspark.io/
- Cost: Free tier available ($9+ a month for paid plans)
Formspark is a basic hosted form backend that lets you accept and manage form submissions without the need for your own backend server. You simply submit forms to Formspark, and it stores (or forwards) the data, with options for email notifications, webhooks, and spam filtering.
Botpoison
- URL: https://botpoison.com/
- Cost: Free plan available ($4+ per month depending on bot verifications required)
Botpoison is an invisible anti-spam and bot prevention service for web forms that blocks automated submissions without requiring CAPTCHAs or extra steps from users. It works by analysing form interactions and applying proof-of-work challenges and reputation checks to distinguish human traffic from bots. I started using it when I was still receiving contact form spam from my Formspark.io setup.
Resend
- URL: https://resend.com/
- Cost: Free plan available ($20+ for paid plans)
Resend is a developer-focused email delivery service, providing a simple API and Simple Mail Transfer Protocol (SMTP) interface for sending transactional and broadcast emails from your applications or website. It handles features like deliverability, bounce tracking, suppression lists, and email analytics, so a developer doesn't need to manage email infrastructure themselves and can be integrated with many platforms. Resend is currently part of my email workflow, as I will describe later in the post.
Overview of the setup
My current setup is: Cloudflare Pages Functions (the Worker) + Google reCAPTCHA v3 + Resend for email.
A high-level view of this workflow is:
- User populates the contact form with their details and message
- reCAPTCHA v3 runs and adds a short-lived verification token
- The form POSTs to
/api/contact(via the Pages Function, that sits infunctions/api/contact.jsin the root of my repository - The Pages Function, then:
- verifies the token with Google
- validates the user inputs
- sends the validated inputs via email using Resend
- Redirects the user to the “Thank You” page, confirming that the message has been sent.

Google reCAPTCHA v3
I have to say, out of all the things written in this post, researching about how exactly Google reCAPTCHA v3 works, was probably the most fascinating! I really love the fact that it is all invisible to the user, and it is based on a complex (but logical) scoring mechanism. There’s no need for a really frustrating visual CAPTCHA’s that don’t prove you are human, “they prove that you are American!” (I can’t take credit for this quote, that I believe goes to Terence Eden, or at least that's who I remember reading it from!)
So how exactly does reCAPTCHA v3 decide if you are a human? Well, it turns out it analyses a user's behavioural and contextual signals rather than setting a user a visual challenge, and based on those signals it creates a risk score. The biggest signal comes from how the user behaves on the page.
Behavioural analysis of a user's session
reCAPTCHA observes a number of patterns and metrics from a user's interaction with the page, these include:
- Mouse movement patterns
- Typing speed and rhythm
- Scroll behaviour
- Click timing and placement
- Focus changes between fields or tabs
Us humans are a little messy when using a computer. Our mouse movements curve and wander slightly, we pause without thinking, and our typing comes with the occasional hesitation or uneven rhythm.
Skynet, on the other hand, tends to be a little too perfect. Its mouse paths are often straight, timing is predictable, and its interactions can happen far faster than any real person would manage. Those overly neat patterns are often a strong indicator that automation is at work.
Browser and device fingerprinting
Now, when I think of Browser fingerprinting I often associate it with advertising and companies tracking users across the internet. In all fairness, this is Google we are talking about, so it's very likely that they actually do this! :sad_face:. Browser and device fingerprinting is the way the reCAPTCHA script also examines the environment and device information of where the request is coming from.
Typical signals include:
- Browser version and capabilities
- Installed fonts and plugins
- Screen resolution and device properties
- WebGL and Canvas fingerprint signals
- Cookie availability
- JavaScript execution behaviour
- Timezone of client
- Language settings
You’d be amazed at how accurate these fingerprints can be. Each piece of information adds a little more entropy, meaning it becomes easier to distinguish one device from another. They are so accurate in fact I know many financial sectors or government departments use them for fraud detection.
You really don’t need to look very far to find libraries like Fingerprint that do this all for you! Look at all the information it captured about you just by clicking this link! And you thought cookie tracking was bad!
Note: There are many other fingerprinting libraries available, I’m not specifically targeting Fingerprint.
So while I disagree with browser and device fingerprinting from a privacy and tracking perspective, it does have its uses when it comes to proving that a “user” is a human and not a bot. In particular, bots often run in headless browsers or minimal environments, which look very different from real user devices.
Network and reputation signals
Due to Google’s global network, it can also delve into global reputation data. It captures:
- IP reputation and history
- Known bot networks or proxy usage
- Data centre IP ranges
- Previous abuse patterns linked to the IP or session
So much traffic flows through Googles network globally, it can detect suspicious network traffic surprisingly easily.
Action Context
Google can also score the predicted actions a user is going to make. With reCAPTCHA v3, developers call the library with an action name such as:
login
checkout
signup
comment
From its countless trillions of observations over the years, Google already knows the typical login behaviour for a human (it takes approximately 3–10 seconds). Whereas, a bot can submit hundreds of logins per minute, which is a huge red flag for the use of automation.
Scoring using Machine learning
So what does Google do with all these various signals it captures?
It feeds them into a machine learning algorithm for scoring, which will decide if whatever is interacting is a human or a bot.
| Score | Meaning |
|---|---|
| 0.9 to 1.0 | Very likely human |
| 0.5 | Uncertain |
| 0.0 to 0.3 | Very likely bot |
At this point, it is then up to your backend to decide what to do with the score it gets back. For example:
0.8+ allow request
0.4–0.8 require MFA
<0.4 block or challenge
In my case, the Cloudflare Worker is my simple serverless backend, and it contains the following code to decide if the form submit is from a human or a bot:
// Verify score (reCAPTCHA v3 returns scores between 0.0 and 1.0)
// Lower scores indicate bot-like behavior. 0.5 is a common threshold, but 0.3 allows more legitimate traffic
if (typeof verifyJson.score === "number" && verifyJson.score < 0.3) {
return new Response(
JSON.stringify({
error: "CAPTCHA verification failed",
details: `Score too low: ${verifyJson.score}`,
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}The code above is saying to reject any score that returns from Google that is under 0.3 (Very likely bot), You could of course easily change this threshold if you found it was being too aggressive with the filtering.
Technical setup
To use reCAPTCHA v3, you must first register your site in the Google reCAPTCHA admin console to obtain two keys.
The “site key” is used on the client-side and allows the browser to request a reCAPTCHA verification token for when a user performs an action. The “secret key” must remain server-side and is used to verify that token with Google.
This means storing the secret securely as an environment variable, for my setup this is done via the Cloudflare Pages build dashboard. The Cloudflare Worker then reads it from the environment variables during the build process.
Once all configured, you can also use the Google reCAPTCHA admin console to:
- restrict the key to specific hostnames
- configure score thresholds to control how strict the bot detection should be
The contact form (HTML and client-side)
The HTML used for the form is all pretty standard, just remember to make it as accessible as possible! Semantic markup is the key to achieving this! If you are unsure how to do this, there’s an excellent post called Creating Accessible Forms on the WebAIM.org website. The HTML for my contact form looks like this:
<!--
Contact form: POSTs to the Cloudflare Pages Function at /api/contact.
Uses novalidate so we can run custom client-side validation and show
accessible error messages before and after submit.
-->
<form id="fs-frm" action="/api/contact" method="POST" aria-labelledby="page-title" novalidate>
<!--
Error summary: shown when validation fails (client or server).
role="alert" announces to screen readers when content appears.
tabindex="-1" allows focus to be moved here after submit for accessibility.
hidden by default; JS removes the attribute to reveal and populate it.
-->
<div id="form-error-summary" class="form-error-summary" role="alert" tabindex="-1" hidden></div>
<!--
Live region for status updates (e.g. "Sending…", success message).
role="status" is polite; screen readers announce changes without interrupting.
-->
<div id="form-status" class="form-status" role="status"></div>
<!--
Honeypot (spam trap): invisible to users, attractive to bots.
- Wrapped in a div with a neutral class (e.g. "abc") so bots don’t skip it.
- aria-hidden="true" keeps it out of the accessibility tree.
- tabindex="-1" and autocomplete="off" reduce chance a human touches it.
- Obscure name so bots don’t recognise it as a honeypot; server rejects if set.
-->
<div class="abc" aria-hidden="true">
<input type="checkbox" name="_its-a-trap!" tabindex="-1" autocomplete="off" />
</div>
<!-- Server/worker uses these hidden fields for redirect and email behaviour -->
<input type="hidden" name="_redirect" value="/contact/thanks/" />
<input type="hidden" name="_append" value="false" />
<input type="hidden" name="_email.subject" value="New Message from Nooshu.com" />
<fieldset>
<!-- Legend is required for fieldset; visually-hidden keeps layout clean -->
<legend class="visually-hidden">Contact form</legend>
<div class="form-group">
<label for="name" class="required">Full Name:</label>
<input type="text" id="name" name="name" autocomplete="name" required />
<span id="name-error" class="form-error" hidden></span>
</div>
<div class="form-group">
<label for="email" class="required">Email:</label>
<input type="email" id="email" name="email" autocomplete="email" placeholder="hello@example.com" required />
<span id="email-error" class="form-error" hidden></span>
</div>
<div class="form-group">
<label for="message" class="required">Message:</label>
<textarea rows="7" id="message" name="message" required></textarea>
<span id="message-error" class="form-error" hidden></span>
</div>
</fieldset>
<!--
reCAPTCHA v3 token: client-side JS runs grecaptcha.execute() and sets
this value before submit. Server verifies the token with Google before
sending the email.
-->
<input type="hidden" name="g-recaptcha-response" value="" />
<button type="submit">Send Message</button>
</form>A version with fewer comments is available in this gist on GitHub.
The contact form I use above has the following features:
Honeypot and reCAPTCHA to block spam
I’ve already detailed how Google reCAPTCHA works, so I won’t cover it again here, but a Honeypot is a simple first layer of protection. The basic theory behind a Honeypot is a simple form field hidden from real users but still present in the page markup. Bots that scan the source code often fill in every field they find, including this hidden one. If that field is completed, it is a strong signal that the submission came from a bot, not a human because it isn’t seen in the User Interface (UI), thus allowing the request to be rejected immediately.
Client and server validation to ensure safe input
Form validation is handled in two layers:
- On the client side, the form uses
novalidatewith custom JavaScript to check required fields and validate the email format for the name, email, and message inputs. - On the server-side, the API performs its own validation by checking that required fields are present, enforcing max length limits for each field (name: 200 characters, email: 320 characters, message: 5000 characters), and confirming that a valid reCAPTCHA token is included with the submission.
Accessible error handling and focus management
Accessibility is built into the form by clearly associating it with the page title using aria-labelledby, providing an error summary and status regions with role=“alert" plus role=“status", also marking individual fields with aria-invalid and aria-describedby so errors are properly announced.
Inline error messages use matching IDs for assistive technologies, required fields are clearly indicated with a *, and a visually hidden legend describes the <fieldset>. When errors occur, the browser focus moves to the summary using tabindex="-1” so it can be announced immediately by the screen reader.
UX updates to keep the user informed during submission
The form includes the autocomplete attribute for the name and email fields, allowing users browser the option to suggest information already stored in the browser and pre-fill it for them. Additionally, there's a simple placeholder for the email input, letting a user know what input format is expected.
When the form is submitted, the submit button is temporarily disabled and its label changes to “Sending…” while a status message informs the user that their message is being sent. After the submission is successful, the user is then redirected to the /contact/thanks/ page, as seen in the _redirect input field, the API then returns a 303 redirect to complete the process.
Secure server side processing
The form submits a POST request to /api/contact, handled by a Cloudflare function. Hidden fields such as _redirect, _append, and _email.subject control what happens on submit.
On the server, the submission is sent as an email in both HTML and plain text formats before redirecting the user on success. The endpoint only accepts form-encoded or multipart content types. It also verifies the reCAPTCHA token using the CF-Connecting-IP header when available, and validates required fields and length limits, it then escapes HTML in the email body to ensure the content is safe.
I’d like to think this form is pretty accessible and simple to use, but if you disagree, please do let me know. How very meta! It’s always fun to learn something new!
Simplified CSP
The great thing about not having to use a third-party form service for sending contact form emails (besides the slight reduction in cost), is that it simplifies my Content-Security-Policy. This is because I’m no longer having to establish a connection to an additional third-party. The Cloudflare Worker runs on my instance of Cloudflare pages, so it is embedded in the build. And thankfully there’s no client-side interaction when using Resend, as this all happens via the Cloudflare Worker, no modifications to the CSP required! This may not seem like a big thing, but the less you can rely on a third parties the better! There are numerous examples from the history of the web, where the failure of a third party has had a massive impact on the web:
- Fastly outage on 8 June 2021
- Dyn DNS outage (2016)
- Facebook BGP outage (2021)
- Wikipedia Category on Internet outages
An interesting point about the Wikipedia link above is you can see how prominent these outages have become over the decades, and also the fact that the placeholder category pages for the 2030s, 2040s, 2050s, and 2060s are already listed! Oh well, those outages are for my grandkids to worry about! 👴🏼
The Cloudflare Worker (Pages Function)
So let’s get onto the final piece of the puzzle: the Cloudflare Worker code. This file sits in the root of my blog under functions/api/contact.js, this translates to the action="/api/contact” in the form code above.
// Import a small helper that escapes HTML characters.
// This prevents user-supplied content (like the message body)
// from breaking HTML or injecting markup when we render it in the email.
import { escapeHtml } from "../../_helpers/escape-html.js";
// Cloudflare Pages Functions export HTTP handlers as named exports.
// `onRequestPost` will be invoked for POST requests to this function's route.
//
// Signature:
// - `request`: the incoming HTTP Request object
// - `env`: environment bindings (secrets, KV, etc.), configured in Cloudflare
export const onRequestPost = async ({ request, env }) => {
try {
// 1. Accept only standard HTML form submissions
// ------------------------------------------------
// We support:
// - application/x-www-form-urlencoded (classic HTML <form method="post">)
// - multipart/form-data (forms that include files)
//
// Anything else (JSON, etc.) is rejected with 415 Unsupported Media Type.
const contentType = request.headers.get("content-type") || "";
if (
!contentType.includes("application/x-www-form-urlencoded") &&
!contentType.includes("multipart/form-data")
) {
return new Response(
JSON.stringify({ error: "Unsupported content type" }),
{
status: 415,
headers: { "content-type": "application/json" },
},
);
}
// Parse the incoming form body. Cloudflare's Request object
// exposes a `.formData()` helper which returns a FormData instance.
const form = await request.formData();
// 2. Honeypot anti-spam field
// ------------------------------------------------
// A "honeypot" is an extra hidden field that humans never fill in,
// but bots often do. If this field is present and non-empty,
// we treat it as spam and quietly redirect away.
if (form.get("_its-a-trap!")) {
// Redirect back to the homepage. We build the URL from the current request
// so it works correctly across environments (preview, production, etc.).
const hpUrl = new URL("/", request.url);
return Response.redirect(hpUrl.toString(), 303);
}
// 3. Extract and normalize form fields
// ------------------------------------------------
// We pull out the core fields we care about:
// - name
// - email
// - message
// - _redirect (optional; where to send the user after success)
//
// Every value is coerced to string and trimmed to avoid
// issues with null/undefined and accidental leading/trailing spaces.
const name = (form.get("name") || "").toString().trim();
const email = (form.get("email") || "").toString().trim();
const message = (form.get("message") || "").toString().trim();
const redirectUrl = (
form.get("_redirect") || "/contact/thanks/"
).toString();
// Basic required-field validation. If any are empty,
// respond with a 400 Bad Request and a JSON error.
if (!name || !email || !message) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { "content-type": "application/json" },
},
);
}
// 4. Length limits (simple validation / abuse mitigation)
// ------------------------------------------------
// These caps are deliberately generous but help avoid:
// - unrealistic payloads
// - abuse where the form is used as a data pipe
if (name.length > 200 || email.length > 320 || message.length > 5000) {
return new Response(JSON.stringify({ error: "Invalid field lengths" }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
// 5. CAPTCHA verification (reCAPTCHA v3 or hCaptcha-compatible)
// ------------------------------------------------
// The form is expected to include a CAPTCHA token:
// - reCAPTCHA v3: `g-recaptcha-response`
// - (optionally) hCaptcha-style: `h-captcha-response`
//
// If no token is present, we immediately reject the submission.
const recaptchaToken =
form.get("g-recaptcha-response") || form.get("h-captcha-response");
if (!recaptchaToken) {
return new Response(
JSON.stringify({ error: "reCAPTCHA token is required" }),
{
status: 400,
headers: { "content-type": "application/json" },
},
);
}
// Select the secret key from Cloudflare environment variables.
// This must be configured in the project/environment:
// - RECAPTCHA_SECRET_KEY or
// - RECAPTCHA_SECRET
const secret = env.RECAPTCHA_SECRET_KEY || env.RECAPTCHA_SECRET;
if (!secret) {
// If the secret is missing, that's a server misconfiguration,
// so we return a 500 Internal Server Error.
return new Response(
JSON.stringify({
error:
"Server not configured for CAPTCHA (missing RECAPTCHA_SECRET_KEY/RECAPTCHA_SECRET)",
}),
{ status: 500, headers: { "content-type": "application/json" } },
);
}
// For better fraud detection, we also send the user's IP to reCAPTCHA.
// Cloudflare exposes client IP via:
// - CF-Connecting-IP header
// - request.cf.clientAddress
const remoteIp =
request.headers.get("CF-Connecting-IP") ||
request.cf?.clientAddress ||
null;
// Build the request body for the reCAPTCHA verification endpoint.
// It expects application/x-www-form-urlencoded payload.
const verifyParams = new URLSearchParams({
secret,
response: recaptchaToken.toString(),
});
if (remoteIp) {
verifyParams.append("remoteip", remoteIp);
}
// POST the verification request to Google's reCAPTCHA API.
const verifyResp = await fetch(
"https://www.google.com/recaptcha/api/siteverify",
{
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: verifyParams,
},
);
// If Google's endpoint itself is failing (network issue or 5xx),
// treat this as a bad gateway (502) and surface a generic error.
if (!verifyResp.ok) {
return new Response(
JSON.stringify({ error: "Failed to verify reCAPTCHA" }),
{
status: 502,
headers: { "content-type": "application/json" },
},
);
}
// Parse the JSON response from reCAPTCHA.
// Example structure:
// {
// success: true/false,
// score: 0.0-1.0,
// hostname: "example.com",
// "error-codes": [...]
// }
const verifyJson = await verifyResp.json();
// 5a. Hard failure: verification not successful at all
// ------------------------------------------------
// We return a 400 with details about why it failed where possible.
if (!verifyJson.success) {
const errorCodes = verifyJson["error-codes"] || [];
return new Response(
JSON.stringify({
error: "CAPTCHA verification failed",
details:
errorCodes.length > 0 ? errorCodes.join(", ") : "Unknown error",
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
// 5b. Soft failure: low reCAPTCHA v3 score
// ------------------------------------------------
// reCAPTCHA v3 uses scores instead of explicit "I'm not a robot" prompts.
// Lower values are more suspicious. Here we reject anything below 0.3.
// This threshold is a trade-off between blocking bots and not annoying users.
if (typeof verifyJson.score === "number" && verifyJson.score < 0.3) {
return new Response(
JSON.stringify({
error: "CAPTCHA verification failed",
details: `Score too low: ${verifyJson.score}`,
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
// 5c. Optional hostname verification
// ------------------------------------------------
// The reCAPTCHA response includes a `hostname` field.
// We check that the hostname reCAPTCHA saw matches the hostname of our request.
// This helps prevent token reuse on other domains.
const requestHost = new URL(request.url).hostname;
if (
verifyJson.hostname &&
verifyJson.hostname !== requestHost &&
!requestHost.endsWith(`.${verifyJson.hostname}`)
) {
// We allow subdomains (e.g. www.example.com vs example.com),
// but log any unexpected mismatch to server logs for later inspection.
// This log will show up in Cloudflare function logs.
console.warn(
`reCAPTCHA hostname mismatch: expected ${requestHost}, got ${verifyJson.hostname}`,
);
}
// 6. Prepare email content for Resend
// ------------------------------------------------
// At this point, the submission has passed:
// - basic validation
// - anti-spam honeypot
// - CAPTCHA checks
//
// Now we build an email to send to the site owner via Resend.
// Fixed "from" address used with Resend.
// This should be a verified sender domain for your Resend account.
const fromEmail = "contact@example.com";
// Allow the subject to be overridden via a hidden form field `_email.subject`,
// otherwise fall back to a sensible default.
const subject =
form.get("_email.subject")?.toString() || "New Message from Nooshu.com";
// Plain-text body, which is useful for mail clients that don't render HTML, note the use of JavaScript Template literals.
const textBody = `New contact form submission on nooshu.com
Name: ${name}
Email: ${email}
Message:
${message}`;
// HTML body with simple markup for better readability.
// We escape all user-supplied fields to avoid injecting HTML or scripts.
const htmlBody = `<h2>New contact form submission on nooshu.com</h2><p><strong>Name:</strong> ${escapeHtml(
name,
)}<br/><strong>Email:</strong> ${escapeHtml(email)}</p><p><strong>Message:</strong></p><pre style="white-space:pre-wrap">${escapeHtml(
message,
)}</pre>`;
// Pull the Resend API key from Cloudflare environment variables.
// This must be configured as a secret in the Pages project.
const apiKey = env.RESEND_API_KEY;
if (!apiKey) {
return new Response(
JSON.stringify({
error: "Server not configured for email (missing RESEND_API_KEY)",
}),
{ status: 500, headers: { "content-type": "application/json" } },
);
}
// 7. Call Resend's email API
// ------------------------------------------------
// We issue a POST request to Resend's /emails endpoint with:
// - from: sender (must be a verified domain)
// - to: recipient(s) (in this case, the site inbox)
// - reply_to: the visitor's email address, so "Reply" in your mail client goes to them
// - subject, text, html: the message content
const resendResp = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"content-type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
from: `Nooshu Contact <${fromEmail}>`,
to: ["website@nooshu.com"],
reply_to: email,
subject,
text: textBody,
html: htmlBody,
}),
});
// If Resend returns a non-2xx status, we surface a 502 to the client
// and include the raw error body for easier debugging.
if (!resendResp.ok) {
const text = await resendResp.text();
return new Response(
JSON.stringify({ error: "Email send failed", details: text }),
{
status: 502,
headers: { "content-type": "application/json" },
},
);
}
// 8. Final redirect on success
// ------------------------------------------------
// If everything above succeeds, we redirect the user to a "thank you"
// page (configurable via the `_redirect` field).
//
// We resolve `redirectUrl` relative to the current request URL
// to avoid hard-coding the origin.
const finalRedirect = new URL(redirectUrl, request.url);
// 303 See Other is the canonical way to redirect after a POST,
// telling the browser to perform a GET to the new URL.
return Response.redirect(finalRedirect.toString(), 303);
} catch (err) {
// 9. Global error handler
// ------------------------------------------------
// If anything unexpected blows up (network issues, runtime errors, etc.),
// we catch it here and return a generic 500 JSON response.
//
// The error is stringified so it's at least inspectable in logs / responses,
// but in a real-world scenario you might want to avoid leaking details
// to the client and instead only log them server-side.
return new Response(
JSON.stringify({ error: "Server error", details: String(err) }),
{
status: 500,
headers: { "content-type": "application/json" },
},
);
}
};A version with fewer comments is available in this gist on GitHub.
Summary
Well, that brings us to the end of another blog post, I only went off on a tangent a couple of times! So well done for sticking with my ramblings!
In the end, the goal was to keep the architecture simple while still covering the essentials. The site itself remains fully static, with a lightweight Cloudflare Worker handling form submissions. Spam protection is provided through Google reCAPTCHA v3, while validation and accessibility patterns ensure the form behaves reliably for real users. It is a small piece of functionality, but implemented carefully it can be both robust and easy to maintain.
As always, thanks for reading, and I’d love to hear your feedback. You are welcome to contact me either via the contact form (again, very meta! 😏), or via any of the various social channels listed on the site.
Post changelog:
- 09/03/26: Initial post published.