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.
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:
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.
Who does what?
Front-end (Browser)
Renders the UI and calls the API with fetch(). It only knows the URL and the JSON shape — nothing about SQL.
Worker (API)
Receives HTTP requests, validates input, routes by method, runs SQL through the env.DB binding, and returns JSON.
D1 (Database)
Stores the todos table and runs the actual SELECT / INSERT / UPDATE / DELETE statements safely.
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:
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.
Translated into SQL, that table looks like this. Save it as schema.sql — we apply it to D1 in the build steps below.
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'))
);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.
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.
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).
Create the D1 database
Wrangler prints a database_id — copy it, you will paste it into wrangler.toml next.
npx wrangler d1 create todo-dbBind D1 to the Worker
The [[d1_databases]] block exposes the database to your Worker as env.DB. Paste the id from step 1.
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>"Apply the table schema
Run schema.sql against the real cloud database. Use --local instead of --remote to test on your machine first.
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';"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.
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 } }); }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.
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(); }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.
<!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>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.
# develop with live reload npx wrangler dev # ship it npx wrangler deploy
Key concepts
Prepared statements
prepare("... ?").bind(value) keeps user input out of the SQL text, blocking SQL injection. Never build SQL with string concatenation.
.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.
HTTP method = intent
GET reads, POST creates, PUT/PATCH updates, DELETE removes. Same URL, different verb — that is REST in a nutshell.
CORS
Browsers block cross-origin calls unless the server replies with Access-Control-* headers. That's why the Worker adds them to every response.
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.
RETURNING *
An SQLite feature that hands back the affected row from an INSERT/UPDATE in the same query — no second SELECT needed.
Pitfalls & pricing
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.
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).
Related products
menu_bookOfficial docsopen_in_new