
What we'll build
A todo list REST API with:
- 5 endpoints (
GET /tasks,GET /tasks/:id,POST /tasks,PATCH /tasks/:id,DELETE /tasks/:id) - Input validation with Zod
- Persistence in Cloudflare D1 (serverless SQLite)
- Bearer token auth
- Global deploy in ~5 seconds
Total time: 30 minutes. Prereqs: Node.js 20+, free Cloudflare account, wrangler CLI.
Why Hono?
Hono is a web framework inspired by Express, but written from scratch to be ultra-fast in edge runtimes. It works on Workers, Bun, Deno, Node, Vercel Edge — all with the same API. Bundle is tiny (< 30 KB) and DX is familiar to anyone who used Express.
Setup
npm create hono@latest todo-api
# Choose "cloudflare-workers" as template
# Choose "npm" as package manager
cd todo-api
npm install
npm install zod
You get a ready project:
todo-api/
├── src/
│ └── index.ts
├── wrangler.jsonc
├── package.json
└── tsconfig.json
Creating the D1 database
npx wrangler d1 create todo-db
The output will show something like:
[[d1_databases]]
binding = "DB"
database_name = "todo-db"
database_id = "abc123-..."
Paste that block into your wrangler.jsonc:
{
"name": "todo-api",
"main": "src/index.ts",
"compatibility_date": "2026-04-01",
"d1_databases": [
{
"binding": "DB",
"database_name": "todo-db",
"database_id": "abc123-..."
}
]
}
Now create the table. Create a file migrations/0001_initial.sql:
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_tasks_created_at ON tasks(created_at);
And apply to local and remote D1:
npx wrangler d1 execute todo-db --local --file=migrations/0001_initial.sql
npx wrangler d1 execute todo-db --remote --file=migrations/0001_initial.sql
Writing the API
Replace the contents of src/index.ts:
import { Hono } from "hono";
import { cors } from "hono/cors";
import { bearerAuth } from "hono/bearer-auth";
import { z } from "zod";
type Bindings = {
DB: D1Database;
API_TOKEN: string;
};
const app = new Hono<{ Bindings: Bindings }>();
// Global middleware
app.use("*", cors());
app.use("/tasks/*", async (c, next) => {
const auth = bearerAuth({ token: c.env.API_TOKEN });
return auth(c, next);
});
// Schemas
const createSchema = z.object({
title: z.string().min(1).max(200),
});
const updateSchema = z.object({
title: z.string().min(1).max(200).optional(),
done: z.boolean().optional(),
});
// GET /tasks
app.get("/tasks", async (c) => {
const { results } = await c.env.DB.prepare(
"SELECT id, title, done, created_at FROM tasks ORDER BY created_at DESC"
).all();
return c.json({ tasks: results });
});
// GET /tasks/:id
app.get("/tasks/:id", async (c) => {
const id = c.req.param("id");
const task = await c.env.DB.prepare(
"SELECT id, title, done, created_at FROM tasks WHERE id = ?"
).bind(id).first();
if (!task) return c.json({ error: "not_found" }, 404);
return c.json(task);
});
// POST /tasks
app.post("/tasks", async (c) => {
const body = await c.req.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: "invalid_input", details: parsed.error.flatten() }, 400);
}
const id = crypto.randomUUID();
await c.env.DB.prepare(
"INSERT INTO tasks (id, title, done) VALUES (?, ?, 0)"
).bind(id, parsed.data.title).run();
return c.json({ id, title: parsed.data.title, done: false }, 201);
});
// PATCH /tasks/:id
app.patch("/tasks/:id", async (c) => {
const id = c.req.param("id");
const body = await c.req.json();
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: "invalid_input" }, 400);
}
const fields: string[] = [];
const values: unknown[] = [];
if (parsed.data.title !== undefined) {
fields.push("title = ?");
values.push(parsed.data.title);
}
if (parsed.data.done !== undefined) {
fields.push("done = ?");
values.push(parsed.data.done ? 1 : 0);
}
if (fields.length === 0) {
return c.json({ error: "no_fields" }, 400);
}
values.push(id);
const result = await c.env.DB.prepare(
`UPDATE tasks SET ${fields.join(", ")} WHERE id = ?`
).bind(...values).run();
if (result.meta.changes === 0) {
return c.json({ error: "not_found" }, 404);
}
return c.json({ id, ...parsed.data });
});
// DELETE /tasks/:id
app.delete("/tasks/:id", async (c) => {
const id = c.req.param("id");
const result = await c.env.DB.prepare(
"DELETE FROM tasks WHERE id = ?"
).bind(id).run();
if (result.meta.changes === 0) {
return c.json({ error: "not_found" }, 404);
}
return c.body(null, 204);
});
export default app;
Configuring the auth token
Create the secret:
npx wrangler secret put API_TOKEN
# Paste a strong token generated with: openssl rand -hex 32
For local development, create .dev.vars:
API_TOKEN=dev-token-not-for-production
Running locally
npx wrangler dev
Output:
⛅️ wrangler 4.x.x
─────────────────
⎔ Starting local server...
[wrangler:info] Ready on http://localhost:8787
Testing
Open another terminal:
TOKEN="dev-token-not-for-production"
URL="http://localhost:8787"
# Create a task
curl -X POST $URL/tasks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Learn Hono"}'
# List tasks
curl $URL/tasks -H "Authorization: Bearer $TOKEN"
# Mark as done (use the id from the previous response)
curl -X PATCH $URL/tasks/YOUR_ID \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"done": true}'
# Delete
curl -X DELETE $URL/tasks/YOUR_ID \
-H "Authorization: Bearer $TOKEN"
All green? Time to ship.
Deploy
npx wrangler deploy
Output:
Deployed todo-api triggers
https://todo-api.YOUR_USERNAME.workers.dev
In 5 seconds your API is live globally, in 300+ Cloudflare data centers. Latency below 50ms anywhere in the world.
Next steps
- Real-time logs:
npx wrangler tail - Custom domain: configure
routesinwrangler.jsonc - Rate limiting: use
cloudflare/workers-rate-limitor Workers KV - Multiple users: add a
user_idcolumn and extract from JWT - Versioned migrations: use
wrangler d1 migrations createto automate
Conclusion
You just shipped a production-ready REST API with global database, validation, auth, and deploy in seconds — without provisioning infra, without cold starts, and spending $0 (free tier). This is the DX level that defines 2026.


