Skip to main content
Catalog and listing endpoints almost always accept a grab bag of filters from the URL — category, price range, sort, search term. This recipe shows how to turn untrusted query strings into a safe, chained Esix query without writing your own ad-hoc SQL-like parser. Esix features used: where with comparison operators, whereIn, orderBy, search, and paginate.

What You’ll Build

Every parameter is optional. Any combination should compose cleanly.

The Product Model

Note: Full-text search requires a text index on the fields you want to search (typically name and description). Create it once with db.products.createIndex({ name: 'text' }).

Parsing the Query Safely

The trick is to translate raw query params into a typed shape before touching Esix. A zod schema gives you that translation, validation, and the TypeScript type all in one definition:
Two important guards live here: z.enum(['price', 'name', 'createdAt']) is an allowlist of sortable fields (so callers can’t sort by passwordHash), and max(100) is an upper bound on perPage (so they can’t ask for a million records). z.coerce.number() handles the fact that query string values arrive as strings.

Building the Query

Apply each filter conditionally. Esix’s fluent interface keeps this readable:
A few patterns worth pointing out:
  • search is the first link in the chain. When the request includes a search term, it kicks off the query; otherwise you start with a where. Both return a QueryBuilder, so the rest of the chain doesn’t care.
  • whereIn handles array params naturally. The schema normalises both ?category=lamps and ?category=lamps&category=desks into a string array, which is exactly what whereIn wants.
  • Comparison operators handle the price range. where('price', '>=', minPrice) and the matching <= give you a numeric between without leaving the query builder.

Trying It Out

A search request looks identical from the client:

Pattern Notes

  • Always parse before you query. ProductFiltersSchema.parse(...) is the cleanest way to keep injection-style bugs out of your handlers — and you get the inferred TypeScript type as a bonus.
  • Allowlist sortable fields. z.enum([...]) is the smallest, highest-value piece of validation you can add to a listing endpoint.
  • Sensible defaults beat optional everything. zod’s .default(...) keeps every filter sensible when the client sends nothing — your tests and your front-end will thank you.

What’s Next