Resources, not actions
The first rule of REST
We have a running server and a data store full of books. Now it’s time to build our first real endpoints. But before we start writing route handlers, we need to understand the most fundamental rule of REST API design.
REST URLs identify resources (things), not actions (operations). The URL tells you what thing you’re working with. The HTTP method tells you what you’re doing to it.
This sounds simple, but it’s the mistake that separates well-designed APIs from messy ones.
What not to do
Here’s what many developers do when they first build an API. They think about the operations their app needs and bake those operations directly into the URL:
BAD: GET /getBooks
BAD: POST /createBook
BAD: POST /deleteBook/123
BAD: GET /fetchAuthorDetails?id=1 Every URL is a verb. Get books. Create book. Delete book. Fetch author details.
Now look at the REST approach:
GOOD: GET /books
GOOD: POST /books
GOOD: DELETE /books/123
GOOD: GET /authors/1 The URLs are all nouns. Books. Authors. The action comes from the HTTP method: GET to read, POST to create, DELETE to remove.
This isn’t just a style preference. It has real consequences. When URLs are nouns, GET requests can be cached by browsers and CDNs. Routing becomes simpler because every resource follows the same pattern. And the API becomes predictable. If a developer sees /books, they can immediately guess that /authors exists too and works the same way.
Collections and items
Every resource URL comes in two forms.
Collection: /books represents all books. You GET the collection to list them, or POST to it to create a new one.
Item: /books/123 represents one specific book. You GET it to read it, PUT or PATCH to update it, and DELETE to remove it.
GET /books list all books
POST /books create a new book
GET /books/123 get book 123
PUT /books/123 replace book 123
PATCH /books/123 update book 123
DELETE /books/123 delete book 123 Six operations, one URL pattern. That’s the power of separating the resource (the noun) from the action (the verb).
A developer who sees /authors can guess that GET /authors/1 returns author 1 without reading any documentation. That predictability is what makes REST APIs easy to work with.
Always use plural nouns
Use plural nouns for collection URLs: /books, /authors, /reviews. Not /book, /author, /review.
Why? Because the collection (/books) is a list of multiple books. The item (/books/123) is one book from that list. Both share the same base path. If you used singular, the collection URL (/book) would look like it returns a single book, which is confusing.
Building the routes
Let’s put this into practice. We’ll build the book routes for our bookstore API. Create a new file at src/routes/books.ts. We’ll start with the imports and a validation schema, then add each route one at a time.
// src/routes/books.ts
import * as z from "zod/v4";
import { route, group } from "@hectoday/http";
import { books, authors, type Book } from "../db.js";
const CreateBookBody = z.object({
title: z.string().min(1).max(500),
isbn: z.string().optional(),
genre: z.string().min(1),
publishedAt: z.string().optional(),
authorId: z.string().min(1),
}); The CreateBookBody schema uses Zod to define exactly what a valid request body looks like when creating a book. The title must be a string between 1 and 500 characters. The isbn and publishedAt are optional. The genre and authorId are required. If a client sends data that doesn’t match this schema, the validation fails and we return a 400 error.
Now let’s build the routes. We’ll group them together using group() and export the array so the app can use them.
Listing all books
export const bookRoutes = group([
route.get("/books", {
resolve: () => {
const sorted = books.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return Response.json(sorted);
},
}), This is the collection endpoint. It returns all books sorted by newest first. We call .slice() to create a copy of the array before sorting, so we don’t modify the original order. The response is JSON, which Response.json() handles for us.
Getting a single book
route.get("/books/:id", {
request: { params: z.object({ id: z.string() }) },
resolve: (c) => {
if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
const { id } = c.input.params;
const book = books.find((b) => b.id === id);
if (!book) return Response.json({ error: "Book not found" }, { status: 404 });
return Response.json(book);
},
}), This is the item endpoint. The :id part in the URL is a parameter. When someone sends GET /books/book-1, c.input.params will contain { id: "book-1" }. We destructure it to get id, then look up the book. If the book exists, we return it. If not, we return a 404.
Notice we destructure c.input.params before using it. This is important with Hectoday HTTP, because accessing nested properties directly off c.input can lose type information. Destructuring first keeps everything typed correctly.
Creating a book
route.post("/books", {
request: { body: CreateBookBody },
resolve: (c) => {
if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
const { title, isbn, genre, publishedAt, authorId } = c.input.body;
const author = authors.find((a) => a.id === authorId);
if (!author) return Response.json({ error: "Author not found" }, { status: 400 });
const book: Book = {
id: crypto.randomUUID(),
title,
isbn,
genre,
publishedAt,
authorId,
createdAt: new Date().toISOString(),
};
books.push(book);
return Response.json(book, { status: 201 });
},
}), This route creates a new book. It first validates the request body against the Zod schema. If validation fails, c.input.ok is false and we return the issues as a 400 error.
We destructure c.input.body to get all the fields. Then we check that the referenced author actually exists. You can’t create a book for a non-existent author. If everything checks out, we create a new Book object with a generated UUID and the current timestamp, push it to the array, and return it with a 201 status code. 201 means “created,” and we’ll talk more about status codes in a later lesson.
Deleting a book
route.delete("/books/:id", {
request: { params: z.object({ id: z.string() }) },
resolve: (c) => {
if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
const { id } = c.input.params;
const index = books.findIndex((b) => b.id === id);
if (index === -1) return Response.json({ error: "Book not found" }, { status: 404 });
books.splice(index, 1);
return new Response(null, { status: 204 });
},
}),
]); This route removes a book. We destructure id from c.input.params, find the book’s index in the array, and use splice to remove it. If the book wasn’t found (index === -1), we return 404. If it was removed successfully, we return 204. That status code means “success, no content.” The book is gone, so there’s nothing to send back in the response body.
Wiring it up
Now let’s add these routes to our app. Update src/app.ts:
import { setup, route } from "@hectoday/http";
import { bookRoutes } from "./routes/books.js";
export const app = setup({
routes: [route.get("/health", { resolve: () => Response.json({ status: "ok" }) }), ...bookRoutes],
}); The ...bookRoutes spread adds all the book routes into the app’s route list.
Try it
# List books
curl http://localhost:3000/books
# Get a single book
curl http://localhost:3000/books/book-1
# Create a book
curl -X POST http://localhost:3000/books \
-H "Content-Type: application/json" \
-d '{"title":"For Whom the Bell Tolls","genre":"fiction","authorId":"author-1"}'
# Delete a book
curl -X DELETE http://localhost:3000/books/book-1 Notice how natural the URLs feel. You don’t need documentation to guess what GET /books/book-1 does. The URL describes the thing, and the method describes the action.
What’s next
We have basic CRUD working, but we’re only using three HTTP methods so far: GET, POST, and DELETE. What about updating a book? That’s where PUT and PATCH come in, and the difference between them trips up a lot of developers. We’ll cover that in the next lesson.
Exercises
Exercise 1: Build the author routes following the same pattern: GET /authors, GET /authors/:id, POST /authors, DELETE /authors/:id.
Exercise 2: Build the review routes: GET /books/:bookId/reviews, POST /books/:bookId/reviews. Reviews are nested under books because a review always belongs to a specific book.
Exercise 3: What URL would you use for “get all books by author 1”? There are two valid options: GET /authors/1/books (nested) or GET /books?authorId=1 (filtered). We’ll explore this tradeoff in the resource modeling lesson.
Why do REST URLs use nouns instead of verbs?