# Build a product listing page

URL: /docs/guides/product-listing

List products with collection, price and attribute filters, sorting, pagination and promotion badges, rendered on your server.

A product listing page is one call to `GET /v1/products` (or `GET /v1/collections/{slug}/products`) with filters, a sort and a page number taken from the page's own URL. This guide builds one on the server, with a price slider fed by `GET /v1/products/price-bounds` and promotion badges from `include=offers`.

## What the listing endpoints accept [#what-the-listing-endpoints-accept]

`GET /v1/products` and `GET /v1/collections/{slug}/products` share one set of query parameters. Only published products are ever returned.

| Parameter | Example | What it does |
|---|---|---|
| `filter[collection]` | `filter[collection]=lighting` | Products in the collection with this slug. |
| `filter[search]` | `filter[search]=lamp` | A simple text match. For instant search with suggestions, use `GET /v1/search`. |
| `filter[price_min]` | `filter[price_min]=2000` | At least one variant's base price is at or above this amount, in minor units. |
| `filter[price_max]` | `filter[price_max]=9000` | At least one variant's base price is at or below this amount, in minor units. |
| `filter[attr][<handle>]` | `filter[attr][finish]=oak` | The product attribute with this handle equals this value. |
| `sort` | `sort=-price` | `name`, `created_at` or `price`. Prefix with `-` for descending. |
| `include` | `include=variants.prices,images,offers` | Related data to embed. See below. |
| `perPage` | `perPage=24` | Page size. Default 24, maximum 100. |
| `page` | `page=2` | Which page. |

An unknown filter, sort or include is refused with a `400`, so a typo shows up straight away instead of being ignored.

### Attribute filters [#attribute-filters]

Attribute filters are a nested array keyed by the attribute's handle, as set in the store admin:

```text
filter[attr][finish]=oak&filter[attr][size]=large
```

Each handle is an exact match on the stored value, ignoring case, and several handles are combined with AND. For a translated attribute the value is matched against the current locale's text. There is no "any of" form: to offer "oak or walnut", make two requests or model the choice as a collection.

### Includes [#includes]

| Include | Adds |
|---|---|
| `variants` | Each variant's `id`, `sku`, `stock` and `shippable`. |
| `variants.prices` | Variants plus `price` and `pricing` (sale price, "was" price, saving and badges). |
| `variants.options` | Each variant's option values and the product's `options` list, for an option picker. |
| `images` | `id`, `name`, `url` and `thumbnail_url` for each image. |
| `collections` | The collections the product belongs to, each with its `slug`. |
| `offers` | The promotions the product advertises: badges, banners and bulk price tables. |

For a listing card you usually want `include=variants.prices,images,offers`. `GET /v1/products/{slug}` always returns every include, offers too.

## Fetch the page on the server [#fetch-the-page-on-the-server]

Call the engine from your server with both the store key and the server secret. The secret must never reach the browser.

```bash tab="cURL"
curl -G https://api.elaraone.app/api/v1/products \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
  --data-urlencode "filter[collection]=lighting" \
  --data-urlencode "filter[price_max]=9000" \
  --data-urlencode "filter[attr][finish]=oak" \
  --data-urlencode "sort=price" \
  --data-urlencode "include=variants.prices,images,offers" \
  --data-urlencode "perPage=24" \
  --data-urlencode "page=1"
```

```ts tab="TypeScript"
const ENGINE = 'https://api.elaraone.app/api/v1'

export async function engineGet<T>(path: string, query: URLSearchParams): Promise<T> {
  const res = await fetch(`${ENGINE}${path}?${query}`, {
    headers: {
      Accept: 'application/json',
      'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!,
      'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!,
    },
  })
  if (!res.ok) {
    const body = await res.json().catch(() => null)
    throw new Error(body?.error?.code ?? `HTTP_${res.status}`)
  }
  return res.json() as Promise<T>
}
```

```php tab="PHP"
$query = http_build_query([
    'filter' => [
        'collection' => 'lighting',
        'price_max' => 9000,
        'attr' => ['finish' => 'oak'],
    ],
    'sort' => 'price',
    'include' => 'variants.prices,images,offers',
    'perPage' => 24,
    'page' => 1,
]);

$response = Http::withHeaders([
    'X-Tenant-Key' => env('ELARAONE_TENANT_KEY'),
    'X-Tenant-Secret' => env('ELARAONE_TENANT_SECRET'),
])->acceptJson()->get("https://api.elaraone.app/api/v1/products?{$query}");
```

The response is a page of products, with Laravel's `links` and `meta` for pagination. Money is always integer minor units.

```json title="Response (trimmed)"
{
  "data": [
    {
      "id": 101,
      "status": "published",
      "name": "Oak desk lamp",
      "description": "<p>Turned oak base, linen shade.</p>",
      "slug": "oak-desk-lamp",
      "variants": [
        {
          "id": 412,
          "sku": "LAMP-OAK",
          "stock": 14,
          "shippable": true,
          "price": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" },
          "pricing": {
            "price": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" },
            "sale_price": { "amount": 7565, "currency": "GBP", "formatted": "£75.65" },
            "compare_price": null,
            "on_sale": true,
            "saving": {
              "amount": { "amount": 1335, "currency": "GBP", "formatted": "£13.35" },
              "percent_bp": 1500
            },
            "badges": [{ "label": "15% off", "kind": "product_adjustment" }]
          }
        }
      ],
      "images": [
        {
          "id": 9,
          "name": "oak-desk-lamp",
          "url": "https://…/oak-desk-lamp.jpg",
          "thumbnail_url": "https://…/oak-desk-lamp-small.jpg"
        }
      ],
      "offers": [
        {
          "id": 7,
          "kind": "product_adjustment",
          "label": "Autumn lighting sale",
          "badge": "15% off",
          "banner": null,
          "bulk_table": null,
          "bulk_table_variant_id": null,
          "ends_at": "2026-11-01T00:00:00Z",
          "scope": "collection"
        }
      ]
    }
  ],
  "links": { "first": "…", "last": "…", "prev": null, "next": "…" },
  "meta": { "current_page": 1, "last_page": 3, "per_page": 24, "total": 61 }
}
```

