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?