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
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
npm create cloudflare@latest contact-worker -- --type=hello-world --lang=ts
cd contact-worker
npx wrangler d1 create contact-dbThe last command prints a binding block. Add it to your Wrangler config:
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
npx wrangler d1 migrations create contact-db initCREATE 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);npx wrangler d1 migrations apply contact-db --local # for wrangler dev
npx wrangler d1 migrations apply contact-db --remote # production3. The Worker
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
npx wrangler secret put TURNSTILE_SECRET
npx wrangler secret put TELEGRAM_TOKEN
npx wrangler secret put TELEGRAM_CHAT_ID
npx wrangler deployALLOWED_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:
<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>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
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.

