Skip to main content
When list state lives in the URL — /products?category=lamps&page=2 — pages become shareable, the back button works, and the server has everything it needs on the first request. This recipe shows how to build that page in the App Router with Esix doing the heavy lifting. Esix features used: paginate, where, whereIn, orderBy. Next.js features used: searchParams prop on async server components.

What You’ll Build

A page at /products that reads filters from the URL, runs a single Esix query, and renders the results plus next/prev links:

The Product Model

Parsing Search Params

searchParams arrives as Record<string, string | string[] | undefined> — narrow it to a typed shape before touching Esix. A zod schema gives you the parsing, validation, and the TypeScript type from a single definition:
The same parsing strategy you’d use in an Express endpoint — typed shape in, defaults applied, upper bound on perPage. z.coerce.number() is the key piece: every search param arrives as a string, and the schema converts and validates in one step.

The Page Component

searchParams is passed directly to the page as a prop. Build the query conditionally and call paginate once:
paginate returns everything you need to render the navigation — page, lastPage, total, perPage, and the page of data itself. Pager links should preserve every other filter — drop the user back where they were, just on a different page. A tiny helper rebuilds the query string:
<Link> does client-side navigation when possible, but on the server side the page re-runs with the new searchParams, the Esix query re-runs, and the new HTML streams down. The catalog stays fully indexable and shareable.

Filter UI

A plain HTML form is enough — point it at the current page with method="get" and the browser does the URL building for you:
No client state, no useState hook — submitting reloads the page with the new search params, which is exactly what we want.

Pattern Notes

  • URL is the state machine. Every filter that influences what the page shows belongs in searchParams. Server components re-render on URL change for free.
  • Cap perPage. z.coerce.number().max(100) does the work — a client setting ?perPage=1000000 would otherwise be the path of least resistance to an OOM.
  • paginate over hand-rolled skip/limit. You get total and lastPage back, which is exactly what the Pager component needs — and Esix validates the inputs for you.

What’s Next