Formclick

How to Build a Serverless Form Backend

Build your own serverless form backend with Cloudflare Workers, Vercel or Netlify Functions — validation, honeypot, Turnstile, email delivery and storage — and know when a hosted service is the better choice.

FFormclick Team4 min read
Close-up of a modern server unit in a blue-lit data center environment.
Photo by panumas nikhomkhai on Pexels
On this page
  1. What a form backend actually has to do
  2. Step 1: The HTML form
  3. Step 2: The Worker
  4. Step 3: Sending the email notification
  5. Step 4: Make it production-ready
  6. Vercel and Netlify versions
  7. Build it or use a service?

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

  1. Accept a POST request with form data (URL-encoded, multipart or JSON).
  2. Validate the data.
  3. Block spam: honeypot, CAPTCHA verification, rate limiting.
  4. Deliver it: send an email notification, store it, or both.
  5. Respond: redirect to a thank-you page, or return JSON for JavaScript clients.
  6. 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.

Found this useful? Share it.

All articles