sellIntegration
sync_alt

Front-end ↔ Worker ↔ D1: full CRUD

Build a real todo app: an HTML page that creates, reads, updates and deletes rows in a cloud SQL database — all through one Worker.

3Layers wired
4CRUD verbs
1SQL database
0Servers to manage
insights

What are we wiring up?

CRUD stands for Create, Read, Update, Delete — the four things almost every app does with data. In this guide we build a tiny todo app where the browser talks to a Worker (a small program running on Cloudflare's edge), and the Worker reads and writes a D1 database (a serverless SQL database built on SQLite).

Three layers, one data flow. The front-end never touches the database directly — it always goes through the Worker, which is the only thing holding the database binding. Here is the big picture:

schemaArchitecture: Browser → Worker → D1

fetch /api/todos

prepare().bind()

rows

JSON

Browser (HTML / JS)

Worker API

D1 (SQLite)

restaurant

Think of it like a restaurant

The browser is the customer placing an order, the Worker is the waiter who takes it to the kitchen and brings food back, and D1 is the kitchen pantry where all the ingredients (your data) are stored. The customer never walks into the pantry — they always ask the waiter.

account_tree

Who does what?

web

Front-end (Browser)

Renders the UI and calls the API with fetch(). It only knows the URL and the JSON shape — nothing about SQL.

bolt

Worker (API)

Receives HTTP requests, validates input, routes by method, runs SQL through the env.DB binding, and returns JSON.

database

D1 (Database)

Stores the todos table and runs the actual SELECT / INSERT / UPDATE / DELETE statements safely.

vpn_key

The binding

wrangler.toml connects the Worker to D1 under the name env.DB — no connection string or password needed.

All four CRUD operations hit the same /api/todos path; the Worker decides what to do based on the HTTP method (GET, POST, PUT, DELETE). This is the routing logic in one picture:

schemaWorker routing by HTTP method

GET

POST

PUT

DELETE

Request /api/todos

HTTP method?

SELECT * FROM todos

INSERT INTO todos

UPDATE todos SET ...

DELETE FROM todos

Response.json(...)

schema

The data model

Our app needs just one table: todos. Each row is one task. id is the primary key (PK — a unique number that identifies the row), title is the task text, done is 0 or 1 (SQLite has no real boolean, so we use an integer), and created_at is a timestamp filled in automatically.

schematodos table (ER diagram)

TODOS

integer

id

PK

auto increment

text

title

task text

integer

done

0 or 1

text

created_at

timestamp

Translated into SQL, that table looks like this. Save it as schema.sql — we apply it to D1 in the build steps below.

sqlschema.sql
CREATE TABLE IF NOT EXISTS todos (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  title      TEXT    NOT NULL,
  done       INTEGER NOT NULL DEFAULT 0,
  created_at TEXT    NOT NULL DEFAULT (datetime('now'))
);
swap_vert

One full round trip

Let's trace a Create from start to finish: the user types a task and submits the form. The browser sends POST /api/todos with a JSON body, the Worker validates it and runs an INSERT, D1 returns the freshly created row (thanks to RETURNING *), and the Worker hands that JSON back so the UI can show it instantly.

schemaCreate round trip (POST)
"D1""Worker""Browser""D1""Worker""Browser"POST /api/todos (title)validate titleINSERT INTO todos RETURNING *new row201 Created + JSONrender new todo
bolt

Why RETURNING *?

Without RETURNING you would have to run a second SELECT to learn the new id and created_at. RETURNING * gives you the whole inserted row in one query — fewer round trips, simpler code.

construction

Build it step by step

Follow these seven steps and you will have a deployed full-stack todo app. You need Node.js installed; everything else runs through npx wrangler (Cloudflare's command-line tool).

  1. Create the D1 database

    Wrangler prints a database_id — copy it, you will paste it into wrangler.toml next.

    bash
    npx wrangler d1 create todo-db
  2. Bind D1 to the Worker

    The [[d1_databases]] block exposes the database to your Worker as env.DB. Paste the id from step 1.

    toml
    name = "todo-api"
    main = "src/index.js"
    compatibility_date = "2024-09-23"
    
    [[d1_databases]]
    binding = "DB"
    database_name = "todo-db"
    database_id = "<paste-your-id-here>"
  3. Apply the table schema

    Run schema.sql against the real cloud database. Use --local instead of --remote to test on your machine first.

    bash
    npx wrangler d1 execute todo-db --remote --file=./schema.sql
    
    # quick check it worked
    npx wrangler d1 execute todo-db --remote --command "SELECT name FROM sqlite_master WHERE type='table';"
  4. Write the Worker (src/index.js)

    One fetch handler routes all four CRUD verbs to D1 using prepare().bind() plus .all() / .first() / .run(). The CORS headers let a browser on another origin call this API.

    js
    export default {
      async fetch(request, env) {
        const url = new URL(request.url);
        const id = url.pathname.match(/^\/api\/todos\/(\d+)$/);
        const cors = {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
          "Access-Control-Allow-Headers": "Content-Type"
        };
    
        if (request.method === "OPTIONS") return new Response(null, { headers: cors });
    
        try {
          // READ: list every todo
          if (url.pathname === "/api/todos" && request.method === "GET") {
            const { results } = await env.DB
              .prepare("SELECT * FROM todos ORDER BY created_at DESC")
              .all();
            return json(results, 200, cors);
          }
    
          // CREATE: insert one todo
          if (url.pathname === "/api/todos" && request.method === "POST") {
            const { title } = await request.json();
            if (!title) return json({ error: "title required" }, 400, cors);
            const row = await env.DB
              .prepare("INSERT INTO todos (title) VALUES (?) RETURNING *")
              .bind(title)
              .first();
            return json(row, 201, cors);
          }
    
          // UPDATE: toggle done or edit title
          if (id && request.method === "PUT") {
            const { title, done } = await request.json();
            const row = await env.DB
              .prepare("UPDATE todos SET title = COALESCE(?, title), done = COALESCE(?, done) WHERE id = ? RETURNING *")
              .bind(title ?? null, done ?? null, Number(id[1]))
              .first();
            return row ? json(row, 200, cors) : json({ error: "not found" }, 404, cors);
          }
    
          // DELETE: remove one todo
          if (id && request.method === "DELETE") {
            const info = await env.DB
              .prepare("DELETE FROM todos WHERE id = ?")
              .bind(Number(id[1]))
              .run();
            return json({ deleted: info.meta.changes }, 200, cors);
          }
    
          return json({ error: "not found" }, 404, cors);
        } catch (err) {
          return json({ error: err.message }, 500, cors);
        }
      }
    };
    
    function json(data, status, cors) {
      return new Response(JSON.stringify(data), {
        status,
        headers: { "Content-Type": "application/json", ...cors }
      });
    }
  5. Front-end: the four fetch calls

    These helpers map one-to-one to CRUD: GET reads, POST creates, PUT updates, DELETE removes. Point API at your deployed Worker URL.

    js
    const API = "https://todo-api.<your-subdomain>.workers.dev/api/todos";
    
    // READ — GET all todos
    export async function listTodos() {
      const res = await fetch(API);
      return res.json();
    }
    
    // CREATE — POST a new todo
    export async function addTodo(title) {
      const res = await fetch(API, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title })
      });
      return res.json();
    }
    
    // UPDATE — PUT to toggle done (or edit title)
    export async function toggleTodo(id, done) {
      const res = await fetch(`${API}/${id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ done })
      });
      return res.json();
    }
    
    // DELETE — remove a todo
    export async function removeTodo(id) {
      const res = await fetch(`${API}/${id}`, { method: "DELETE" });
      return res.json();
    }
  6. Wire it into a page (index.html)

    A complete, runnable page: a form that POSTs new todos, a list that GETs them, a checkbox that PUTs done, and a button that DELETEs. Open it with any static server.

    html
    <!DOCTYPE html>
    <html lang="zh-Hant">
    <head>
      <meta charset="UTF-8" />
      <title>Todo</title>
    </head>
    <body>
      <h1>My Todos</h1>
      <form id="new">
        <input id="title" placeholder="What needs doing?" required />
        <button type="submit">Add</button>
      </form>
      <ul id="list"></ul>
    
      <script type="module">
        const API = "https://todo-api.<your-subdomain>.workers.dev/api/todos";
    
        async function load() {
          const todos = await (await fetch(API)).json();
          const ul = document.getElementById("list");
          ul.innerHTML = "";
          for (const t of todos) {
            const li = document.createElement("li");
            const box = document.createElement("input");
            box.type = "checkbox";
            box.checked = !!t.done;
            box.onchange = () => update(t.id, box.checked ? 1 : 0);
            const span = document.createElement("span");
            span.textContent = " " + t.title + " ";
            const del = document.createElement("button");
            del.textContent = "x";
            del.onclick = () => remove(t.id);
            li.append(box, span, del);
            ul.appendChild(li);
          }
        }
    
        async function update(id, done) {
          await fetch(`${API}/${id}`, {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ done })
          });
          load();
        }
    
        async function remove(id) {
          await fetch(`${API}/${id}`, { method: "DELETE" });
          load();
        }
    
        document.getElementById("new").addEventListener("submit", async (e) => {
          e.preventDefault();
          const title = document.getElementById("title").value.trim();
          if (!title) return;
          await fetch(API, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ title })
          });
          e.target.reset();
          load();
        });
    
        load();
      </script>
    </body>
    </html>
  7. Run locally, then deploy

    wrangler dev runs the Worker on your machine; wrangler deploy publishes it to the world and prints your live workers.dev URL.

    bash
    # develop with live reload
    npx wrangler dev
    
    # ship it
    npx wrangler deploy
school

Key concepts

shield

Prepared statements

prepare("... ?").bind(value) keeps user input out of the SQL text, blocking SQL injection. Never build SQL with string concatenation.

data_object

.all() vs .first() vs .run()

all() returns many rows, first() returns one row (or null), run() is for writes where you only need the change count.

swap_horiz

HTTP method = intent

GET reads, POST creates, PUT/PATCH updates, DELETE removes. Same URL, different verb — that is REST in a nutshell.

public

CORS

Browsers block cross-origin calls unless the server replies with Access-Control-* headers. That's why the Worker adds them to every response.

cloud_sync

Local vs remote D1

--local hits a SQLite file on your disk for fast tests; --remote hits the real cloud database. They are separate — seed both.

playlist_add_check

RETURNING *

An SQLite feature that hands back the affected row from an INSERT/UPDATE in the same query — no second SELECT needed.

tips_and_updates

Pitfalls & pricing

warning

Don't expose D1 to the browser

The env.DB binding only exists inside the Worker. Never try to query D1 from front-end JavaScript — always go through the Worker so validation and auth stay server-side.

payments

Billed by rows, not by time

D1 counts rows read and rows written, plus storage. The free tier gives 5 million rows read and 100,000 rows written per day with 5 GB storage — plenty for a todo app.

  • Validate input in the Worker (e.g. reject empty titles) before touching the database.
  • Add an index on columns you filter or sort by often to read fewer rows and save money.
  • Use status codes meaningfully: 201 for created, 404 for not found, 400 for bad input.
  • Remember --local and --remote D1 are separate databases; seed the one you are testing.
  • Serve the front-end from Cloudflare Pages so HTML and API share one domain (and you can drop CORS).