hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses REST API Design with @hectoday/http

What Makes an API RESTful

  • APIs are contracts
  • Project setup
  • Resources, not actions

HTTP Methods

  • GET, POST, PUT, PATCH, DELETE
  • Idempotency
  • Method safety and side effects

Status Codes

  • The status codes that matter
  • Error responses

Resource Design

  • Modeling resources
  • Partial responses and field selection
  • Pagination
  • Filtering, sorting, and searching

API Lifecycle

  • Versioning
  • Content negotiation
  • Rate limiting and quotas

Advanced Patterns

  • Bulk operations
  • Long-running operations
  • HATEOAS and discoverability

Putting It All Together

  • API design checklist
  • Summary

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?

← Project setup GET, POST, PUT, PATCH, DELETE →

© 2026 hectoday. All rights reserved.