Every website eventually needs a contact form. And most developers eventually reach for FormSubmit — it is free, it takes five minutes, and it just works. Until it doesn’t.
This is the story of how we replaced FormSubmit on w3it.com with a proper Cloudflare stack: a Worker that validates a Turnstile token, saves every submission to a D1 database, and exposes a private admin view. No third-party dependencies. No ads. No lost leads. And still completely free.
The Problem With FormSubmit
FormSubmit is genuinely useful for getting a form working fast. You point your form action at their endpoint, add a few hidden fields, and emails arrive. But there are real trade-offs.
Spam gets through. Their honeypot helps with basic bots, but disabling their CAPTCHA — which most developers do for UX reasons — leaves the door open. We were already seeing junk submissions before this rebuild.
They include an advert in notification emails. Not ideal when you are forwarding those to a client or using them as CRM input.
You own nothing. There is no database, no history, no way to query past submissions. If an email gets buried or filtered, that lead is gone.
No confirmation email to the submitter. A small thing, but it matters for credibility.
The replacement stack addresses all of this, and because it runs entirely on Cloudflare infrastructure, it fits neatly alongside a site already hosted on Cloudflare Pages.
The Stack
- Cloudflare Turnstile — bot protection that does not make users click buses
- Cloudflare Worker — handles POST requests, validates the Turnstile token, inserts to D1
- Cloudflare D1 — SQLite at the edge, stores every submission permanently
- Astro (static) — forms submit via
fetch, no page reload, redirect to/thank-youon success
The site is a static Astro build deployed to Cloudflare Pages. Because it is fully static, there is no server-side code — so the Worker acts as the form backend.
Step 1: Create the D1 Database
In the Cloudflare dashboard, go to Workers & Pages → D1 → Create database. Name it w3it-contacts and choose a region close to your users.
Then create the contacts table. You can run SQL directly in the D1 query console:
CREATE TABLE contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
form_type TEXT NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
business TEXT,
interest TEXT,
message TEXT,
website TEXT,
company_size TEXT,
concern TEXT,
urgency TEXT,
budget TEXT,
business_type TEXT,
ip_address TEXT,
user_agent TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
The schema covers fields from two forms — a general contact form and a longer security check application form. Columns that do not apply to a given form just stay null.
Step 2: Register a Turnstile Widget
In the Cloudflare dashboard go to Turnstile → Add widget. Enter your domain (w3it.com), choose Managed mode (Cloudflare decides when to challenge), and save. You will get two keys:
- Site key — goes in your HTML (public, safe to expose)
- Secret key — used server-side to verify tokens (keep this private)
Step 3: Write the Worker
Create a new Worker called w3it-forms. The Worker does three things: validates the Turnstile token, inserts the submission to D1, and serves a private admin view.
const ALLOWED_ORIGINS = ['https://w3it.com', 'https://www.w3it.com'];
export default {
async fetch(request, env) {
const origin = request.headers.get('Origin') || '';
const corsHeaders = {
'Access-Control-Allow-Origin': ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const url = new URL(request.url);
if (url.pathname === '/admin') {
return handleAdmin(request, url, env);
}
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
if (url.pathname !== '/submit') {
return new Response('Not found', { status: 404 });
}
// Parse the form data
const data = await request.formData();
// Validate the Turnstile token before doing anything else
const token = data.get('cf-turnstile-response');
if (!token) {
return jsonResponse({ success: false, error: 'Missing security token' }, 400, corsHeaders);
}
const ip = request.headers.get('CF-Connecting-IP') || '';
const turnstileValid = await verifyTurnstile(token, ip, env.TURNSTILE_SECRET);
if (!turnstileValid) {
return jsonResponse({ success: false, error: 'Security check failed' }, 403, corsHeaders);
}
// All clear — save to D1
await env.DB.prepare(
`INSERT INTO contacts (form_type, name, email, business, ...)
VALUES (?, ?, ?, ?, ...)`
).bind(/* field values */).run();
return jsonResponse({ success: true }, 200, corsHeaders);
},
};
Turnstile server-side verification
This is the critical part that makes Turnstile actually effective. Without server-side verification, a bot could skip the widget entirely and POST directly to your endpoint. The verification call confirms the token was genuinely issued by Cloudflare for a real browser interaction:
async function verifyTurnstile(token, ip, secret) {
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ secret, response: token, remoteip: ip }),
});
const result = await res.json();
return result.success === true;
}
The remoteip parameter is optional but recommended — it gives Cloudflare more signal to evaluate the request.
The admin endpoint
Rather than wiring up email notifications (which requires a third-party sending service), we added a password-protected /admin route directly to the Worker. It queries D1 and returns a clean HTML table:
async function handleAdmin(request, url, env) {
const key = url.searchParams.get('key');
if (!key || key !== env.ADMIN_KEY) {
return new Response('Unauthorised', { status: 401 });
}
const { results } = await env.DB
.prepare('SELECT * FROM contacts ORDER BY created_at DESC LIMIT 100')
.all();
// Build and return an HTML table...
}
Visit https://your-worker.workers.dev/admin?key=YOURPASSWORD and you get a table of every submission, filterable by form type.
Step 4: Update the Astro Forms
The old FormSubmit form looked like this:
<form action="https://formsubmit.co/YOUR_HASH" method="POST">
<input type="hidden" name="_subject" value="New enquiry" />
<input type="hidden" name="_captcha" value="false" />
<input type="hidden" name="_next" value="https://yoursite.com/thank-you" />
<input type="text" name="_honey" style="display:none" />
<!-- fields -->
</form>
The new version removes all of that and uses fetch instead:
<form id="contact-form" data-worker="https://your-worker.workers.dev/submit">
<input type="hidden" name="form-type" value="contact" />
<!-- your fields -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-theme="light"></div>
<button type="submit">Send Message</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
const form = document.getElementById('contact-form');
const submitBtn = form.querySelector('[type="submit"]');
form.addEventListener('submit', async (e) => {
e.preventDefault();
submitBtn.disabled = true;
submitBtn.textContent = 'Sending…';
try {
const res = await fetch(form.dataset.worker, {
method: 'POST',
body: new FormData(form),
});
const json = await res.json();
if (json.success) {
window.location.href = '/thank-you';
} else {
throw new Error(json.error);
}
} catch {
// show error state
submitBtn.disabled = false;
submitBtn.textContent = 'Send Message';
}
});
</script>
A few things worth noting:
- The Turnstile widget is just a
<div>— the JS snippet from Cloudflare renders it automatically when the page loads new FormData(form)picks up the hiddencf-turnstile-responsefield that Turnstile injects automatically — no extra code needed- The Worker URL is stored in
data-workerso it is easy to change without touching the JS
Step 5: Worker Secrets and Bindings
In the Worker settings, add:
| Type | Name | Value |
|---|---|---|
| D1 Binding | DB | w3it-contacts |
| Secret | TURNSTILE_SECRET | Your Turnstile secret key |
| Secret | ADMIN_KEY | A strong password for the admin view |
The D1 binding makes env.DB available in the Worker. The secrets are never exposed in the Worker code — only referenced via env.TURNSTILE_SECRET and env.ADMIN_KEY.
What We Gained
| FormSubmit | Cloudflare Stack | |
|---|---|---|
| Spam protection | Honeypot only | Turnstile (server-verified) |
| Data storage | None | D1 — permanent, queryable |
| Notification email | Yes (with ad) | Admin view (no email dependency) |
| Control | None | Full |
| Cost | Free | Free |
Every submission now lands in a database we own. The admin panel gives an instant view of contacts filtered by form type. And because Turnstile verification happens server-side before anything is written to D1, spam never reaches the database in the first place.
CORS: One Detail Worth Getting Right
Because the form is on w3it.com and the Worker is on workers.dev, the browser will make a cross-origin request. Without the correct CORS headers on the Worker response, the fetch call will fail silently in the browser.
The Worker checks the Origin header against an allowlist and reflects the correct value back:
const ALLOWED_ORIGINS = ['https://w3it.com', 'https://www.w3it.com'];
const corsHeaders = {
'Access-Control-Allow-Origin': ALLOWED_ORIGINS.includes(origin)
? origin
: ALLOWED_ORIGINS[0],
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
The OPTIONS preflight also needs to return the CORS headers — browsers send this before the actual POST.
The Result
The contact and security check forms on w3it.com now submit to the Worker, pass Turnstile validation, and save to D1 — all in under 200ms. The full Worker including the admin view is around 150 lines of plain JavaScript with no dependencies.
If you are running an Astro site on Cloudflare Pages and want to do the same, the pieces are all in the free tier. The only thing you need that is not covered here is a sending domain if you want email notifications — Cloudflare Email Routing handles that without needing a third-party service.