.dev
Storefront templates

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_id on every list call (from useStore().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):

ParamAlsoMeaning
store_idstoreIdRequired on the storefront
qsearchSearch name, slug, id, description, sku
category_idcategoryIdCategory id (preferred)
category_slugcategorySlugEmbedded slug
price_min / price_maxpriceMin / priceMaxPrice range
stock_min / stock_maxStock range
in_stockinStocktrue → stock above zero; !true → out of stock
has_media / has_photoMedia / cover present
sku / barcodePartial match
sold_minMinimum sold (or true → sold above zero)
created_after / created_beforeISO dates (same for updated_*)
idsproductsWhitelist of ids
status / statusespublished / draft / archived; !published excludes
randomORDER BY RANDOM() (products with media)
order_byorderByfield:dir, e.g. price:desc, created_at:desc
page / limitPagination
filteratorJSON 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 sortBysort_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 / notIn take values: [...] (an array), not value.
  • Fields are camelCase and mapped to snake_case columns (categoryIdcategory_id, createdAtcreated_at). One exception: type / productType both map to the column type.
  • 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_id and 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.

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:

FieldTypeNotes
id / slugstringLink to /products/ + (slug or id)
namestring or null
photoUrlstring or nullCover image
mediastring[]Plain URL strings — not { url } objects
price / discount / stock / soldnumberPrices display with the store currency symbol
categoryIdstringDrives related-products
offers / variant / addonsUpsell / COD structures
bodymarkdownRender via the product_body registry component only

Do / Don't

DoDon't
Null-check ff / store before fetchingAssume hooks always return data
store_id: store.id on every listOmit 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 itemslimit: 500 for a rail
React.useEffect with deps + cancel flagFire the fetch on every render
Link / RouterNav for product linksRaw anchor tags for in-app navigation

On this page