`saving.percent_bp` is in basis points: `1500` is 15%.

## Add a price slider [#add-a-price-slider]

`GET /v1/products/price-bounds` returns the lowest and highest base price across the products the same listing would show. Forward the listing's query string unchanged: it honours `filter[collection]`, `filter[search]` and `filter[attr]`, and deliberately ignores `filter[price_min]` and `filter[price_max]`, so the slider never shrinks to the range the shopper just picked.

```json title="GET /v1/products/price-bounds?filter[collection]=lighting"
{
  "data": {
    "currency": "GBP",
    "min": { "amount": 1800, "currency": "GBP", "formatted": "£18.00" },
    "max": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" }
  }
}
```

`min` and `max` are `null` when no product in the set has a price. Their `amount` values are exactly what `filter[price_min]` and `filter[price_max]` take.

<Callout type="info" title="Base price, not sale price">
Price filters and bounds use each variant's base price. A product on promotion is matched on its normal price, so show the sale price on the card but filter on the base price.
</Callout>

## Keep filters in the URL [#keep-filters-in-the-url]

Put the shopper's choices in your page's own URL (`/collections/lighting?finish=oak&max=9000&sort=price&page=2`) and translate them into engine parameters on the server. The page can then be shared, bookmarked, crawled and rendered without JavaScript.

```ts title="app/collections/[slug]/page.tsx"
import { engineGet } from '@/lib/engine'

type Money = { amount: number; currency: string; formatted: string }
type PriceBounds = { currency: string | null; min: Money | null; max: Money | null }
type ProductPage = {
  data: Array<{ id: number; name: string; slug: string }>
  meta: { current_page: number; last_page: number; total: number }
}

const SORTS = new Set(['name', '-name', 'created_at', '-created_at', 'price', '-price'])
const ATTRIBUTES = ['finish', 'size'] // the attribute handles you offer as filters

function engineQuery(slug: string, params: Record<string, string | string[] | undefined>) {
  const one = (key: string) => (typeof params[key] === 'string' ? (params[key] as string) : undefined)
  const q = new URLSearchParams({ include: 'variants.prices,images,offers', perPage: '24' })

  q.set('filter[collection]', slug)
  const min = one('min')
  const max = one('max')
  if (min && /^\d+$/.test(min)) q.set('filter[price_min]', min)
  if (max && /^\d+$/.test(max)) q.set('filter[price_max]', max)
  for (const handle of ATTRIBUTES) {
    const value = one(handle)
    if (value) q.set(`filter[attr][${handle}]`, value)
  }
  const sort = one('sort')
  if (sort && SORTS.has(sort)) q.set('sort', sort)
  const page = one('page')
  if (page && /^\d+$/.test(page)) q.set('page', page)
  return q
}

export default async function CollectionPage(props: {
  params: Promise<{ slug: string }>
  searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
  const { slug } = await props.params
  const query = engineQuery(slug, await props.searchParams)

  const [products, bounds] = await Promise.all([
    engineGet<ProductPage>('/products', query),
    engineGet<{ data: PriceBounds }>('/products/price-bounds', query),
  ])

  // Render the grid from products.data, the slider from bounds.data,
  // and pagination from products.meta.current_page and products.meta.last_page.
}
```

Only pass on values you recognise. An allow-list of sorts and attribute handles keeps a hand-edited URL from turning into a `400`.

## Show promotion badges [#show-promotion-badges]

With `include=offers` each product carries `offers`, and each variant's `pricing` carries `badges`. A common pattern:

- **Card badge:** the first variant's `pricing.badges[0].label`, or the first offer's `badge`.
- **Price:** show `pricing.sale_price` with `pricing.price` struck through when `pricing.on_sale` is `true`. Show `pricing.compare_price` as a "was" price only when it is not `null`.
- **Offers strip:** offers with `scope` set to `all` apply store-wide. `GET /v1/promotions` lists the store's live public offers for a banner.
- **Countdown:** `ends_at` is an ISO 8601 time, or `null` for an open-ended offer.

## Collections for navigation [#collections-for-navigation]

`GET /v1/collections` returns a flat, paginated list in tree order, each with `id`, `parent_id`, `name` and `slug`. Add `include=children` to get each collection's children nested. `GET /v1/collections/{slug}` returns one collection and answers `404 NOT_FOUND` for an unknown slug.

## What can go wrong [#what-can-go-wrong]

| Status | Code | Cause |
|---|---|---|
| `400` | `HTTP_ERROR` | A filter, sort or include that is not on the allow-list. |
| `404` | `NOT_FOUND` | An unknown collection slug on `GET /v1/collections/{slug}/products`. |
| `429` | `RATE_LIMITED` | Too many requests. Wait for `Retry-After` seconds. |

Catalogue responses are cached by the engine per store and query, and the cache is cleared when the merchant edits a product, price or collection, so you do not need your own short-lived cache to stay fast.
