Product lists
Build PLPs, related-product rails, search grids and carousels with the filterator query language and the products/categories list APIs.
Every product surface in a theme — the /products collection page, a "you may also like"
rail, a bestsellers carousel, a search-as-you-type grid — reduces to one thing: a
custom component calling GET /products with the right query.
This page covers the two query surfaces, how a list component is wired into
TemplateData, and the recipes that cover almost every PLP design.
Two ways to query products
A) Flat query params → q, category_id, price_min, in_stock, order_by, page, limit, …
B) Filterator JSON → nested AND/OR tree + ordering + paging (?filterator=<json>)Prefer flat params for simple UIs; reach for the filterator when you need nested
OR/AND logic, inList over many ids, null checks, or multi-field sorts. Categories accept
flat params only — there is no filterator for categories.
Two invariants, always:
- Pass
store_idon every list call (fromuseStore().store.id). - Filter server-side (
in_stock,price_min, filterator) — never fetch the whole catalog and filter in JS.
Fetching from theme code
Inside a custom component, the injected scope gives you the SDK and store context — no imports. The canonical fetch skeleton:
function App() {
const ff = useFeeef();
const storeCtx = useStore();
const store = storeCtx?.store;
const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
let alive = true;
if (!ff || !store?.id) { setLoading(false); return; }
setLoading(true);
ff.products.list({
params: { store_id: store.id, limit: 12, in_stock: true, order_by: "sold:desc" },
}).then((res) => {
if (!alive) return;
setItems(Array.isArray(res) ? res : (res?.data ?? []));
setLoading(false);
}).catch(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [ff, store?.id]);
if (loading) return <div>…</div>;
return (
<div>
{items.map((p) => (
<RouterNav key={p.id} href={"/products/" + (p.slug || p.id)}>
<img src={p.photoUrl || (p.media && p.media[0]) || ""} alt={p.name || ""} />
<span>{p.name}</span>
</RouterNav>
))}
</div>
);
}Note the null checks (ff and store can be null), the cancel flag, and the response
normalization — some client paths return { data, meta }, others a bare array.
Flat params reference
GET /products accepts (camelCase aliases exist for most):
| Param | Also | Meaning |
|---|---|---|
store_id | storeId | Required on the storefront |
q | search | Search name, slug, id, description, sku |
category_id | categoryId | Category id (preferred) |
category_slug | categorySlug | Embedded slug |
price_min / price_max | priceMin / priceMax | Price range |
stock_min / stock_max | Stock range | |
in_stock | inStock | true → stock above zero; !true → out of stock |
has_media / has_photo | Media / cover present | |
sku / barcode | Partial match | |
sold_min | Minimum sold (or true → sold above zero) | |
created_after / created_before | ISO dates (same for updated_*) | |
ids | products | Whitelist of ids |
status / statuses | published / draft / archived; !published excludes | |
random | ORDER BY RANDOM() (products with media) | |
order_by | orderBy | field:dir, e.g. price:desc, created_at:desc |
page / limit | Pagination | |
filterator | JSON string — next section |
The JS SDK's typed options minPrice / maxPrice serialize to min_price / max_price,
which the products endpoint does not implement (it expects price_min / price_max).
Same for sortBy → sort_by (the endpoint expects order_by). In theme code, pass the
backend names through params: params: { store_id, price_min: 1000, order_by: "sold:desc" }.
The filterator
For arbitrary conditions, send a URL-encoded JSON query as the filterator param:
{
"filtering": {
"condition": "or",
"filters": [
{ "field": "name", "operation": "contains", "value": "shirt" }
],
"groups": [
{
"condition": "and",
"filters": [
{ "field": "price", "operation": "greaterThan", "value": 1000 },
{ "field": "sold", "operation": "greaterThan", "value": 10 }
]
}
]
},
"ordering": [{ "field": "sold", "dir": "desc" }],
"paging": { "limit": 24, "offset": 0 }
}Meaning: (name contains "shirt") OR (price above 1000 AND sold above 10), sorted by sold descending. Via the SDK:
ff.products.list({
params: {
store_id: store.id,
filterator: JSON.stringify(query),
},
});Rules that keep filterator queries valid:
- Operators — prefer the long names:
equals,notEquals,contains,startsWith,endsWith,greaterThan,greaterOrEqual,lessThan,lessOrEqual,inList,notIn,isNull,isNotNull. Short aliases (eq,gt,in, …) exist on the products endpoint, but long names work everywhere. Full operator table: API conventions. inList/notIntakevalues: [...](an array), notvalue.- Fields are camelCase and mapped to snake_case columns (
categoryId→category_id,createdAt→created_at). One exception:type/productTypeboth map to the columntype. - Useful fields:
id,slug,name,sku,price,cost,discount,stock,sold,views,likes,status,type,categoryId,createdAt,updatedAt,photoUrl. - The filterator is a query language, not auth — results stay scoped by
store_idand backend policies (guests only see published products).
Wiring a list into TemplateData
A list component is a normal custom node: the query knobs live in propsSchema so the
merchant can tune them in the editor, and the code string does the fetch. For example, a
"featured grid" node:
{
"type": "custom",
"instanceId": "custom_featured_grid_home",
"title": "Featured products",
"props": { "categoryId": "", "limit": 8, "onlyInStock": true },
"propsSchema": {
"categoryId": { "type": "string", "name": "Category ID" },
"limit": { "type": "number", "tool": { "type": "slider", "min": 4, "max": 24, "step": 4 }, "name": "Products" },
"onlyInStock": { "type": "boolean", "name": "Hide out of stock" }
},
"code": "function App() { /* read props.categoryId, props.limit … then ff.products.list(...) */ }"
}Inside App(), build the params from props:
ff.products.list({
params: {
store_id: store.id,
limit: props.limit || 8,
category_id: props.categoryId || undefined,
in_stock: props.onlyInStock ? true : undefined,
order_by: "sold:desc",
},
});In the kit you author this as a flat .tsx file with
export const meta carrying the propsSchema — the build compiles it to the node above.
Recipes
PLP with URL-driven filters
Read filters from the URL so category chips, search and pagination are shareable links:
const searchParams = useSearchParams();
const categoryId = searchParams?.get("category_id") || "";
const q = searchParams?.get("q") || "";
const page = Number(searchParams?.get("page") || 1);
ff.products.list({
params: {
store_id: store.id,
limit: 24,
page,
category_id: categoryId || undefined,
q: q || undefined,
search: q || undefined, // dual-send for compat
in_stock: true,
price_min: priceFrom || undefined,
price_max: priceTo || undefined,
order_by: "sold:desc",
},
});On chip select, router.push the new query string and refetch.
Related products
There is no dedicated related-products endpoint. The pattern is same-category, exclude the current product:
const { product } = useCurrentProduct();
const res = await ff.products.list({
params: {
store_id: store.id,
category_id: product?.categoryId,
limit: 8,
in_stock: true,
},
});
const related = (res?.data || []).filter((p) => p.id !== product.id);Fallbacks when the category is empty: ff.products.random(8) or bestsellers via
order_by: "sold:desc".
Price band + bestsellers (filterator)
"Bestsellers under 5000":
filterator: JSON.stringify({
filtering: {
condition: "and",
filters: [
{ field: "price", operation: "lessOrEqual", value: 5000 },
{ field: "stock", operation: "greaterThan", value: 0 },
{ field: "status", operation: "equals", value: "published" },
],
},
ordering: [{ field: "sold", dir: "desc" }],
paging: { limit: 12 },
})Multi-category spotlight
{ "field": "categoryId", "operation": "inList", "values": ["idA", "idB", "idC"] }New arrivals with photos
params: { store_id: store.id, has_media: true, order_by: "created_at:desc", limit: 12 }Search as you type
Debounce the input (300 ms or more), keep limit modest (12–24), send both q and
search, and show a skeleton while loading.
Infinite scroll / pagination
Track page in React.useState, append res.data, and stop when meta.lastPage (or
meta.total) is reached. The paginated envelope is
{ data: [...], meta: { total, currentPage, perPage, … } } — see
API conventions.
Categories
Flat params only:
GET /categories?store_id=…&parent_id=…&page&limit
GET /categories/tree?store_id=…parent_id: null (or the string 'null') returns root categories. SDK helpers:
ff.categories.list({ storeId: store.id, parentId: null, limit: 50 });
ff.categories.listRootCategories(store.id);
ff.categories.listChildren(store.id, parentId);Typical PLP nav: load roots as chips, on select push ?category_id= to the URL, refetch
products.
Reading the product
Fields you will actually render:
| Field | Type | Notes |
|---|---|---|
id / slug | string | Link to /products/ + (slug or id) |
name | string or null | |
photoUrl | string or null | Cover image |
media | string[] | Plain URL strings — not { url } objects |
price / discount / stock / sold | number | Prices display with the store currency symbol |
categoryId | string | Drives related-products |
offers / variant / addons | Upsell / COD structures | |
body | markdown | Render via the product_body registry component only |
Do / Don't
| Do | Don't |
|---|---|
Null-check ff / store before fetching | Assume hooks always return data |
store_id: store.id on every list | Omit store scope |
Server filters (in_stock, price_min, filterator) | Fetch 200 rows and filter in JS |
Treat media as string[] | product.media[0].url |
| Cap decorative carousels at 8–24 items | limit: 500 for a rail |
React.useEffect with deps + cancel flag | Fire the fetch on every render |
Link / RouterNav for product links | Raw anchor tags for in-app navigation |