sellIntegration
dynamic_form

HTML form → Worker → database

A contact or signup form that posts to a Worker, gets validated, and is saved to a D1 database.

3Layers wired
1Table to store rows
201Created on success
400On invalid input
insights

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.

local_post_office

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.

schemaArchitecture at a glance

Fill in and submit

fetch POST /api/submit

INSERT one row

success / failure

JSON response

Show success or error

Visitor / Browser

Front-end page (HTML + JS)

Worker (validate + logic)

D1 database

account_tree

Who does what

Each layer has one clear job. Keeping them separate is what makes the app easy to reason about and secure.

web

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.

verified_user

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.

database

D1 (the storage)

A SQL database that keeps every submission as a row. The Worker talks to it through the env.DB binding.

shield_lock

Turnstile (optional)

A free, puzzle-free bot check. Add it to stop spam from flooding your table.

dns

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.

schema

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.

schemasubmissions table

SUBMISSIONS

integer

id

PK

primary key

text

name

visitor name

text

email

contact email

text

message

message body

text

created_at

created time

sqlschema.sql
-- 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'))
);
schedule

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.

swap_vert

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.

schemaSubmit → validate → save → confirm
"D1 database""Worker""Front-end page""Visitor""D1 database""Worker""Front-end page""Visitor"alt[Fields valid][Fields invalid]Fill in form and click submitPOST /api/submit (JSON)Validate fields (name / email / message)INSERT INTO submissionsWrite succeeded201 Created + JSONShow success message400 + error listShow field errors
swap_horiz

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.

construction

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.

  1. 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).

    bash
    # 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.sql
  2. Bind 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.

    json
    {
      "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>"
        }
      ]
    }
  3. 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.

    html
    <!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>
  4. 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.

    js
    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 });
      },
    };
  5. (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.

    js
    // 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 });
    }
  6. Deploy

    Publish the Worker. Your form page and API go live on the same URL.

    bash
    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).

schemaValidation branches

Yes

No

Receive POST request

Parse JSON body

All fields valid?

INSERT INTO submissions

Return 201 + success

Collect error messages

Return 400 + error list

gpp_bad

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.

school

Key concepts

block

preventDefault()

Stops the browser's default form submit (a full-page reload) so your JS can send the data with fetch instead.

dataset

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.

key

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.

link

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.

tag

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.

smart_toy

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.

tips_and_updates

Pitfalls & tips

report

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

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.