Adding a Contact Form with Cloudflare Workers

A contact form is the smallest piece of server code a static site needs, and a good first function to own. On Cloudflare Pages or Workers with static assets, it is one file under functions/, deployed with the site in the same commit: it validates the submission, checks a Turnstile token to stop bots, rate-limits by IP, writes the message to a D1 database, emails the team and redirects the reader to a thank-you page. The page stays static; the form works without JavaScript; nothing leaves your Cloudflare account except the notification email.

This guide builds that handler for a 700-page Astro documentation site and reports four weeks of results. The comparison with other approaches is in Handling Form Submissions on a Static Site; the wider context is Serverless Functions for Static Sites.

Prerequisites

  • A site on Cloudflare Pages (or Workers with static assets) deployed with Wrangler.
  • A Turnstile widget created in the Cloudflare dashboard (site key and secret key).
  • A D1 database, and an email provider's API key or Email Routing set up for the destination address.

Step 1: The Form and Thank-You Page

<!-- src/pages/contact.astro (excerpt) -->
<form method="post" action="/api/contact" id="contact">
  <label for="name">Name</label>
  <input id="name" name="name" required maxlength="100" autocomplete="name">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required maxlength="200" autocomplete="email">
  <label for="message">Message</label>
  <textarea id="message" name="message" required maxlength="5000" rows="6"></textarea>
  <div hidden><label>Website <input name="website" tabindex="-1" autocomplete="off"></label></div>
  <input type="hidden" name="ts" id="ts">
  <div class="cf-turnstile" data-sitekey="0x4AAAAAAA…" data-appearance="interaction-only"></div>
  <button type="submit">Send</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script is:inline>document.getElementById('ts').value = Date.now();</script>

The Turnstile script loads asynchronously and only on the contact page, so other pages carry no cost. data-appearance="interaction-only" keeps the widget invisible unless Cloudflare decides a visitor needs an interactive check. The thank-you page is a normal static page at /contact/thanks/.

Step 2: The Function

// functions/api/contact.js
const LIMIT = { name: 100, email: 200, message: 5000 };
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

export async function onRequestPost({ request, env }) {
  const f = await request.formData();
  const get = (k) => (f.get(k) ?? '').toString().trim();
  const ip = request.headers.get('cf-connecting-ip') ?? '';

  // 1. cheap bot checks
  if (get('website')) return redirect('/contact/thanks/');           // honeypot: pretend success
  if (Date.now() - Number(get('ts')) < 3000) return redirect('/contact/thanks/');

  // 2. validation
  const data = { name: get('name'), email: get('email'), message: get('message') };
  for (const [k, max] of Object.entries(LIMIT)) if (!data[k] || data[k].length > max) return fail(`Please check the ${k} field.`);
  if (!EMAIL.test(data.email)) return fail('Please enter a valid email address.');

  // 3. rate limit: 5 per IP per hour
  const { success } = await env.CONTACT_LIMITER.limit({ key: ip });
  if (!success) return fail('Too many messages from this address. Please try again later.', 429);

  // 4. Turnstile
  const ts = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
    method: 'POST',
    body: new URLSearchParams({ secret: env.TURNSTILE_SECRET, response: get('cf-turnstile-response'), remoteip: ip }),
  }).then((r) => r.json());
  if (!ts.success) return fail('Verification failed. Please try again.');

  // 5. store first, then notify
  const id = crypto.randomUUID();
  await env.DB.prepare('INSERT INTO messages (id, name, email, message, created) VALUES (?, ?, ?, ?, ?)')
    .bind(id, data.name, data.email, data.message, new Date().toISOString()).run();
  if (env.ENVIRONMENT === 'production') {
    await fetch('https://api.email-provider.example/v1/send', {
      method: 'POST',
      headers: { authorization: `Bearer ${env.EMAIL_KEY}`, 'content-type': 'application/json' },
      body: JSON.stringify({ to: 'docs-team@example.com', reply_to: data.email,
        subject: `Contact: ${data.name}`, text: `${data.message}\n\n— ${data.name} <${data.email}>\nID ${id}` }),
      signal: AbortSignal.timeout(5000),
    }).catch(() => {});                                                 // stored already; email is best effort
  }
  return redirect('/contact/thanks/');
}

const redirect = (to) => new Response(null, { status: 303, headers: { location: to } });
const fail = (msg, status = 400) => new Response(
  `<!doctype html><title>Message not sent</title><p>${msg}</p><p><a href="/contact/">Back to the form</a></p>`,
  { status, headers: { 'content-type': 'text/html; charset=utf-8' } });

The order of checks runs from cheapest to most expensive: the honeypot and timing checks cost nothing, validation costs microseconds, the rate limiter a lookup, and Turnstile a network call. Bots that fail early never reach the database or the email provider. Honeypot and timing failures redirect to the thank-you page rather than returning an error, so bots learn nothing.

Checks in the contact function, cheapest first A submission passes through five stages: honeypot and timing, field validation, per-IP rate limit, Turnstile verification, then storage in D1 and an email notification. Failures at the first stage silently redirect to the thank-you page; failures at later stages return an error page. A successful submission redirects with a 303 to the thank-you page. Reject early, spend nothing on bots honeypot + 3 s timing validate lengths, email rate limit 5 / IP / hour Turnstile siteverify D1 + email store, then notify fake success 400 page 429 page 400 page 303 → /thanks/ Cost rises left to right: nothing, microseconds, a lookup, a network call, a write and an email
Storing before emailing means a failed notification never loses a message.

Step 3: Configuration and Secrets

# wrangler.toml
name = "docs-site"
pages_build_output_dir = "dist"

[[d1_databases]]
binding = "DB"
database_name = "docs-contact"
database_id = "…"

