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
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
npm create cloudflare@latest contact-worker -- --type=hello-world --lang=ts
cd contact-worker
npx wrangler d1 create contact-dbLa dernière commande affiche un bloc de liaison (binding). Ajoutez-le à votre configuration Wrangler :
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
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. Le 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
}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
npx wrangler secret put TURNSTILE_SECRET
npx wrangler secret put TELEGRAM_TOKEN
npx wrangler secret put TELEGRAM_CHAT_ID
npx wrangler deployALLOWED_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 :
<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
})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
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.

