Retour au blog

Un formulaire de contact serverless avec Cloudflare Workers et D1

Un formulaire de contact serverless avec Cloudflare Workers et D1

7 octobre 2026 — Cloudflare

Publié le

7 octobre 2026

Temps de lecture

2 min de lecture

Catégorie

Cloudflare

Mots-clés

  • Cloudflare Workers
  • D1
  • Serverless
  • Forms
  • Turnstile

(Intro)Dotez un site statique d’un vrai formulaire de contact grâce à un Cloudflare Worker, une base D1, la protection anti-spam Turnstile et des notifications instantanées. Offre gratuite, code complet.

Les sites statiques sont rapides, peu coûteux et quasi incassables — jusqu’au jour où il vous faut un formulaire de contact. Les réponses habituelles : un service de formulaires facturé au mois, ou carrément un back-end complet. Il existe une option plus légère : un seul Cloudflare Worker qui valide l’envoi, l’enregistre dans une base D1 (SQLite) et vous prévient. Avec l’offre gratuite, un site de petite entreprise ne paiera jamais rien.

C’est la configuration qui fait tourner le formulaire de demande de ce site.

L’architecture

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

1. Créer le projet et la base de données

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

La dernière commande affiche un bloc de liaison (binding). Ajoutez-le à votre configuration Wrangler :

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. Une migration pour la 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. Le 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
}

Trois détails à noter :

  • Utilisez toujours prepare().bind(). Ne construisez jamais de SQL par concaténation de chaînes ; les paramètres liés rendent l’injection impossible.
  • Le CORS est limité à votre propre origine, si bien que d’autres sites ne peuvent pas envoyer de données à votre endpoint depuis un navigateur.
  • La validation se fait côté serveur. La validation côté client sert le confort de l’utilisateur ; c’est le Worker qui protège les données.

Si vous ne voulez pas que la réponse attende la notification, utilisez ctx.waitUntil(promise) avec le troisième argument ctx de fetch.

4. Secrets et déploiement

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 n’est pas secret : placez-le dans [vars] de la configuration Wrangler. En développement local, les secrets vont dans un fichier .dev.vars que vous ne commitez jamais.

5. Le formulaire sur le site statique

Ajoutez le script du widget Turnstile et un conteneur, puis envoyez le formulaire en 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
})

En React, la même logique vit dans un Client Component avec useState pour l’état — ou avec React Hook Form et Zod si le formulaire s’étoffe.

Consulter les envois

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"

Pour quelque chose de plus pratique, ajoutez au Worker une route GET qui exige un en-tête Authorization: Bearer <token> et renvoie les dernières lignes en JSON, à lire depuis une page d’administration privée.

Check-list de sécurisation

  • Turnstile vérifié côté serveur, pas seulement affiché côté client
  • CORS limité à votre domaine
  • Limites de longueur sur chaque champ
  • Paramètres liés pour chaque requête
  • Une règle de limitation de débit sur la route dans le tableau de bord Cloudflare (WAF → Rate limiting rules)
  • Les secrets dans wrangler secret, jamais dans le dépôt
  • Une politique pour les données personnelles : combien de temps vous conservez les demandes, et comment en supprimer une sur demande

Pourquoi c’est mieux qu’un service de formulaires

Les données vous appartiennent, cela ne coûte rien aux volumes d’une petite entreprise, aucun script tiers ne piste vos visiteurs, et vous pouvez l’étendre — réponses automatiques, synchronisation avec un CRM, étape de réservation — en quelques lignes de TypeScript. Il s’associe naturellement à un site exporté en statique ; voir le guide de l’export statique avec Next.js.

Newsletter

Recevez les nouveaux articles par e-mail

Un court e-mail à chaque nouvelle publication. Pas de spam, et désinscription possible à tout moment.

Core Web Vitals pour sites animés : réussir LCP, INP et CLS
Core Web Vitals pour sites animés : réussir LCP, INP et CLS
Article suivant