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

Filtering, sorting, and searching

Clients need to find things

We’ve got pagination working. The client can page through books 20 at a time. But what if they want only fiction books? Or books sorted by publication date? Or books matching a search term?

Right now, the only option is to fetch everything and filter on the client side. That’s wasteful. The server can do this work much more efficiently. Let’s add filtering, sorting, and searching to our GET /books endpoint.

Filtering

Filters narrow a list down to items that match specific criteria. The convention is simple: use the field name as the query parameter.

GET /books?genre=fiction
GET /books?authorId=author-1
GET /books?genre=fiction&authorId=author-1

Multiple filters compose with AND. The last example returns fiction books by author-1. This feels natural and is easy to understand without any documentation.

Building a filterable endpoint

We’re going to evolve our GET /books handler to support filtering, sorting, and searching. All the query parameters go into a single Zod schema, and the handler chains operations one after another. Here’s the full handler with filtering and pagination:

const ALLOWED_SORTS: Record<string, (a: Book, b: Book) => number> = {
  title: (a, b) => a.title.localeCompare(b.title),
  "-title": (a, b) => b.title.localeCompare(a.title),
  publishedAt: (a, b) => (a.publishedAt ?? "").localeCompare(b.publishedAt ?? ""),
  "-publishedAt": (a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? ""),
  createdAt: (a, b) => a.createdAt.localeCompare(b.createdAt),
  "-createdAt": (a, b) => b.createdAt.localeCompare(a.createdAt),
};

route.get("/books", {
  request: {
    query: z.object({
      genre: z.string().optional(),
      authorId: z.string().optional(),
      sort: z.string().optional(),
      q: z.string().optional(),
      limit: z.coerce.number().default(20),
      cursor: z.string().optional(),
    }),
  },
  resolve: (c) => {
    if (!c.input.ok) return fromZodIssues(c.input.issues);
    const { genre, authorId, sort: sortParam, q, cursor } = c.input.query;
    const limit = Math.min(c.input.query.limit, 100);

    let result = books.slice();

    // Filter
    if (genre) {
      result = result.filter((b) => b.genre === genre);
    }
    if (authorId) {
      result = result.filter((b) => b.authorId === authorId);
    }

    // Search
    if (q) {
      const lower = q.toLowerCase();
      result = result.filter(
        (b) => b.title.toLowerCase().includes(lower) || b.genre.toLowerCase().includes(lower),
      );
    }

    // Paginate (cursor is always based on createdAt)
    result.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
    if (cursor) {
      result = result.filter((b) => b.createdAt < cursor);
    }
    const page = result.slice(0, limit);
    const nextCursor = page.length === limit ? page[page.length - 1].createdAt : null;

    // Apply custom sort to the page
    const sortFn = ALLOWED_SORTS[sortParam ?? "-createdAt"] ?? ALLOWED_SORTS["-createdAt"];
    page.sort(sortFn);

    return Response.json({
      data: page,
      pagination: { limit, nextCursor, hasMore: nextCursor !== null },
    });
  },
});

That’s a lot of code, so let’s break it down section by section.

Filtering

All query parameters are defined upfront with Zod. Every one is optional, so clients can use any combination. We destructure c.input.query to get the validated values, then start with a copy of the full books array and progressively narrow it down.

if (genre) {
  result = result.filter((b) => b.genre === genre);
}
if (authorId) {
  result = result.filter((b) => b.authorId === authorId);
}

If the client sends ?genre=fiction, we narrow the array to only books where genre matches. If they don’t send it, we skip it. Adding a new filter is just another optional field in the schema and another if block.

Try it:

# Fiction books only
curl "http://localhost:3000/books?genre=fiction"

# Books by a specific author
curl "http://localhost:3000/books?authorId=author-2"

# Both: fiction books by author-1
curl "http://localhost:3000/books?genre=fiction&authorId=author-1"

Sorting

Sorting lets clients control the order of results. The convention that many APIs use: a sort parameter with a field name, where a - prefix means descending.

GET /books?sort=title          alphabetical A-Z (ascending)
GET /books?sort=-publishedAt   newest first (descending)

The ALLOWED_SORTS object defined above the route is critical. We never use the sort parameter directly to access arbitrary fields. Instead, we use it as a key to look up a safe, pre-defined comparator function:

const sortFn = ALLOWED_SORTS[sortParam ?? "-createdAt"] ?? ALLOWED_SORTS["-createdAt"];
result.sort(sortFn);

If the client sends a sort value that’s not in the whitelist, we fall back to the default (-createdAt). No error, no unexpected behavior. Just a sensible default.

[!WARNING] Never use the sort parameter to dynamically access object fields. Without a whitelist like ALLOWED_SORTS, an attacker could send ?sort=__proto__ or other crafted values and access fields you never intended to expose. The whitelist maps user input to known, safe sort functions, keeping the API surface controlled.

Try it:

# Alphabetical by title
curl "http://localhost:3000/books?sort=title"

# Newest publication date first
curl "http://localhost:3000/books?sort=-publishedAt"

# Oldest first
curl "http://localhost:3000/books?sort=createdAt"

Searching

Full-text search uses a q parameter. This convention goes back to the earliest web search engines and is instantly recognizable.

if (q) {
  const lower = q.toLowerCase();
  result = result.filter(
    (b) => b.title.toLowerCase().includes(lower) || b.genre.toLowerCase().includes(lower),
  );
}

We convert both the search term and the fields to lowercase so the search is case-insensitive. ?q=sea matches “The Old Man and the Sea” because “sea” appears in the title.

For a real production app, you’d want something more powerful, like a dedicated search engine such as Elasticsearch or Meilisearch. Simple string matching gets slow on large datasets and doesn’t support features like typo tolerance or relevance ranking. But for our bookstore with a few hundred books, this works fine.

Try it:

# Search by title
curl "http://localhost:3000/books?q=sea"

# Search by genre
curl "http://localhost:3000/books?q=fiction"

# Case-insensitive
curl "http://localhost:3000/books?q=WIZARD"

Combining everything

The beauty of this approach is that everything composes. Each query parameter is independent and optional:

# Fiction books, sorted by title, matching "the", limited to 3
curl "http://localhost:3000/books?genre=fiction&sort=title&q=the&limit=3"

This returns fiction books, sorted alphabetically, matching “the” in the title, limited to 3 results. The client can use any combination of these parameters, and they all work together.

Conventions worth following

Use field names as filter keys. ?genre=fiction, not ?filterBy=genre&filterValue=fiction. The first is readable and intuitive. The second is a generic framework pattern that nobody enjoys using.

Use - prefix for descending sort. ?sort=-publishedAt is concise and widely understood. JSON:API, MongoDB, and many popular APIs use this convention.

Use q for search. Short, standard, and immediately understood by every developer.

Ignore unknown parameters. If the client sends ?color=blue and books don’t have a color field, just ignore it. Don’t return an error. This keeps your API forward-compatible and forgiving.

What’s next

Our API is getting powerful: pagination, filtering, sorting, searching. But we’ve been building on /books this whole time. What happens when we need to change the API in a way that would break existing consumers? That’s where versioning comes in.

Exercises

Exercise 1: Add genre and authorId filters to GET /books. Test combining them.

Exercise 2: Add sorting with the - prefix convention. Test ascending and descending.

Exercise 3: Add a q search parameter that searches the title field. Test with partial matches.

Exercise 4: Try combining all three: GET /books?genre=fiction&sort=title&q=the. Verify the result is filtered, sorted, and searched.

Why do we use a whitelist for sort fields instead of accepting any field name?

← Pagination Versioning →

© 2026 hectoday. All rights reserved.