Statische Websites sind schnell, günstig und kaum kaputtzukriegen – bis Sie ein Kontaktformular brauchen. Die üblichen Antworten sind ein Formular-SaaS mit Monatsgebühr oder gleich ein ganzes Backend. Es gibt eine schlankere Lösung: einen einzigen Cloudflare Worker, der die Eingaben prüft, sie in einer D1-Datenbank (SQLite) speichert und Ihnen Bescheid gibt. Im kostenlosen Tarif zahlt eine kleine Unternehmenswebsite dafür nie etwas.
Genau dieses Setup steckt hinter dem Anfrageformular auf dieser Website.
Die Architektur
Browser (static page)
└─ POST /api/inquiry ──► Cloudflare Worker
├─ verify Turnstile token
├─ validate fields
├─ INSERT into D1
└─ notify (Telegram / email)1. Projekt und Datenbank anlegen
npm create cloudflare@latest contact-worker -- --type=hello-world --lang=ts
cd contact-worker
npx wrangler d1 create contact-dbDer letzte Befehl gibt einen Binding-Block aus. Fügen Sie ihn in Ihre Wrangler-Konfiguration ein:
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. Eine Migration für die Tabelle
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. Der 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
}Drei Details, auf die es ankommt:
- Immer
prepare().bind()verwenden. Bauen Sie SQL nie per String-Verkettung zusammen; gebundene Parameter machen Injection unmöglich. - CORS ist auf Ihre eigene Origin beschränkt, sodass andere Websites aus dem Browser nicht an Ihren Endpunkt senden können.
- Die Validierung passiert auf dem Server. Validierung im Browser dient dem Komfort der Nutzer; geschützt werden die Daten durch den Worker.
Wenn die Antwort nicht auf die Benachrichtigung warten soll, verwenden Sie ctx.waitUntil(promise) mit dem dritten Argument ctx von fetch.
4. Secrets und Deployment
npx wrangler secret put TURNSTILE_SECRET
npx wrangler secret put TELEGRAM_TOKEN
npx wrangler secret put TELEGRAM_CHAT_ID
npx wrangler deployALLOWED_ORIGIN ist nicht geheim; es gehört unter [vars] in die Wrangler-Konfiguration. Für die lokale Entwicklung kommen Secrets in eine .dev.vars-Datei, die Sie nie committen.
5. Das Formular auf der statischen Website
Binden Sie das Skript des Turnstile-Widgets und einen Container ein und senden Sie das Formular dann als 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 liegt dasselbe in einer Client Component mit useState für den Status – oder mit React Hook Form und Zod, wenn das Formular wächst.
Die Einsendungen lesen
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"Komfortabler wird es mit einer GET-Route im Worker, die einen Header Authorization: Bearer <token> verlangt und die neuesten Einträge als JSON zurückgibt – abgerufen von einer privaten Admin-Seite.
Checkliste zur Absicherung
- Turnstile wird auf dem Server geprüft, nicht nur im Browser angezeigt
- CORS ist auf Ihre Domain beschränkt
- Längenbegrenzung für jedes Feld
- Gebundene Parameter für jede Abfrage
- Eine Rate-Limiting-Regel für die Route im Cloudflare-Dashboard (WAF → Rate limiting rules)
- Secrets in
wrangler secret, nie im Repository - Ein Plan für personenbezogene Daten: wie lange Sie Anfragen aufbewahren und wie Sie eine auf Wunsch löschen
Warum das besser ist als ein Formulardienst
Die Daten gehören Ihnen, bei den Mengen kleiner Unternehmen kostet es nichts, kein Drittanbieter-Skript verfolgt Ihre Besucher, und Sie können es mit ein paar Zeilen TypeScript erweitern – automatische Antworten, ein CRM-Abgleich, ein Buchungsschritt. Es passt hervorragend zu einer statisch exportierten Website; siehe den Leitfaden zum statischen Export mit Next.js.

