Products & Search
Browse, filter, and search products
List Products
const result = await client.catalog.getProducts({
page: 1,
limit: 24,
sort: 'price_asc',
inStock: true,
});
// { items: ProductListItem[], total, page, totalPages }Filter Options
| Param | Type | Description |
|---|---|---|
page | number | Page number (1-based) |
limit | number | Items per page (default 24) |
category | string | Filter by category slug |
label | string | Filter by label slug |
priceMin | number | Minimum price |
priceMax | number | Maximum price |
sort | string | price_asc, price_desc, name_asc, name_desc, newest, featured, bestselling, rating |
inStock | boolean | Only in-stock items |
ratingMin | number | Minimum aggregate rating (e.g. 4 for "4 and up") |
search | string | Full-text search |
ids | string[] | Filter by product IDs |
slugs | string[] | Filter by slugs |
labels | string[] | Multiple labels (OR) |
categories | string[] | Multiple categories (OR) |
hasDiscount | boolean | Only discounted products |
isFeatured | boolean | Only featured products |
parameters | object | Parameter filters, keyed by parameter slug. A value may be an array = multi-select (OR within the key): { "material": "bavlna", "barva": ["cerna", "bila"], "hmotnost_min": 100 }. Only parameters the merchant marked filterable are accepted |
facets | object | Slug-based facet selection for SEO URLs: parameter slug → value slugs, e.g. { "barva": ["cerna"] }. Resolved server-side to the underlying values |
priceMin/priceMax filter the customer's actual selling price in the requested
currency. bestselling sorts by units sold; rating by aggregate review score.
Product Detail
const product = await client.catalog.getProduct('wireless-headphones', {
locale: 'cs',
currency: 'CZK',
});Always build the detail URL from the slug the list returned. That value is the
product's per-locale slug, the single source of truth for product URLs; it falls
back to the product id only when the merchant has not set a slug in any
language. getProduct accepts the per-locale slug and the product id alike, so a
link built from product.slug always resolves and older id-based links keep
working.
Variants
When a product has variants (colour, size, …), product.variants lists them.
Each variant is its own sellable product, so variant.id is an eshop product id
with its own price and stock. Render a picker and pass the chosen variant.id
straight to cart.addItem({productId}):
product.variants.map((v) => ({
id: v.id, // eshop product id, use as productId when adding to cart
name: v.name,
sku: v.sku,
price: v.price, // variant's own price (null for live-quote products)
inStock: v.inStock,
attributes: v.attributes, // e.g. [{name: 'Color', value: 'Blue'}]
}));Only purchasable variants (a published, enabled eshop product) appear in the list.
A variant carries its own content (SDK 1.1.0)
A variant is a full product row in Behio, so the merchant can give it its own localized name, both descriptions, gallery and SEO. Switching the picker should therefore switch the page, not just the price.
import {
variantFromQuery,
resolveVariantContent,
variantHref,
} from '@behio/storefront-sdk';
// 1. Resolve the selection ON THE SERVER, from ?variant=<variantSlug>.
const variant = variantFromQuery(product, searchParams.variant);
// 2. Merge it onto the parent. Empty variant fields INHERIT the parent, so a
// shop that only differentiated the name never renders an empty block.
const content = resolveVariantContent(product, variant);
content.name; // variant name or the product name
content.shortDescription; // HTML, render through your RichText component
content.longDescription;
content.images; // variant gallery, or the parent's when it has none
// 3. Link through the PARENT, because a variant has no page of its own.
variantHref(product.slug, variant); // /product/tricko?variant=tricko-modreThree rules that are easy to get wrong:
- Resolve on the server. Selecting only in a client component renders the parent's text first and swaps it after hydration: a visible flash, and the wrong text in view-source for crawlers and social previews.
variantSlugis not a URL. The catalog answers 404 for a variant slug and every listing excludes variants, because a variant is bought from the parent's page. Always build/product/{product.slug}?variant={variant.variantSlug}.- Empty means inherit.
shortDescription: nullon a variant is not "no description", it is "the parent's description".
Multi-axis pickers and comparison tables have helpers too:
import {
findVariantByAttributes,
availableAxisValues,
buildVariantComparison,
} from '@behio/storefront-sdk';
findVariantByAttributes(product, {Barva: 'modrá', Velikost: 'M'}); // or null
availableAxisValues(product, 'Velikost', {Barva: 'modrá'}); // ['S', 'M']
buildVariantComparison(product, labels, formatFn); // only rows that DIFFERfindVariantByAttributes returns null while the selection is incomplete or
names a combination the merchant never published, and that is the moment to disable
the buy button, not to add "some" variant to the cart.
Parents sold only through their variants (variantsOnly + priceFrom)
Very often the parent is not a real sellable thing: the shop sells the red shirt and the yellow shirt, and "Tričko" on its own means nothing. The merchant can mark such a parent in the Behio admin, and the catalog then reports it on both the list item and the detail:
const p = await client.catalog.getProduct('tricko', {locale: 'cs'});
p.variantsOnly;
// true = this product CANNOT be bought under its own id. It keeps its card,
// its PDP and its search presence, it is simply not a sellable unit. The
// server enforces it: cart.addItem({productId: p.id}) and the checkout both
// answer 400. `isPurchasable` is already false, so a template that only
// honours that flag disables the right button for free.
p.priceFrom;
// ProductPrice | null. Holds the CHEAPEST variant price, in the requested currency.
// Render "od 990 Kč". `compareAtPrice` carries that variant's strike-through
// price when it has one.What a template is expected to render for such a product:
- headline price:
od {priceFrom}, never a bare number.pricestill carries the parent's own amount, so printing it unlabelled would quote 990 while the shopper's chosen variant charges 1990. - no add-to-cart until a variant is chosen. Once one is, switch the headline to
variant.priceand addvariant.id(notproduct.id) to the cart. - listing cards:
od {priceFrom}too.priceFromis identical on the card and on the PDP by construction.
What "from" means when a variant is out of stock
priceFrom is the cheapest of the variants the PDP lists, regardless of
stock. A sold-out cheapest variant still sets the "od" number.
That is deliberate. Filtering on availability would make the advertised price
jump up and down every time one size sells out and comes back, which breaks the
shopper's price memory and desynchronises XML feeds (Google and Heureka penalise
a landing page whose price differs from the feed). A variant the merchant really
wants gone is unpublished in the admin, and then it disappears from variants[]
and from priceFrom together.
priceFrom is populated for every variant parent, not only variantsOnly
ones, so a template can offer an "od" line on ordinary variant products too.
It is null for products without variants and when prices are gated behind
login in B2B mode.
Availability comes from the variants, not from the parent
Unlike priceFrom, inStock and availability on a variantsOnly parent are
not stock-blind. They are derived from the parent's purchasable variants:
- at least one variant with stock:
inStock: true,availability.code: 'in-stock' - every variant sold out:
inStock: false,availability.code: 'sold-out'
A variantsOnly parent is never stocked itself, since the merchant keeps the
quantities on the variants, so its own warehouse number says nothing about
whether anything is buyable. Reading it produced badges that were right or wrong
by luck: a real shop had a parent sitting at 0 with three variants of 5 pcs each,
and the card advertised "Vyprodáno" on a product available in three editions.
Only variants a shopper can actually buy count, i.e. the same published and
enabled set that variants[] and priceFrom use. An unpublished variant sitting
in the warehouse does not make the parent look available.
Ordinary (non-variantsOnly) products are untouched: they keep deriving
availability from their own stock, and an availability state assigned by the
merchant in the admin still wins over any derivation.
The inStock=true filter, the availability facet counts in /catalog/facets
and the shop-wide "hide sold-out products" behaviour all use the same
derivation, so a parent whose card says "Skladem" is also returned by the
filter and counted as in stock. That was not always true: the filter used to
read the parent's own warehouse number, so a parent with stock in its variants
was dropped from inStock=true results, and in a shop configured to hide
sold-out products it disappeared from the catalog entirely while its own PDP
kept advertising it as available.
Backwards compatibility
variantsOnly and priceFrom are additive, and price is untouched: a
variantsOnly parent still returns its own amount exactly as before. An older
template that knows nothing about this feature renders the parent's own price
and, if it honours isPurchasable, a disabled buy button. That is acceptable
but not correct: the number is unlabelled and therefore misleading. Updating the
template to show "od" is the point of the feature.
inStock and availability are the one exception: on a variantsOnly parent
they now answer from the variants (see above). No template change is needed,
because every template already renders those two fields; they simply stopped
reporting a number nobody maintains. Templates that cached or mirrored the old
value should drop that copy.
Variant display config (swatches)
product.variantAxes carries the merchant's per-axis display configuration
from the admin (Variant Display), so the picker renders exactly the way the
shop configured it: color swatches, image swatches, a dropdown or buttons.
product.variantAxes;
// [
// {
// name: 'Barva', // matches variant.attributes[].name
// displayType: 'SWATCH_COLOR', // 'DROPDOWN' | 'BUTTON' | 'SWATCH_COLOR' | 'SWATCH_IMAGE'
// order: 0,
// values: [
// { value: 'červená', swatchColor: '#e02020', swatchImage: null },
// { value: 'modrá', swatchColor: '#2040e0', swatchImage: null },
// ],
// },
// ]- Join axes to variants by
name+valueagainstvariant.attributes. - An axis the merchant never configured comes back as
BUTTON(render plain buttons, the historical default), so nothing changes until the shop opts in. valuesonly contains values present on purchasable variants, in the admin-defined order. Empty array = the product has no variants.
Colour split: sibling cards (catalogSiblings)
Some shops sell one dress as one card per colour, the way almondmuse.com does:
/products/serena-blue is its own product with its own photos, address and SEO,
and the colour dots are links to sibling products, not an in-page switch.
Turn it on in the admin (E-shop settings, tab Katalog: mode SPLIT_BY_AXIS plus
the axis to split by, overridable per product). The catalog then returns one card
per value of that axis and the PDP carries catalogSiblings:
product.catalogSiblings;
// [
// { productId: '01K…', slug: 'serena-blue', name: 'Serena, modrá',
// axisValue: 'Modrá', swatchColor: '#2040e0', swatchImage: null, isCurrent: true },
// { productId: '01K…', slug: 'serena-pink', name: 'Serena, růžová',
// axisValue: 'Růžová', swatchColor: '#e050a0', swatchImage: null, isCurrent: false },
// ]- Render them as real
<a href>links. They must work without JavaScript, or search engines index one colour and ignore the rest. - Preload the neighbours. Each sibling carries
imageUrl, the cover photo of that colour. Render it in the dot and prefetch it (<link rel="prefetch" as="image">), and mark the linksprefetch. Without both, switching a colour waits for a server round trip and then for the photo, which reads as the page flashing. - Each card carries its own photos. The merchant tags product photos with
the colour they show, so
imageon the card andimageson its detail are that colour's shots, not the parent's cover. Untagged photos stay shared and show up for every colour. - Price and stock are the colour's, not one size's. A card reports
variantsOnly: truewithpriceFrom(cheapest size of that colour) and its stock summed across the colour, and itsvariantsare only that colour's sizes. The split axis is absent fromvariantAxes, because the colour is already chosen by which card the visitor is standing on. - The currently open card is included and flagged
isCurrent, so you can draw the whole row without recomputing it. - The remaining axes (size) stay inside the card as the usual variant picker.
- Empty array on every shop that does not use the split, which is the default.
Which page search engines get (seo.canonicalSlug, seo.noIndex)
The split turns one product into as many public pages as it has colours, so the merchant chooses in the admin (E-shop settings, tab Katalog, Stránky pro vyhledávače) which of them represents the product:
| Choice | Colour card | Parent product | Sitemap |
|---|---|---|---|
| A page for every colour (default) | canonical to itself | noindex, follow | the cards |
| One product page | canonical to the parent | canonical to itself | the parent |
Both fields arrive on product.seo, so a template never has to know the rule:
alternates: {
canonical: product.seo?.canonicalSlug
? `${SITE_URL}/product/${product.seo.canonicalSlug}`
: `${SITE_URL}/product/${product.slug}`,
},
...(product.seo?.noIndex ? {robots: {index: false, follow: true}} : {}),A colour card with no description of its own inherits the parent's short and long description (and SEO description) at read time, so the page is never empty. The title and H1 stay the card's own, because the name already carries the colour.
Sitemap (getSitemap)
Build the sitemap from this call, not from getProducts: which page belongs in
the index is a catalog rule, and a listing does not know it.
const {data} = await behio.catalog.getSitemap();
// { locale: 'cs',
// products: [{slug: 'serena-modra', updatedAt: 1754400000000}, …],
// categories: [{slug: 'saty', updatedAt: …}, …],
// pages: [{slug: 'obchodni-podminky', updatedAt: …}, …] }Products already honour the indexing choice above, categories are the active
ones and pages are the published content pages. updatedAt is epoch ms for
lastmod. Added in SDK 1.4.0.
Product parameters (spec table)
product.parameterGroups is the spec table the shopper sees under the
description. The merchant builds it in the Behio admin: named groups, ordered
rows, localized labels, units. The public API returns only these curated
groups.
Warehouse data groups are internal bookkeeping and never reach the storefront. No data-group id, no field key and no warehouse field type appears in any public payload, and filtering is possible only on parameters the merchant explicitly marked filterable, so a visitor can never filter by something they cannot see on the product.
const { data: product } = await client.catalog.getProduct('tricko-basic', {
locale: 'cs',
});
product?.parameterGroups;
// [
// {
// slug: 'parametry-obleceni',
// name: 'Parametry oblečení',
// parameters: [
// {label: 'Materiál', value: 'bavlna', booleanValue: null, unit: null},
// {label: 'Gramáž', value: '180', booleanValue: null, unit: 'g'},
// {label: 'Délka rukávu', value: '62', booleanValue: null, unit: 'cm'},
// {label: 'Do sušičky', value: null, booleanValue: false, unit: null},
// ],
// },
// {
// slug: 'puvod',
// name: 'Původ',
// parameters: [
// {label: 'Země výroby', value: 'Portugalsko', booleanValue: null, unit: null},
// ],
// },
// ]Contract:
- Array order is the order. Groups and rows arrive sorted the way the
merchant arranged them. There is no
orderfield to sort by. - Empty values are already gone. A parameter without a value is omitted server-side, so a template never renders an empty row and never has to filter.
valueis a preformatted string in the requested language.booleanValueis non-null only for boolean parameters, and thenvalueisnull.unitis a display unit ("cm","g", a currency code,"%") and belongs after the value with a space. It isnullwhen the parameter has none.label,nameand text values are merchant-written and already localized by thelocaleyou requested. Print them as they arrive.- An empty array means the shop has not assigned any parameter group to the product. Render nothing, not an empty heading.
Variants carry their own parameters
variant.parameterGroups has exactly the same shape. A variant carries its own
value wherever it has one (a different length, a different weight) and inherits
the parent's everywhere else, so one renderer serves both the product table and
a per-variant table under the picker.
Dedicated endpoints
The PDP normally needs no extra request, the detail already carries
parameterGroups. The endpoints below are for lazy tabs, comparison pages and
partial re-fetches:
| Endpoint | Returns |
|---|---|
GET /storefront/v1/catalog/products/{slug}/parameters | {groups: ProductParameterGroup[]} |
GET /storefront/v1/catalog/products/{slug}/parameters/{groupSlug} | a single ProductParameterGroup, 404 when the product does not have that group |
Both accept an optional ?locale=; without it the shop default language is
used. {slug} is the product slug (the product id also resolves, like on
getProduct), {groupSlug} is ProductParameterGroup.slug.
const { data } = await client.catalog.getProductParameters('tricko-basic', {
locale: 'cs',
});
const groups = data?.groups ?? [];
const { data: group, error } = await client.catalog.getProductParameterGroup(
'tricko-basic',
'parametry-obleceni',
{locale: 'cs'},
);
// error?.code === 'NOT_FOUND' = the product does not have that groupReact hook:
import { useProductParameters } from '@behio/storefront-sdk/react';
const { data: groups = [], isLoading } = useProductParameters('tricko-basic', {
locale: 'cs',
});
// groups: ProductParameterGroup[]
// One named group only (same hook, one request):
const { data: sizing = [] } = useProductParameters('tricko-basic', {
locale: 'cs',
groupSlug: 'rozmery',
});Rendering the table
import type { ProductParameter, ProductParameterGroup } from '@behio/storefront-sdk';
function formatValue(p: ProductParameter): string {
if (p.booleanValue !== null) return p.booleanValue ? 'Ano' : 'Ne';
return p.unit ? `${p.value} ${p.unit}` : `${p.value}`;
}
export function SpecTable({ groups }: { groups: ProductParameterGroup[] }) {
if (groups.length === 0) return null;
return (
<div className="space-y-8">
{groups.map((group) => (
<section key={group.slug} id={`parametry-${group.slug}`}>
<h3 className="mb-3 text-lg font-semibold">{group.name}</h3>
<table className="w-full text-sm">
<tbody>
{group.parameters.map((p) => (
<tr key={p.label} className="border-b last:border-0">
<th scope="row" className="py-2 pr-4 text-left font-normal opacity-70">
{p.label}
</th>
<td className="py-2 font-medium">{formatValue(p)}</td>
</tr>
))}
</tbody>
</table>
</section>
))}
</div>
);
}
// PDP: <SpecTable groups={product.parameterGroups} />
// Variant: <SpecTable groups={selectedVariant.parameterGroups} />Gallery media (images + videos)
product.media is the product gallery: images with responsive derivatives
plus optional videos (type: 'VIDEO'). Render videos with a native player
only when selected (no upfront download) and use an image derivative as the
poster when one exists:
const items = product.media
.filter((m) => m.url)
.sort((a, b) => Number(b.isCover) - Number(a.isCover) || a.order - b.order);
// item.type === 'VIDEO'
// <video controls preload="metadata" poster={jpegDerivative?.url} src={item.url} />
// item.type === 'IMAGE': pick the smallest supported format from item.variantsAsset groups (videos, downloads, extra content)
product.assetGroups (SDK 1.7.0) is merchant-curated extra content attached to
the product, organized into named groups such as "videos" or "downloads".
It is distinct from images (the roled listing gallery) and media (the
inventory gallery): asset groups carry anything the merchant wants next to the
product, including PDF manuals, size charts, hosted videos and external video
embeds. The array is empty when the merchant attached nothing, and empty groups
are never emitted.
{
"assetGroups": [
{
"group": "videos",
"items": [
{
"id": "01J...",
"kind": "EXTERNAL_VIDEO",
"url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
"contentType": null,
"size": null,
"title": "Product walkthrough",
"description": null,
"order": 0
},
{
"id": "01K...",
"kind": "VIDEO",
"url": "https://cdn.behio.com/.../unboxing.mp4",
"contentType": "video/mp4",
"size": 10485760,
"title": "Unboxing",
"description": null,
"order": 1
}
]
},
{
"group": "downloads",
"items": [
{
"id": "01M...",
"kind": "FILE",
"url": "https://cdn.behio.com/.../manual.pdf",
"contentType": "application/pdf",
"size": 524288,
"title": "User manual",
"description": "Setup and maintenance guide",
"order": 0
}
]
}
]
}Each item's kind decides the rendering:
EXTERNAL_VIDEO: an embed URL (YouTube/Vimeo). Render it in an<iframe>, never in a<video>tag.sizeis always null.VIDEO: a file hosted on the Behio CDN. Render with a native<video>player andpreload="metadata"so nothing downloads upfront.IMAGE/FILE: direct file URLs;contentTypeandsize(bytes) describe the file, so a downloads list can show "PDF, 512 kB".
title and description are already resolved in the requested locale (falling
back to the shop default locale) and may be null. Items arrive sorted by
order.
Rendering a video section:
import type { ProductAssetGroup, ProductAssetItem } from '@behio/storefront-sdk';
function VideoItem({ item }: { item: ProductAssetItem }) {
if (item.kind === 'EXTERNAL_VIDEO') {
return (
<iframe
src={item.url}
title={item.title ?? 'Video'}
className="aspect-video w-full rounded-lg"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture"
allowFullScreen
/>
);
}
return (
<video
controls
preload="metadata"
src={item.url}
className="aspect-video w-full rounded-lg"
/>
);
}
export function ProductVideos({ groups }: { groups: ProductAssetGroup[] }) {
const videos = groups.find((g) => g.group === 'videos');
if (!videos || videos.items.length === 0) return null;
return (
<section className="space-y-6">
{videos.items.map((item) => (
<figure key={item.id}>
<VideoItem item={item} />
{item.title && <figcaption className="mt-2 text-sm">{item.title}</figcaption>}
</figure>
))}
</section>
);
}
// PDP: <ProductVideos groups={product.assetGroups} />Availability & low stock
Every product (list item and detail) carries a resolved availability status
plus an optional low-stock nudge. Both are computed server-side, so the template
only renders them:
const p = await client.catalog.getProduct('wireless-headphones', {locale: 'cs'});
p.availability;
// { code: 'in-stock', label: 'Skladem', color: null, restockAt: null }
// code is a stable machine hook: a merchant preset slug ('preorder',
// 'on-order', custom) or the derived 'in-stock' / 'sold-out'.
// label is already localized, render as-is.
// restockAt (epoch ms) = "available from" date for preorder-style states.
p.lowStockRemaining;
// number | null. Non-null ONLY when the merchant enabled the indicator AND
// 0 < stock <= threshold. Render "Zbývá posledních 3 kusy" whenever set.
// No client-side threshold math: null means "show nothing".Variants carry lowStockRemaining too, so the variant picker can flag the
last pieces of a specific size or colour.
Stock behaviour (shop-level contract)
shop.checkout.stockBehavior from getShopInfo() decides what sold-out means,
and the server enforces it:
stockBehavior | Catalog lists | PDP | Add to cart / qty update | Checkout |
|---|---|---|---|---|
HIDE | Sold-out products are filtered out server-side | Direct link still resolves; render as sold out | 400 above available stock | 400 above stock |
SHOW_SOLD_OUT | Visible with sold-out state | Visible, buy button disabled | 400 above available stock | 400 above stock |
BACKORDER | Visible | Purchasable | Any quantity accepted | Accepts over-stock |
For HIDE / SHOW_SOLD_OUT, cap quantity steppers at stockQuantity, because the
cart API rejects anything above it with a 400. For BACKORDER, keep the buy
button active on sold-out products and show "Na objednávku" (use
availability.label).
Sale window & early bird (launch)
Any product can have a sale window: it is visible in the catalog from the
moment it is published, but purchasable only between saleStartAt and
saleEndAt (both epoch ms, either can be null). The server enforces the window
on add-to-cart, quantity updates and checkout with a 400, so the storefront only
renders the state:
const p = await client.catalog.getProduct('online-kurz', {locale: 'cs'});
p.isPurchasable;
// Server-resolved "can this be bought RIGHT NOW": the sale window is open AND
// the product can be ordered (stock / backorder rules included). Disable the
// buy button when false. Never re-derive the rule client-side.
p.saleStartAt; // epoch ms or null. Before it, render "Prodej startuje za ..."
p.saleEndAt; // epoch ms or null. While open, optionally count down to close
p.earlyBirdActive;
// true = price.amount already IS the discounted early-bird amount and the
// regular price sits in price.compareAtPrice. Early bird is time-based and
// applies in the shop default currency.
p.earlyBirdUntil; // epoch ms. Render "Early bird do ..."; null when inactiveBefore the sale opens, collect e-mails with the regular back-in-stock
subscription (client.catalog.notifyWhenAvailable(productId, email)). The
platform notifies the whole queue automatically the moment saleStartAt
passes.
Search
const results = await client.catalog.search('bluetooth speaker', {
page: 1,
limit: 10,
});Dynamic Filters
getFilters returns the parameters the merchant marked as filterable, already
localized and grouped the same way as the spec table:
const { data } = await client.catalog.getFilters({ locale: 'cs' });
data?.filters;
// [
// {
// key: 'material', // parameter slug, the key `parameters` expects
// name: 'Materiál',
// type: 'enum', // 'enum' | 'range' | 'boolean'
// groupSlug: 'parametry-obleceni',
// groupName: 'Parametry oblečení',
// unit: null,
// values: ['bavlna', 'len', 'vlna'],
// },
// {
// key: 'hmotnost',
// name: 'Gramáž',
// type: 'range',
// groupSlug: 'parametry-obleceni',
// groupName: 'Parametry oblečení',
// unit: 'g',
// },
// ]Send the selection back in parameters, keyed by the same key:
const { data } = await client.catalog.getProducts({
category: 'trika',
parameters: {
material: ['bavlna', 'len'], // enum, array = OR within the key
hmotnost_min: 120, // range, `<key>_min` / `<key>_max`
hmotnost_max: 200,
do_susicky: true, // boolean
},
});typedescribes how to render the control, not how the value is stored:enumrenders thevalueslist as checkboxes,rangerenders two number inputs,booleanrenders a single checkbox.groupSlug+groupNamelet the sidebar use the merchant's own grouping, so the filters read like the product's spec table.unitgoes next to the range inputs (g,cm,%, a currency code) and isnullwhen the parameter has none.- Pass
localeto get labels and enum values in the shopper's language. Without it the shop default language is used. - Nothing else is filterable. A parameter the merchant did not mark as a filter
never shows up here and is rejected in
parameters, so a visitor cannot filter by a field they cannot see.
Facets (values + counts)
getFacets(query) returns an Alza-style faceted-navigation payload for the
same query you pass to getProducts: facet groups (filterable parameters,
labels, price, availability, rating, subcategories) with selection-aware
counts. Counts for a facet are computed with that facet excluded (ticking one
brand does not zero the others), and values that fall to 0 under the current
selection are still returned so you can render them disabled instead of hiding
them.
const { data } = await client.catalog.getFacets({
category: 'kabely',
facets: { barva: ['cerna'] }, // slug-based selection (SEO URLs)
});
// data.facets: [{ key, name, type: 'enum'|'range'|'boolean', groupSlug, groupName, unit?, values?: [{ value, label, slug, count }], range? }]
// // key = parameter slug, the same key `parameters` and `facets` use
// data.priceRange: { min, max, currency }
// data.availability: { inStockCount, outOfStockCount }
// data.rating: [{ from: 4, count }, { from: 3, count }]
// data.labels: [{ slug, name, color, count }]
// data.categories: [{ slug, name, count }] // child subcategories of the current categoryReact hook:
import { useFacets } from '@behio/storefront-sdk/react';
const { data: facets } = useFacets({ category: 'kabely', priceMin: 100 });Each enum facet value carries a stable slug (e.g. "cerna") so you can build
indexable SEO landing pages like /category/kabely/f/barva-cerna and pass the
slug back via facets to filter both getProducts and getFacets.
A facet describes a parameter, never a warehouse field: key is the parameter
slug, groupSlug is the parameter group, and there is no fieldType. type
(enum / range / boolean) is a rendering hint, so build the control from it
and never from a storage type.