--- title: Sorting and pagination description: Order discovery results with sort, and page through them with cursors — including what each pagination_status value means. slug: discovery/sorting-and-pagination docKind: guide hub: luigisbox-ai --- Search and collections results are ordered and paged. Recommendations are neither; a recommender returns one fixed-size slate. ## Sorting One body field, an object with a `field` and a `direction`: ```json { "sort": { "field": "price", "direction": "asc" } } { "sort": { "field": "created_at", "direction": "desc" } } { "sort": { "field": "lbx:price", "direction": "asc" } } ``` There is no multi-field sort. A namespaced field such as `lbx:price` is written as-is. | Direction | Meaning | |---|---| | `asc` | Smallest or earliest first | | `desc` | Largest or latest first | Both keys are required. Omitting `direction`, or sending anything but `asc` / `desc`, is a `422`. ### Relevance is the default With no `sort`, results come back in the surface's own ranked order — relevance for a query, the surface's configured ordering for a collection. Use it as the default option in a sort dropdown: ```js const SORT_OPTIONS = [ { label: 'Relevance', value: undefined }, // omit the field entirely { label: 'Price, low to high', value: { field: 'price', direction: 'asc' } }, { label: 'Price, high to low', value: { field: 'price', direction: 'desc' } }, { label: 'Newest', value: { field: 'created_at', direction: 'desc' } }, ]; ``` Omit `sort` for relevance rather than sending `null`. An explicit `sort` replaces ranking rather than refining it: the sort field is the only ordering applied. ### A field is sortable if you can see it If a field is not returned on a hit, do not sort by it. Sorting on a text field sorts lexically, which is why `price` stored as `"9.90"` sorts before `"10.00"` — check the field's type with [catalog metadata](/indexing/catalog-metadata/) if an ordering looks wrong. ## Pagination Discovery pages with an opaque **cursor**, not with page numbers. A cursor keeps page 2 consistent with the page 1 the shopper saw. ```bash # First page curl -X POST 'https://api.eu1.luigisbox.ai/discovery/v1/search?channel_id=lbn_4hj9tv&surface_id=lbs_search_main' \ -H 'Authorization: Bearer ' \ -H 'X-Lbx-Visitor-Id: 8f14e45f-…' \ -H 'Content-Type: application/json' \ -d '{ "type": "product", "query": "running shoes", "size": 24 }' ``` The response carries the cursor for the page after it: ```json { "hits": [ "…24 hits…" ], "total": 412, "next_page_cursor": "eyJvIjoyNCwic…", "pagination_status": "more" } ``` Pass it back verbatim as `cursor`, with every other field unchanged: ```json { "type": "product", "query": "running shoes", "size": 24, "cursor": "eyJvIjoyNCwic…" } ``` Cursors are opaque. Do not decode, edit or construct one. For the first page, leave `cursor` out (or send `null`) — an empty string is a `422`. ### Reading `pagination_status` `pagination_status` says why `next_page_cursor` is or is not set: | Value | Meaning | What to do | |---|---|---| | `more` | More pages exist, and `next_page_cursor` is set | Keep paging | | `exhausted` | That was the last page | Stop | | `unsupported` | This result set cannot be paged at all | Stop — retrying will not help | | `unavailable` | Pagination state could not be saved this time | Re-issuing the search may succeed | Branch on `pagination_status`, not on the cursor's presence: ```js async function* pages(body) { const url = `${API}/discovery/v1/search?channel_id=${channelId}&surface_id=${surfaceId}`; let cursor; for (;;) { const page = await fetch(url, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ ...body, ...(cursor && { cursor }) }), }).then((r) => r.json()); yield page; if (page.pagination_status !== 'more') return; cursor = page.next_page_cursor; } } ``` ### A short page is not the last page A page may return fewer than `size` hits **and** still carry a cursor. Results are re-validated after retrieval, and anything that went stale between indexing and serving is dropped — so a page of 24 can arrive with 22 hits. Keep paging until `pagination_status` stops being `more`. Treating a short page as the end silently truncates results. ### Changing anything starts over A cursor belongs to one result set. Change the query, the filters, the sort or the size and the old cursor is rejected with a `422` — drop it and request the first page again. In a UI, reset the cursor whenever a facet is clicked or the sort changes. Order counts as a change. A cursor is bound to the filter tree exactly as you wrote it, so reordering the entries of an `$and` array, the fields inside one condition node, or the values inside an `in` list all produce a different result set as far as pagination is concerned. Build the tree the same way on every page of a sequence — the [`buildFilters`](/discovery/filters/#building-a-filter-from-facet-selections) pattern, run over the same selections, does this. ## Totals and infinite scroll For "412 results", read `total ?? total_approx`: ```js const count = page.total ?? page.total_approx; ``` `total` is exact but can be `null` when the result set is too large to count exactly; `total_approx` is always present. Calling [`/facets`](/discovery/facets/) upgrades `total` to an exact figure, so a page that renders a filter sidebar usually has a real number. For infinite scroll, drive the loop off `pagination_status` and use the total only as a label. If the surface collapses variants, the total counts variant *groups* — five sizes of one shirt count once. ## See also - [Search](/discovery/search/) · [Collections](/discovery/collections/) - [Facets](/discovery/facets/) — refreshing the sidebar on each new result set - [Product variants](/discovery/variants/) — why totals count groups - [Requests and responses](/api-basics/requests-and-responses/#pagination) — page/size pagination on configuration endpoints