[[unsafe.bindings]]
name = "CONTACT_LIMITER"
type = "ratelimit"
namespace_id = "1001"
simple = { limit = 5, period = 3600 }

[vars]
ENVIRONMENT = "production"

[env.preview.vars]
ENVIRONMENT = "preview"
npx wrangler d1 execute docs-contact --remote --command \
  "CREATE TABLE messages (id TEXT PRIMARY KEY, name TEXT, email TEXT, message TEXT, created TEXT)"
npx wrangler pages secret put TURNSTILE_SECRET
npx wrangler pages secret put EMAIL_KEY

Secrets never appear in the repository or the build output. The ENVIRONMENT variable keeps preview deploys from emailing the team: previews still exercise the whole function, including D1 writes to a preview database, but skip the notification.

Step 4: Enhance, Do Not Depend

With the server side complete, a few lines of script can improve the experience without making JavaScript a requirement. Intercept the submit event, send the same FormData with fetch, and on a redirect response show an inline confirmation instead of navigating; on an error response, insert the server's message next to the form and move focus to it. If the script fails to load or throws, the browser simply performs the ordinary form post, and the reader still reaches the thank-you page.

const form = document.getElementById('contact');
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const res = await fetch(form.action, { method: 'POST', body: new FormData(form), redirect: 'manual' });
  const box = document.getElementById('form-status');
  box.textContent = res.type === 'opaqueredirect' || res.ok ? 'Thanks — we reply within two working days.' : 'Something went wrong; please check the fields and try again.';
  box.focus();
});
The same submission with and without JavaScript Without JavaScript, the browser posts the form, receives a 303 redirect and loads the thank-you page. With JavaScript, a fetch posts the same data to the same endpoint, sees the redirect response and shows an inline message in a live region. Both paths reach the same function and the same outcome. One endpoint, two ways to reach it No JS form POST /api/contact → 303 /contact/thanks/ page With JS fetch(FormData) /api/contact → 303 inline live-region message The server never needs to know which path the reader took
Enhancement changes only what the reader sees after a successful post, never whether the post can happen.

The status element should be <p id="form-status" tabindex="-1" role="status"> so screen readers announce the result and focus lands on it.

Step 5: Retention and Review

A contact form accumulates personal data. A scheduled Worker (cron trigger) deletes messages older than 180 days:

export default {
  async scheduled(_, env) {
    await env.DB.prepare("DELETE FROM messages WHERE created < datetime('now', '-180 days')").run();
  },
};

State the retention period under the form. For the team, a small protected page — behind Cloudflare Access, as in Protecting a Static Site Behind Authentication — can list recent messages straight from D1 for when an email goes astray.

Measured Impact

Four weeks after replacing an embedded form widget with this function:

MeasureEmbedded widgetWorker function
JavaScript on contact page148 KB11 KB (Turnstile, async)
Contact page LCP (mobile, Lighthouse)2.7 s1.2 s
CLS0.190.00
Legitimate submissions4963
Spam reaching the team52
Function latency p50 / p9938 ms / 190 ms (incl. Turnstile verify)
Monthly cost29 USD (widget plan)0 USD (free tiers)
Where the function's time goes A stacked bar of a median 38 millisecond submission: 1 millisecond of validation, 2 milliseconds for the rate limiter, 21 milliseconds for Turnstile verification, 9 milliseconds for the D1 write, and 5 milliseconds for dispatching the email request. Median submission: 38 ms end to end Turnstile verify · 21 ms D1 write · 9 ms email 5 validate 1 · limit 2 Worker timing via performance.now() spans, logged to Workers Analytics Engine, 4 weeks
Turnstile verification dominates; everything the site itself does takes about 17 ms.

Pitfalls & Rollback

  • Returning errors for honeypot hits. Bots adapt to error messages; pretend success instead.
  • Emailing before storing. An email API outage then loses messages. Store first.
  • No timeout on the email call. A slow provider would hold the reader on a spinning submit; set a timeout and treat email as best effort.
  • Real emails from previews. Gate notifications on an environment variable.
  • Echoing input unescaped. The error page above interpolates only fixed messages; if you ever echo the reader's input back, escape it, or the form becomes a reflected-XSS vector.
  • Trusting the reply-to address. Anyone can type any email. Never auto-send confirmations to it without rate limits, or the form becomes a way to email third parties through your domain.
  • Rollback: point the form's action back at the previous handler, or delete the function file; the static site is unaffected either way.

Conclusion

A contact form on Cloudflare is one function file, a D1 table, a rate-limit binding and a Turnstile widget — all deployed with the site and all within free tiers at typical volumes. Ordering the checks from cheapest to most expensive keeps bots from costing anything, storing before emailing keeps messages safe, and a retention job keeps the data from piling up. Replacing an embedded widget with it halved the contact page's LCP, removed its layout shift and cut spam to two messages in four weeks.

FAQ

Does the form work without JavaScript?

Yes. It is a normal HTML form that posts to the function and receives a redirect to a thank-you page. JavaScript only adds the Turnstile widget and an inline success message; without it, the server skips nothing except the inline message, and Turnstile falls back to a non-interactive check where possible.

How are emails sent from a Worker?

Through an email API over HTTPS, or Cloudflare's Email Workers send binding for addresses verified in Email Routing. A Worker cannot open SMTP connections on port 25, so a transactional email provider's HTTP API is the common choice.

Why store submissions in D1 as well as emailing them?

Email can fail or be filtered. Writing each submission to a database first means nothing is lost if the email step fails, and it gives you a place to apply a retention policy.

How much does this cost?

For a typical contact form, nothing beyond the free tiers: Workers requests, D1 storage and Turnstile are all free at low volume. The email provider may have its own free allowance.