Routes and errors
Create resources with POST
Parse a JSON request, create a server-owned identifier, and return 201 with the new resource and Location header.
POST /books asks the server to create a new book inside the collection. The client sends the fields it knows. The server picks the ID, stores the book, and tells the client where the new resource lives.
Here is the first version of the handler:
app.post('/books', async c => {
const input = await c.req.json()
const book = { ...input, id: crypto.randomUUID() }
books.push(book)
c.header('Location', `/books/${book.id}`)
return c.json({ book }, 201)
})
c.req.json() reads and parses the body, so the handler is async. crypto.randomUUID() is a global in Node.js, no import needed. The second argument to c.json() sets the status.
Send a book and look at the response:
curl -i --json '{"title":"Dune","author":"Frank Herbert"}' http://localhost:3000/books
HTTP/1.1 201 Created
location: /books/8f1c2a3e-5b7d-4e9f-a1b2-c3d4e5f6a7b8
content-type: application/json
{"book":{"title":"Dune","author":"Frank Herbert","id":"8f1c2a3e-..."}}
Two details matter here. The status is 201 Created, not a generic 200. And the Location header points at the canonical URL of the new book. A client can follow it right away with a GET and read back what it just created. Try that: copy the location, request it, and confirm GET /books now lists two books.
The server owns the ID
Notice the order in the spread: { ...input, id }. Because id comes last, a client that sends its own id field gets it overwritten. That’s on purpose. The client never chooses identifiers, and the same goes for createdAt when we add it.
Still, spreading input straight into storage is a shortcut I only accept in a first draft. If the client sends { "title": "Dune", "admin": true }, the admin field ends up in the book. The right order is: validate the body, pick only the writable fields, then attach the server-owned ones. That’s the whole point of the validation lesson in the next module.
Store first, answer second
A 201 is a promise. It says “this resource exists now, go read it at Location”. So the push into books has to happen before the return. With a database, the insert has to commit before the 201 goes out. Never answer success and save afterwards.
Failures at the boundary
Bad input can fail in three different ways, and each gets its own status. A body that isn’t JSON at all, say Content-Type: text/plain, is a 415 Unsupported Media Type. A body that claims to be JSON but doesn’t parse is a 400 Bad Request. A body that parses fine but breaks our rules, like a missing title, is a 422 Unprocessable Content.
POST is not safe to retry
One last thing to be careful with. If the network drops after the server saved the book but before the client saw the 201, the client doesn’t know what happened. A blind retry creates a second Dune. POST is not idempotent: doing it twice is not the same as doing it once. The retries lesson in the fourth module adds an idempotency key so clients can retry safely.
Lesson completed