Zurück zum Blog

Ein serverloses Kontaktformular mit Cloudflare Workers und D1

Ein serverloses Kontaktformular mit Cloudflare Workers und D1

7. Oktober 2026 — Cloudflare

Veröffentlicht

7. Oktober 2026

Lesezeit

2 Min. Lesezeit

Kategorie

Cloudflare

Schlagwörter

  • Cloudflare Workers
  • D1
  • Serverless
  • Forms
  • Turnstile

(Intro)So bekommt eine statische Website ein funktionierendes Kontaktformular – mit einem Cloudflare Worker, einer D1-Datenbank, Spamschutz per Turnstile und sofortiger Benachrichtigung. Im kostenlosen Tarif, mit vollständigem Code.

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

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

1. Projekt und Datenbank anlegen

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

Der letzte Befehl gibt einen Binding-Block aus. Fügen Sie ihn in Ihre Wrangler-Konfiguration ein:

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. Eine Migration für die Tabelle

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. Der 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
}

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

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 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:

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 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

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"

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.

Newsletter

Neue Beiträge per E-Mail

Eine kurze E-Mail, wenn es etwas Neues gibt. Kein Spam, jederzeit abbestellbar.

Core Web Vitals für animierte Websites: LCP, INP und CLS bestehen
Core Web Vitals für animierte Websites: LCP, INP und CLS bestehen
Nächster Beitrag