sellIntegration
api

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.

4CRUD verbs
1Worker file
0msCold starts
CORSBrowser-ready
insights

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.

schemaThe big picture

HTTP request

with CORS headers

Browser front-end (fetch)

Worker REST API

Router (method + path)

GET handler

POST handler

PUT handler

DELETE handler

JSON + status code

restaurant_menu

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".

account_tree

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.

front_hand

OPTIONS — preflight

The browser's CORS safety check. Answer it first with 204 and the CORS headers, before any real work.

download

GET — read

List a collection (/api/users) or fetch one item (/api/users/:id). Returns 200, or 404 if not found.

add_circle

POST — create

Read the JSON body, validate it, create the resource. Returns 201 Created, or 400 if the body is invalid.

edit

PUT — update

Find the item by :id and replace its fields. Returns 200, 400 for bad input, or 404 if missing.

delete

DELETE — remove

Remove the item by :id. Returns 200 when it's gone, or 404 if there was nothing to delete.

block

Anything else

An unknown path is 404; a path that exists but with the wrong method is 405 Method Not Allowed.

schemaRequest router

OPTIONS

GET

POST

PUT

DELETE

other

/api/users

/api/users/:id

Incoming request

method?

204 + CORS headers

path?

create → 201 / 400

update → 200 / 400 / 404

remove → 200 / 404

405 not allowed

list → 200

one → 200 / 404

swap_vert

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.

schemaPreflight then GET
"Worker API"Browser"Worker API"BrowserCORS preflightReal requestOPTIONS /api/users204 + Access-Control-Allow-*GET /api/usersroute by method + path200 JSON + CORS headers
info

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.

schemaInside one handler

ok

bad input

not found

threw error

Handler runs

outcome?

200 / 201 JSON

400 error JSON

404 error JSON

500 error JSON

attach CORS headers

send to browser

construction

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.

  1. Create a Worker project

    Scaffold a plain "Hello World" Worker in JavaScript, then open src/index.js.

    bash
    npm create cloudflare@latest -- my-api
    cd my-api
  2. Write 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.

  3. Run locally, then deploy

    Test at http://localhost:8787/api/users, then publish to a public *.workers.dev URL.

    bash
    npx wrangler dev
    npx wrangler deploy
jssrc/index.js — the REST API
// 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);
    }
  },
};
jsfront-end — calling the API with fetch()
// 這段跑在瀏覽器裡。換成你部署後拿到的網址。
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" });
tomlwrangler.toml
name = "my-api"
main = "src/index.js"
compatibility_date = "2025-01-01"
database

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.

school

Key concepts

http

HTTP method

The verb of a request — GET reads, POST creates, PUT updates, DELETE removes. Read it from request.method.

link

URL & path

new URL(request.url) parses the address. Its pathname (e.g. /api/users/2) tells you which resource is wanted.

data_object

JSON body

POST and PUT carry data in the request body. await request.json() turns it into a JavaScript object — always validate it.

check_circle

Status code

A 3-digit result: 2xx success, 4xx client mistake, 5xx server error. They let the front-end react without parsing text.

tune

Response.json()

A Workers helper that serializes an object to JSON and sets Content-Type for you. Pass { status, headers } as the 2nd arg.

public

CORS

Headers that grant a browser on another domain permission to call you. Handle OPTIONS and add Access-Control-Allow-* to every response.

fork_right

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.

rule

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_and_updates

Tips & common pitfalls

report

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

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.