HTML form → Worker → database
A contact or signup form that posts to a Worker, gets validated, and is saved to a D1 database.
What are we wiring up?
This is the most common job in web development: a visitor fills in a form, you save what they typed, and you confirm it worked. We will wire three layers together — a front-end HTML form, a Cloudflare Worker (your server-side code that runs at the edge), and a D1 database (a serverless SQL database) where each submission becomes one row.
Think of it like…
A reception desk. The visitor hands over a paper form (the HTML form). The receptionist checks it is filled in correctly (the Worker validating), then files it in a drawer (D1). If a field is blank, the receptionist hands it back and points at the problem — it never goes in the drawer.
Who does what
Each layer has one clear job. Keeping them separate is what makes the app easy to reason about and secure.
Front-end (the form)
HTML for the fields, plus JS that intercepts submit and sends the data with fetch. It also shows success or error messages.
Worker (the gatekeeper)
Receives the POST, validates every field, and only then writes to the database. This is the one place you can actually trust.
D1 (the storage)
A SQL database that keeps every submission as a row. The Worker talks to it through the env.DB binding.
Turnstile (optional)
A free, puzzle-free bot check. Add it to stop spam from flooding your table.
One origin, two paths
With Workers static assets, the same Worker can serve your HTML page (e.g. /) and handle the API (/api/submit). Because they share an origin, the fetch needs no CORS setup.
The data model
We only need one table: submissions. Each row is one filled-in form. The id auto-increments, and created_at is stamped automatically so you know when each message arrived.
-- schema.sql
CREATE TABLE IF NOT EXISTS submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);Let the database stamp the time
Using DEFAULT (datetime('now')) means you never have to send created_at from the client — the server decides the time, so it cannot be faked.
The request flow
Follow one submission from click to confirmation. Notice that validation happens on the Worker, and the database is only touched when every field is valid.
Status codes are the contract
The Worker answers 201 (created) on success and 400 (bad request) on invalid input. The front-end reads res.ok to decide whether to clear the form or show errors.
Build it step by step
Here is the whole thing: the database and table, the wrangler config, the front-end form, and the Worker that ties it together. Copy each piece into the matching file.
Create the database and table
Save the CREATE TABLE above as schema.sql, then run these. The --remote flag means 'apply to the real cloud database' (leave it off to test locally).
# 1) Create the database (copy the printed database_id) npx wrangler d1 create contact-db # 2) Apply the table from schema.sql to the real cloud database npx wrangler d1 execute contact-db --remote --file=./schema.sqlBind D1 in wrangler.jsonc
Paste the database_id from step 1. The binding name DB is how your Worker reaches the database as env.DB. The assets block lets the same Worker serve your HTML.
{ "name": "contact-form", "main": "src/index.js", "compatibility_date": "2025-01-01", "assets": { "directory": "./public" }, "d1_databases": [ { "binding": "DB", "database_name": "contact-db", "database_id": "<paste-your-id-here>" } ] }Build the front-end form
Save this as public/index.html. It uses FormData to gather the fields, sends them as JSON with fetch, and updates the status line based on the response.
<!doctype html> <html lang="zh-Hant"> <head> <meta charset="utf-8" /> <title>Contact us</title> </head> <body> <form id="contact-form"> <input name="name" type="text" placeholder="Your name" required /> <input name="email" type="email" placeholder="you@example.com" required /> <textarea name="message" placeholder="Message" required></textarea> <button type="submit">Send</button> <p id="status"></p> </form> <script> const form = document.getElementById('contact-form'); const statusEl = document.getElementById('status'); form.addEventListener('submit', async (e) => { e.preventDefault(); // stop the normal full-page reload statusEl.textContent = 'Sending...'; // FormData -> plain object -> JSON the Worker can read const data = Object.fromEntries(new FormData(form)); const res = await fetch('/api/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); const result = await res.json(); if (res.ok) { statusEl.textContent = 'Thanks! We received your message.'; form.reset(); } else { // result.errors looks like { email: 'Invalid email' } statusEl.textContent = 'Please fix: ' + Object.values(result.errors).join(', '); } }); </script> </body> </html>Write the Worker (validate + insert)
Save this as src/index.js. It checks the method and path, validates every field, and uses a prepared statement with bind() so user input can never become SQL.
export default { async fetch(request, env) { const url = new URL(request.url); // Only the form endpoint is handled here if (request.method !== 'POST' || url.pathname !== '/api/submit') { return new Response('Not found', { status: 404 }); } // 1. Read the body safely (bad JSON must not crash the Worker) let body; try { body = await request.json(); } catch { return Response.json({ errors: { form: 'Invalid JSON' } }, { status: 400 }); } // 2. Validate EVERY field on the server. Never trust the client. const errors = {}; const name = String(body.name || '').trim(); const email = String(body.email || '').trim(); const message = String(body.message || '').trim(); if (name.length < 2) errors.name = 'Name is too short'; if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) errors.email = 'Invalid email'; if (message.length < 5) errors.message = 'Message is too short'; if (message.length > 2000) errors.message = 'Message is too long'; if (Object.keys(errors).length > 0) { return Response.json({ errors }, { status: 400 }); } // 3. Insert with a prepared statement. bind() fills the ? holes // so user input can never become SQL (blocks SQL injection). await env.DB .prepare('INSERT INTO submissions (name, email, message) VALUES (?, ?, ?)') .bind(name, email, message) .run(); // 4. Confirm success with 201 Created return Response.json({ ok: true }, { status: 201 }); }, };(Optional) Stop bots with Turnstile
Add the Turnstile widget to the form, then verify the token at the very top of the Worker — before validation — so bots are rejected before they ever touch your validation or database.
// Optional: block bots BEFORE you validate or insert. // Add a Turnstile widget to the form (see the Turnstile guide), // it adds a "cf-turnstile-response" token to the submitted data. const token = body['cf-turnstile-response']; const verify = await fetch( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ secret: env.TURNSTILE_SECRET_KEY, // a Worker secret, never hardcoded response: token || '', remoteip: request.headers.get('CF-Connecting-IP') || '', }), } ); const outcome = await verify.json(); if (!outcome.success) { return Response.json({ errors: { form: 'Bot check failed' } }, { status: 403 }); }Deploy
Publish the Worker. Your form page and API go live on the same URL.
npx wrangler deploy
Server-side validation, branch by branch
This is the decision the Worker makes for every request. There is exactly one happy path (save and return 201) and one rejection path (return 400 with a list of what is wrong).
Never trust the client — why?
HTML attributes like required or type=email are only hints for honest users. Anyone can open the dev tools, or send a raw request with curl, and skip the page entirely. The Worker is the real guard: it must re-check length, format, and required fields itself, because it is the only code an attacker cannot edit.
Key concepts
preventDefault()
Stops the browser's default form submit (a full-page reload) so your JS can send the data with fetch instead.
FormData
A built-in object that grabs all named inputs from a form. Object.fromEntries turns it into a plain object ready for JSON.stringify.
Prepared statement
prepare() + bind() keep user input out of the SQL text. The ? placeholders are filled as values, so input can never run as code — this blocks SQL injection.
Binding (env.DB)
A binding is a named connection from your Worker to a resource. wrangler.jsonc maps the name DB to your database; in code it appears as env.DB.
HTTP status codes
201 = created (success). 400 = bad request (your input is wrong). 403 = forbidden (e.g. bot check failed). The front-end branches on them.
Turnstile token
A one-time pass the widget adds to the form. The Worker sends it to Cloudflare's Siteverify API to confirm a real human submitted it.
Pitfalls & tips
Validate, then store — in that order
A common bug is inserting first and validating later. Always reject bad input before the INSERT so your table never fills with junk or oversized rows.
- Return a structured errors object (per field) so the front-end can point at each problem, not just say 'failed'.
- Trim inputs and cap their length on the server; never rely on maxlength in HTML alone.
- Wrap request.json() in try/catch — a malformed body should give a clean 400, not a crash.
- Add an index on created_at if you list recent submissions often, to read fewer rows.
- Store the Turnstile secret with 'wrangler secret put TURNSTILE_SECRET_KEY' — never hardcode it.
- D1's free tier gives 5M rows read and 100K rows written per day — plenty for a contact form.
Defense in depth
Layer your defenses: HTML hints for UX, Worker validation for correctness, prepared statements against injection, and Turnstile against bots. No single layer is enough on its own.
Related products
menu_bookOfficial docsopen_in_new