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

Modeling resources

Resources don’t exist in isolation

Our bookstore has books, authors, and reviews. But these aren’t independent. Books have authors. Authors have books. Books have reviews. These relationships need to show up in the API somehow.

This raises two questions that come up in every API you’ll ever design:

  1. How should the URLs be structured? Should it be /authors/author-1/books or /books?authorId=author-1?
  2. How much related data should a response include? Should a book response include the full author object, just the author’s ID, or something in between?

Let’s work through both.

Nested vs flat routes

There are two ways to express a “belongs to” relationship in your URLs.

Nested routes put the child resource under the parent: /authors/author-1/books means “books that belong to author-1.”

Flat routes with filters keep the child as a top-level resource: /books?authorId=author-1 means “all books, filtered to only those by author-1.”

Both return the same data. The difference is in how clearly the URL communicates the relationship, and how flexible the endpoint is.

Here’s the rule of thumb:

Use nested routes when the child doesn’t make sense without the parent. A review always belongs to a specific book. You’d never ask for “all reviews across all books” in a typical API. So POST /books/book-1/reviews makes perfect sense, because you’re creating a review for book-1.

Use flat routes when the child is an independent resource. Books exist regardless of who wrote them. You might want all books, or books filtered by genre, or books filtered by author. A flat route like GET /books with optional query parameters is more flexible.

For our bookstore, here’s how it shakes out:

GET /books                        all books (flat, with filters)
GET /books/:id                    a specific book
GET /books/:id/reviews            reviews for a book (nested, reviews belong to books)
POST /books/:id/reviews           create a review for a book (nested)

GET /authors                      all authors (flat)
GET /authors/:id                  a specific author
GET /authors/:id/books            books by an author (nested convenience)
GET /books?authorId=author-1      same data as above (flat with filter)

Notice that reviews are always nested under books. A review doesn’t exist without a book. But an author’s books are available both ways: nested at /authors/:id/books (convenient for “show me this author’s books”) and flat at /books?authorId=... (more flexible, composes with other filters).

Implementing nested routes

Let’s build the review routes. Reviews are the classic case for nesting. First, the imports and validation schema:

// src/routes/reviews.ts
import * as z from "zod/v4";
import { route } from "@hectoday/http";
import { books, reviews, type Review } from "../db.js";
import { fromZodIssues, notFound } from "../errors.js";

const CreateReviewBody = z.object({
  reviewerName: z.string().min(1),
  rating: z.number().int().min(1).max(5),
  body: z.string().optional(),
});

The CreateReviewBody schema validates that every review has a reviewer name and a rating between 1 and 5. The review body is optional.

Now the routes:

route.get("/books/:bookId/reviews", {
  request: { params: z.object({ bookId: z.string() }) },
  resolve: (c) => {
    if (!c.input.ok) return fromZodIssues(c.input.issues);

    // Verify the book exists first
    const { bookId } = c.input.params;
    const book = books.find((b) => b.id === bookId);
    if (!book) return notFound("Book");

    const bookReviews = reviews
      .filter((r) => r.bookId === bookId)
      .sort((a, b) => b.createdAt.localeCompare(a.createdAt));

    return Response.json(bookReviews);
  },
}),

route.post("/books/:bookId/reviews", {
  request: { params: z.object({ bookId: z.string() }), body: CreateReviewBody },
  resolve: (c) => {
    if (!c.input.ok) return fromZodIssues(c.input.issues);

    const { bookId } = c.input.params;
    const book = books.find((b) => b.id === bookId);
    if (!book) return notFound("Book");

    const { reviewerName, rating, body: reviewBody } = c.input.body;
    const id = crypto.randomUUID();

    const review: Review = {
      id,
      bookId,
      reviewerName,
      rating,
      body: reviewBody,
      createdAt: new Date().toISOString(),
    };

    reviews.push(review);

    return Response.json(review, {
      status: 201,
      headers: { location: `/books/${bookId}/reviews/${id}` },
    });
  },
}),

There’s an important detail in both handlers: we check that the book exists before doing anything with reviews. If someone sends GET /books/fake-id/reviews, we return 404 for the book, not an empty list. This makes it clear that the book itself doesn’t exist, rather than suggesting it exists but has no reviews.

The bookId comes from destructuring the URL parameters. When a client sends POST /books/book-1/reviews, bookId is "book-1". The review automatically gets associated with that book through the bookId field.

Embedding vs linking

When you return a book, should the response include the full author object? Just the author’s ID? Or a link to the author endpoint?

Embedding includes the full related object:

{
  "id": "book-1",
  "title": "The Old Man and the Sea",
  "author": {
    "id": "author-1",
    "name": "Ernest Hemingway",
    "bio": "American novelist and journalist."
  }
}

Linking includes only the ID:

{
  "id": "book-1",
  "title": "The Old Man and the Sea",
  "authorId": "author-1"
}

The tradeoff is straightforward. Embedding saves the consumer a second request (they already have the author data right there). Linking keeps responses small, which matters especially in list endpoints where the same author might appear on many books.

A good middle ground is to embed a summary: just enough to display, not the full object.

{
  "id": "book-1",
  "title": "The Old Man and the Sea",
  "author": {
    "id": "author-1",
    "name": "Ernest Hemingway"
  }
}

The consumer has enough to display “The Old Man and the Sea by Ernest Hemingway.” If they need the full bio, they call GET /authors/author-1. This is a common pattern in production APIs. You give the consumer the most useful fields upfront and let them fetch more if they need it.

Don’t nest more than two levels deep

GOOD: /books/:bookId/reviews
BAD:  /authors/:authorId/books/:bookId/reviews/:reviewId/comments

Deep nesting makes URLs unwieldy, adds complexity to your routing, and creates ambiguity. What does a three-level-deep URL even mean? If you find yourself going beyond two levels, it’s a sign that the deeply nested resource should be a top-level resource with filters instead.

What’s next

We’ve covered how to structure relationships. But there’s another problem lurking in our API. When we return a list of books, we return everything about every book. What if the client only needs the title and author name? That’s wasted bandwidth, especially on mobile networks. Next, we’ll look at field selection to let clients ask for only the data they need.

Exercises

Exercise 1: Implement GET /books/:bookId/reviews and POST /books/:bookId/reviews.

Exercise 2: Implement GET /authors/:id/books as a convenience endpoint. It should return the same data as GET /books?authorId=:id.

Exercise 3: Update GET /books/:id to embed the author’s name and ID (not the full author object).

When should you use nested routes instead of flat routes with filters?

← Error responses Partial responses and field selection →

© 2026 hectoday. All rights reserved.