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.
| 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 are a nested array keyed by the attribute's handle, as set in the store admin:
filter[attr][finish]=oak&filter[attr][size]=largeEach 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
| 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
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.
{
"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.
{
"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.
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'sbadge. - Price: show
pricing.sale_pricewithpricing.pricestruck through whenpricing.on_saleistrue. Showpricing.compare_priceas a "was" price only when it is notnull. - Offers strip: offers with
scopeset toallapply store-wide.GET /v1/promotionslists the store's live public offers for a banner. - Countdown:
ends_atis an ISO 8601 time, ornullfor 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
| 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.
Versioning and changes
How the API is versioned, which changes we treat as breaking and which as additive, how to write clients that keep working, and where changes are announced.
Checkout with Stripe
Take a card payment end to end, from cart to placed order, with the Stripe Payment Element and exactly-once order placement.