Back to blog
Tutorials

Tutorial: REST API with Hono and Cloudflare Workers from Scratch

Bruno Bracaioli
Tutorial: REST API with Hono and Cloudflare Workers from Scratch

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 routes in wrangler.jsonc
  • Rate limiting: use cloudflare/workers-rate-limit or Workers KV
  • Multiple users: add a user_id column and extract from JWT
  • Versioned migrations: use wrangler d1 migrations create to 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.

Compartilhar:

Fique por dentro

Receba novos artigos sobre IA, desenvolvimento e tecnologia direto no seu email.