On this page
Serverless functions let you add a small piece of backend code to a static site without managing a server. A form handler is one of the most common first projects. In this guide you'll build one with Cloudflare Workers — the same ideas apply to Vercel and Netlify Functions — and see what it takes to make it production-ready.
What a form backend actually has to do
- Accept a POST request with form data (URL-encoded, multipart or JSON).
- Validate the data.
- Block spam: honeypot, CAPTCHA verification, rate limiting.
- Deliver it: send an email notification, store it, or both.
- Respond: redirect to a thank-you page, or return JSON for JavaScript clients.
- Handle errors and CORS properly.
Step 1: The HTML form
<form action="https://forms.example.workers.dev/submit" method="POST">
<input type="text" name="company_website" class="hp" tabindex="-1" autocomplete="off">
<input name="name" required aria-label="Name">
<input name="email" type="email" required aria-label="Email">
<textarea name="message" required aria-label="Message"></textarea>
<div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>.hp { display: none; }</style>
Step 2: The Worker
// src/index.js — Cloudflare Worker
const ALLOWED_ORIGIN = 'https://yoursite.com';
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === 'OPTIONS') return cors(new Response(null, { status: 204 }));
if (url.pathname !== '/submit' || request.method !== 'POST') {
return new Response('Not found', { status: 404 });
}
// 1. Parse JSON or form data
const type = request.headers.get('content-type') || '';
const data = type.includes('application/json')
? await request.json()
: Object.fromEntries(await request.formData());
const wantsJson = (request.headers.get('accept') || '').includes('application/json');
const reply = (ok, message, status) => wantsJson
? cors(Response.json({ success: ok, message: message }, { status: status }))
: Response.redirect(ALLOWED_ORIGIN + (ok ? '/thanks/' : '/contact/?error=1'), 303);
// 2. Honeypot — pretend success so bots learn nothing
if (data.company_website) return reply(true, 'Thanks!', 200);
// 3. Validate
const email = String(data.email || '').trim();
const message = String(data.message || '').trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || message.length < 10) {
return reply(false, 'Please enter a valid email and a longer message.', 400);
}
// 4. Verify Cloudflare Turnstile
const check = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: new URLSearchParams({
secret: env.TURNSTILE_SECRET,
response: String(data['cf-turnstile-response'] || ''),
remoteip: request.headers.get('cf-connecting-ip') || '',
}),
}).then((r) => r.json());
if (!check.success) return reply(false, 'Spam check failed.', 400);
// 5. Deliver (store + notify) — see step 3
await env.DB.prepare(
'INSERT INTO submissions (name, email, message, created_at) VALUES (?, ?, ?, ?)'
).bind(String(data.name || ''), email, message, new Date().toISOString()).run();
await notify(env, { name: data.name, email: email, message: message });
return reply(true, 'Thanks! We will be in touch.', 200);
},
};
function cors(response) {
response.headers.set('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
response.headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Accept');
return response;
}
Secrets like TURNSTILE_SECRET are set with wrangler secret put, and DB is a D1 database binding configured in your Wrangler config.
Step 3: Sending the email notification
Serverless platforms don't send email by themselves. Use a transactional email provider's HTTP API (Resend, Postmark, SendGrid, Amazon SES, Cloudflare Email and others). The pattern is the same for all of them:
async function notify(env, submission) {
await fetch('https://api.your-email-provider.com/send', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + env.EMAIL_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Website <forms@yoursite.com>',
to: 'you@yoursite.com',
reply_to: submission.email,
subject: 'New message from ' + submission.name,
text: submission.message,
}),
});
}
To make sure notifications don't land in spam, send from your own domain and set up SPF, DKIM and DMARC records as your email provider instructs.
Step 4: Make it production-ready
- Rate limiting — limit submissions per IP (Cloudflare has rate-limiting rules and bindings; on other platforms use a KV store or Redis).
- Size limits — reject oversized bodies before parsing.
- Escaping — if you send HTML emails or show submissions in a dashboard, escape user input to prevent injection.
- Parameterised queries — always use
bind()/placeholders, never string concatenation, to prevent SQL injection. - Retries — if the email API fails, the submission is still stored; add a queue to retry notifications.
- Monitoring — alert when errors spike, or you'll find out from an angry customer.
- Privacy — decide how long you keep data and how people can ask for deletion.
Vercel and Netlify versions
The logic is identical — only the function signature changes:
// Vercel: api/submit.js | Netlify: netlify/functions/submit.mjs
export default async function handler(request) {
const data = Object.fromEntries(await request.formData());
// …same validation, spam checks, storage and email as above…
return Response.json({ success: true });
}
Build it or use a service?
| Your serverless backend | Hosted form backend | |
|---|---|---|
| Setup | Hours to days | Minutes |
| Control | Total | Configuration and integrations |
| Spam, email deliverability, storage | You build and maintain | Included |
| Dashboard for non-developers | You build it | Included |
| Cost | Platform + email provider + your time | Free tier, then a plan |
Building your own is a great learning project and the right choice for unusual processing or strict data-residency rules. For most sites, a hosted backend gets you further, faster: with Formclick you point the form at https://api.formclick.io/submit, and spam filtering, email, a dashboard and integrations — including direct delivery to your own PostgreSQL, MySQL, MongoDB, Supabase or Neon database — are already done.
Compare approaches in How to Handle Forms on Static Websites and Webhooks vs REST APIs for Form Submissions.



