Back to the blog

A Serverless Contact Form with Cloudflare Workers and D1

A Serverless Contact Form with Cloudflare Workers and D1

7 October 2026 — Cloudflare

Published

7 October 2026

Reading time

2 min read

Category

Cloudflare

Tags

  • Cloudflare Workers
  • D1
  • Serverless
  • Forms
  • Turnstile

(Intro)Give a static website a working contact form with a Cloudflare Worker, a D1 database, Turnstile spam protection and instant notifications. Free tier, full code.

Static sites are fast, cheap and hard to break — until you need a contact form. The usual answers are a form SaaS with a monthly fee or a whole backend. There's a lighter option: a single Cloudflare Worker that validates the submission, stores it in a D1 (SQLite) database and pings you. On the free tier, a small business site will never pay for it.

This is the setup behind the enquiry form on this site.

The architecture

text
Browser (static page)
  └─ POST /api/inquiry  ──►  Cloudflare Worker
                               ├─ verify Turnstile token
                               ├─ validate fields
                               ├─ INSERT into D1
                               └─ notify (Telegram / email)

1. Create the project and the database

bash
npm create cloudflare@latest contact-worker -- --type=hello-world --lang=ts
cd contact-worker
npx wrangler d1 create contact-db

The last command prints a binding block. Add it to your Wrangler config:

wrangler.toml
name = "contact-worker"
main = "src/index.ts"
compatibility_date = "2026-10-01"

[[d1_databases]]
binding = "DB"
database_name = "contact-db"
database_id = "<the id wrangler printed>"

2. A migration for the table

bash
npx wrangler d1 migrations create contact-db init
migrations/0001_init.sql
CREATE TABLE inquiries (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  name       TEXT NOT NULL,
  email      TEXT NOT NULL,
  message    TEXT NOT NULL,
  budget     TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  ip_country TEXT
);
CREATE INDEX idx_inquiries_created ON inquiries (created_at);
bash
npx wrangler d1 migrations apply contact-db --local   # for wrangler dev
npx wrangler d1 migrations apply contact-db --remote  # production

3. The Worker

src/index.ts
interface Env {
  DB: D1Database
  TURNSTILE_SECRET: string
  TELEGRAM_TOKEN?: string
  TELEGRAM_CHAT_ID?: string
  ALLOWED_ORIGIN: string
}

const cors = (origin: string) => ({
  'Access-Control-Allow-Origin': origin,
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type',
})

const json = (body: unknown, status: number, origin: string) =>
  new Response(JSON.stringify(body), {
    status,
    headers: { 'Content-Type': 'application/json', ...cors(origin) },
  })

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const origin = env.ALLOWED_ORIGIN
    // A 204 can't have a body, so the preflight gets its own response
    if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors(origin) })
    if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405, origin)

    let data: Record<string, unknown>
    try {
      data = await request.json()
    } catch {
      return json({ error: 'Invalid JSON' }, 400, origin)
    }

    // 1. Spam check
    const ip = request.headers.get('CF-Connecting-IP') ?? ''
    if (!(await verifyTurnstile(String(data.token ?? ''), ip, env.TURNSTILE_SECRET))) {
      return json({ error: 'Verification failed' }, 403, origin)
    }

    // 2. Validation
    const name = String(data.name ?? '').trim()
    const email = String(data.email ?? '').trim()
    const message = String(data.message ?? '').trim()
    const budget = data.budget ? String(data.budget).slice(0, 50) : null
    if (!name || name.length > 100) return json({ error: 'Name is required' }, 422, origin)
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return json({ error: 'Email is invalid' }, 422, origin)
    if (message.length < 10 || message.length > 5000) return json({ error: 'Message is too short or long' }, 422, origin)

    // 3. Store
    const country = (request as Request & { cf?: { country?: string } }).cf?.country ?? null
    await env.DB.prepare(
      'INSERT INTO inquiries (name, email, message, budget, ip_country) VALUES (?, ?, ?, ?, ?)',
    )
      .bind(name, email, message, budget, country)
      .run()

    // 4. Notify, without making the visitor wait for it
    if (env.TELEGRAM_TOKEN && env.TELEGRAM_CHAT_ID) {
      const text = `New enquiry from ${name} <${email}>\n\n${message.slice(0, 500)}`
      await fetch(`https://api.telegram.org/bot${env.TELEGRAM_TOKEN}/sendMessage`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ chat_id: env.TELEGRAM_CHAT_ID, text }),
      }).catch(() => {})
    }

    return json({ ok: true }, 200, origin)
  },
}

async function verifyTurnstile(token: string, ip: string, secret: string) {
  if (!token) return false
  const body = new FormData()
  body.append('secret', secret)
  body.append('response', token)
  if (ip) body.append('remoteip', ip)
  const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body })
  const outcome = (await res.json()) as { success: boolean }
  return outcome.success
}

Three details worth noticing:

  • Always use prepare().bind(). Never build SQL with string concatenation; bound parameters make injection impossible.
  • CORS is restricted to your own origin, so other sites can't post to your endpoint from a browser.
  • Validation happens on the server. Client-side validation is for the user's convenience; the Worker is what protects the data.

If you'd rather not block the response on the notification, use ctx.waitUntil(promise) with the third ctx argument of fetch.

4. Secrets and deploy

bash
npx wrangler secret put TURNSTILE_SECRET
npx wrangler secret put TELEGRAM_TOKEN
npx wrangler secret put TELEGRAM_CHAT_ID
npx wrangler deploy

ALLOWED_ORIGIN isn't secret; put it in [vars] in the Wrangler config. For local development, secrets go in a .dev.vars file that you never commit.

5. The form on the static site

Add the Turnstile widget script and a container, then post the form as JSON:

html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form id="contact">
  <input name="name" required maxlength="100" />
  <input name="email" type="email" required />
  <textarea name="message" required minlength="10"></textarea>
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
  <button>Send</button>
</form>
js
document.getElementById('contact').addEventListener('submit', async (event) => {
  event.preventDefault()
  const form = new FormData(event.currentTarget)
  const res = await fetch('https://contact-worker.example.workers.dev', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: form.get('name'),
      email: form.get('email'),
      message: form.get('message'),
      token: form.get('cf-turnstile-response'),
    }),
  })
  // show a success or error message based on res.ok
})

In React, the same thing lives in a Client Component with useState for the status — or with React Hook Form and Zod if the form grows.

Reading the submissions

bash
npx wrangler d1 execute contact-db --remote \
  --command "SELECT created_at, name, email, substr(message, 1, 80) FROM inquiries ORDER BY id DESC LIMIT 20"

For something friendlier, add a GET route to the Worker that requires an Authorization: Bearer <token> header and returns the latest rows as JSON, and read it from a private admin page.

Hardening checklist

  • Turnstile verified on the server, not only rendered on the client
  • CORS limited to your domain
  • Length limits on every field
  • Bound parameters for every query
  • A rate-limiting rule on the route in the Cloudflare dashboard (WAF → Rate limiting rules)
  • Secrets in wrangler secret, never in the repository
  • A plan for personal data: how long you keep enquiries, and how to delete one on request

Why this beats a form service

You own the data, it costs nothing at small-business volumes, there's no third-party script tracking your visitors, and you can extend it — auto-replies, a CRM sync, a booking step — with a few lines of TypeScript. It pairs naturally with a statically exported site; see the Next.js static export guide.

Newsletter

Get new posts by email

A short email when something new is up. No spam, and you can unsubscribe at any time.

Core Web Vitals for Animated Websites: Pass LCP, INP and CLS
Core Web Vitals for Animated Websites: Pass LCP, INP and CLS
Next post →