Designing a REST API with Workers
This is the back-end half of front-end ↔ back-end: one Worker file that answers GET, POST, PUT, and DELETE like a proper REST API.
What are we building?
We're building a REST API on a single Cloudflare Worker. A "REST API" (Representational State Transfer) is just an agreed-upon way for a front-end to ask a back-end for data over HTTP: you pick a URL path for a resource (like /api/users) and an HTTP method (GET, POST, PUT, DELETE) that says what you want to do to it.
The whole back-end lives in one Worker. When a request arrives, the Worker reads two things — the method and the path — decides which handler should run, does the work, and sends back a JSON response with a status code. That's it. No framework required.
Think of it like a restaurant
The URL path is the table number (which resource), and the HTTP method is what you say to the waiter: GET = "show me the menu", POST = "I'd like to order this", PUT = "change my order", DELETE = "cancel it". The status code is the waiter's reply: 200 "here you go", 201 "order placed", 404 "we don't have that".
How the router decides
Inside the Worker, routing is just branching on two values you read from the incoming request: request.method and the pathname from new URL(request.url). Each method/path combination maps to one handler, and each handler returns a response with an appropriate status code.
OPTIONS — preflight
The browser's CORS safety check. Answer it first with 204 and the CORS headers, before any real work.
GET — read
List a collection (/api/users) or fetch one item (/api/users/:id). Returns 200, or 404 if not found.
POST — create
Read the JSON body, validate it, create the resource. Returns 201 Created, or 400 if the body is invalid.
PUT — update
Find the item by :id and replace its fields. Returns 200, 400 for bad input, or 404 if missing.
DELETE — remove
Remove the item by :id. Returns 200 when it's gone, or 404 if there was nothing to delete.
Anything else
An unknown path is 404; a path that exists but with the wrong method is 405 Method Not Allowed.
A browser call, step by step
When a web page on one domain calls an API on another domain, the browser enforces CORS (Cross-Origin Resource Sharing) — a rule that protects users from sneaky cross-site requests. For some requests the browser first sends a "preflight": an OPTIONS request that asks the API "are you OK with me calling you?". Only if the API answers with the right Access-Control-* headers does the browser send the real request.
Not every request is preflighted
"Simple" requests (a plain GET, or a POST with a basic content type) skip the OPTIONS step. The moment you send Content-Type: application/json or a custom header, the browser preflights — which is why a JSON API must always handle OPTIONS.
Build it
Here is the full back-end in one file, a matching front-end fetch example, and the wrangler config. The Worker uses a tiny in-memory array as a stand-in "database" so you can focus on routing — swap in D1 or KV for real, persistent data.
Create a Worker project
Scaffold a plain "Hello World" Worker in JavaScript, then open src/index.js.
npm create cloudflare@latest -- my-api cd my-apiWrite the REST router
Replace src/index.js with the back-end code below. It reads the method and path, routes to a handler, and returns Response.json(...) with the right status and CORS headers.
Run locally, then deploy
Test at http://localhost:8787/api/users, then publish to a public *.workers.dev URL.
npx wrangler dev npx wrangler deploy
// A single-file REST API on one Worker.
// CORS = Cross-Origin Resource Sharing(跨來源資源共用):
// 這些標頭讓「別的網域」的瀏覽器前端可以呼叫我們。
const CORS = {
"Access-Control-Allow-Origin": "*", // 誰可以呼叫(* = 任何人;正式環境請鎖成你的網域)
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
// 一個小小的記憶體資料(重啟會清空——正式請改用 D1 / KV)。
let users = [
{ id: 1, name: "Ada" },
{ id: 2, name: "Linus" },
];
let nextId = 3;
// 用一個 helper 統一產生每個 JSON 回應:資料 + 狀態碼 + CORS 標頭。
function json(data, status = 200) {
return Response.json(data, { status, headers: CORS });
}
export default {
async fetch(request) {
const { pathname } = new URL(request.url); // 例如 "/api/users/2"
const method = request.method; // 例如 "GET"
// 1) CORS 預檢:瀏覽器在真正跨站請求前會先送 OPTIONS。
if (method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS });
}
// 2) 只服務我們的 API 路徑;其餘一律 404 Not Found(找不到)。
if (!pathname.startsWith("/api/users")) {
return json({ error: "Not found" }, 404);
}
// 3) 從 /api/users/:id 取出可有可無的 id。
const parts = pathname.split("/").filter(Boolean); // ["api","users","2"]
const id = parts[2] ? Number(parts[2]) : null;
try {
// GET /api/users → 列出全部(200 OK)
if (method === "GET" && id === null) {
return json(users, 200);
}
// GET /api/users/:id → 取單筆(200,找不到 404)
if (method === "GET" && id !== null) {
const user = users.find((u) => u.id === id);
return user ? json(user, 200) : json({ error: "User not found" }, 404);
}
// POST /api/users → 新增(201 Created,輸入不合法 400 Bad Request)
if (method === "POST" && id === null) {
const body = await request.json();
if (typeof body?.name !== "string") {
return json({ error: "name is required" }, 400);
}
const user = { id: nextId++, name: body.name };
users.push(user);
return json(user, 201);
}
// PUT /api/users/:id → 更新(200 / 400 / 404)
if (method === "PUT" && id !== null) {
const user = users.find((u) => u.id === id);
if (!user) return json({ error: "User not found" }, 404);
const body = await request.json();
if (typeof body?.name !== "string") {
return json({ error: "name is required" }, 400);
}
user.name = body.name;
return json(user, 200);
}
// DELETE /api/users/:id → 刪除(200,找不到 404)
if (method === "DELETE" && id !== null) {
const before = users.length;
users = users.filter((u) => u.id !== id);
return before === users.length
? json({ error: "User not found" }, 404)
: json({ ok: true }, 200);
}
// 路徑存在,但方法不對 → 405 Method Not Allowed(方法不允許)。
return json({ error: "Method not allowed" }, 405);
} catch (err) {
// JSON 內容壞掉或其他意外 → 400 Bad Request。
return json({ error: "Invalid request" }, 400);
}
},
};// 這段跑在瀏覽器裡。換成你部署後拿到的網址。
const API = "https://my-api.<your-subdomain>.workers.dev/api/users";
// GET:列出全部使用者
const res = await fetch(API);
console.log(res.status, await res.json()); // 200, [ {id:1,...}, ... ]
// POST:新增一筆(把 JSON 放在 body,並標明 Content-Type)
const created = await fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Grace" }),
});
console.log(created.status, await created.json()); // 201, { id: 3, name: "Grace" }
// PUT:更新 id = 3 那筆
await fetch(`${API}/3`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Grace H." }),
});
// DELETE:刪掉 id = 3 那筆
await fetch(`${API}/3`, { method: "DELETE" });name = "my-api"
main = "src/index.js"
compatibility_date = "2025-01-01"In-memory data won't persist
Each Worker isolate has its own copy of that users array, and it resets over time. It's perfect for learning routing, but for real data you need storage. See the D1 CRUD guide to wire this exact API to a SQL database.
Key concepts
HTTP method
The verb of a request — GET reads, POST creates, PUT updates, DELETE removes. Read it from request.method.
URL & path
new URL(request.url) parses the address. Its pathname (e.g. /api/users/2) tells you which resource is wanted.
JSON body
POST and PUT carry data in the request body. await request.json() turns it into a JavaScript object — always validate it.
Status code
A 3-digit result: 2xx success, 4xx client mistake, 5xx server error. They let the front-end react without parsing text.
Response.json()
A Workers helper that serializes an object to JSON and sets Content-Type for you. Pass { status, headers } as the 2nd arg.
CORS
Headers that grant a browser on another domain permission to call you. Handle OPTIONS and add Access-Control-Allow-* to every response.
Routing
Matching method + path to one handler. With a few routes, plain if checks beat any framework; for many, reach for itty-router or Hono.
Idempotency
GET, PUT, DELETE should give the same result if repeated; POST usually creates something new each time. It guides which verb to pick.
Status codes you'll use most
- 200 OK — request succeeded (GET, PUT, DELETE)
- 201 Created — a new resource was created (POST)
- 204 No Content — success with no body (great for OPTIONS preflight)
- 400 Bad Request — the client sent invalid data
- 404 Not Found — no resource at that path / id
- 405 Method Not Allowed — path exists but the verb isn't supported
- 500 Internal Server Error — something blew up on your side
Tips & common pitfalls
The #1 CORS mistake
If you forget to handle OPTIONS or leave CORS headers off your error responses, the browser blocks the call and the console shows a confusing "CORS error" — even though your Worker actually ran. Attach the CORS headers to every single response, success or failure.
Lock down the origin in production
Access-Control-Allow-Origin: * is fine while learning, but in production set it to your real front-end domain (e.g. https://app.example.com) so only your site can call the API from a browser.
Habits that keep an API clean
- Always wrap request.json() in try/catch — a malformed body should return 400, not crash the Worker
- Validate every field before you trust it; never write unchecked input into storage
- Return a consistent JSON shape for errors, e.g. { "error": "message" }, so the front-end can handle them uniformly
- Pick the verb by intent: read = GET, create = POST, replace = PUT, remove = DELETE
- Keep one json() helper so status codes and CORS headers are set in exactly one place
- When routes multiply, move from if-chains to a router library like Hono to stay readable
A good REST API is boring on purpose: predictable paths, predictable verbs, predictable status codes.
Related products
menu_bookOfficial docsopen_in_new