Skip to content
Elara One docs

Build a product listing page

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

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

ParameterExampleWhat it does
filter[collection]filter[collection]=lightingProducts in the collection with this slug.
filter[search]filter[search]=lampA simple text match. For instant search with suggestions, use GET /v1/search.
filter[price_min]filter[price_min]=2000At least one variant's base price is at or above this amount, in minor units.
filter[price_max]filter[price_max]=9000At least one variant's base price is at or below this amount, in minor units.
filter[attr][<handle>]filter[attr][finish]=oakThe product attribute with this handle equals this value.
sortsort=-pricename, created_at or price. Prefix with - for descending.
includeinclude=variants.prices,images,offersRelated data to embed. See below.
perPageperPage=24Page size. Default 24, maximum 100.
pagepage=2Which 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 are a nested array keyed by the attribute's handle, as set in the store admin:

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

IncludeAdds
variantsEach variant's id, sku, stock and shippable.
variants.pricesVariants plus price and pricing (sale price, "was" price, saving and badges).
variants.optionsEach variant's option values and the product's options list, for an option picker.
imagesid, name, url and thumbnail_url for each image.
collectionsThe collections the product belongs to, each with its slug.
offersThe 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

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

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"

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

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

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.

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.

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.

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.

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

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

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

StatusCodeCause
400HTTP_ERRORA filter, sort or include that is not on the allow-list.
404NOT_FOUNDAn unknown collection slug on GET /v1/collections/{slug}/products.
429RATE_LIMITEDToo 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.