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 |
When sort is omitted, the API applies the merchant’s defaultProductSort.
ShopInfo.defaultProductSort (SDK 2.9.0) exposes its normalized public value
so the selected control agrees with the server. An explicit sort wins.
priceMin/priceMax filter the customer's actual selling price in the requested
currency, including customer price lists, price rules, early-bird offers and
the displayed variant starting price. The price facet uses the same calculation.
Guest-only hidden prices also hide the price range and ignore price bounds, so
result counts cannot reveal a protected price. Pass the same authenticated
client and query context to both the product list and facets.
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.
Merchant labels
Product lists and details include labels: {id, slug, name, color, iconName}.
getLabels(locale) returns the same label data; label facets also carry
color and iconName. Names are localized. iconName is nullable and was
added in SDK 2.9.0. Existing storefronts can ignore this additive field.
Render the merchant's labels in the initial HTML on both cards and product
details. Resolve icon names against trusted local icons, such as leaf,
sparkles, tag, gift, truck, star, flame, award, badgecheck and
circledashed. Keep the name visible if an icon is absent or unknown. Never
interpret an icon name as HTML, SVG, a module path or a remote image URL.
Use a readable foreground for the configured color, including light colors.
Automatic label assignment (newSinceDays, saleCompareAt, lowStock) is
evaluated by the backend. Render the labels returned by the API; do not
recompute these rules from list prices or stock in the browser. Filtering
uses label slugs through labels, with the same selection passed to facets.
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. In TRACKED mode they are derived from the parent's published 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.
Featured products use the same stock predicate as the main list. A variants-only parent's own stale positive stock never keeps a sold-out family visible. Only enabled variants in the shop's selected warehouses count. Both non-tracked modes remain available even when a saved HIDE setting is present; stock filters, availability facet counts and the admin sold-out list follow that same rule. These stock rules do not override publication or variant-card visibility.
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.
SDK 1.20 adds localizedSlugs: Record<string, string> to each sitemap entry
and to product.seo. These are the actual slugs of published translations in
enabled languages. Use them for hreflang, language navigation and sitemap
alternates; repeating the current slug under every language prefix can point
to an alias or a missing translation. Colour cards using PARENT_PAGE expose
the canonical parent's translations. Omit unknown alternates on older servers.
The sitemap applies the same bound-domain product selection as the product
page. Public crawler output must use a client without customer or cart tokens.
A Next route reading dynamic shop settings must opt into request-time rendering
before the SDK call, for example with await connection() from next/server.
The SDK treats a fetch exception as a network failure; the framework's static
rendering bailout must not pass through that error boundary. Run next build
and check the real /sitemap.xml, /llms.txt, /llms-full.txt, /og and
/manifest.webmanifest responses. Paginate catalog excerpts at up to 100 items
per API request, surface failures, and honour maintenance mode.
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)
Both product.images (listing-specific images) and product.media (shared inventory media) can be populated. Merge their safe URLs and deduplicate them; adding a shared video must not hide the listing photos. Image alt text resolves the requested language, then the shop language, then the image fallback.
product.media contains 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"to avoid eagerly downloading the whole video.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.
Variants expose their own public assetGroups. Use
resolveVariantContent(product, selectedVariant).assetGroups: a non-empty
variant array replaces the parent's groups; otherwise the parent is inherited.
This is public supporting material, separate from purchased digital content.
Paid file URLs are never returned by the product catalog. requiresShipping
and isDigital also belong to the selected variant, even when the parent has
different delivery requirements.
Render all groups in the initial HTML, including custom groups. Validate URLs
before using them as links, images or video sources; never accept executable
schemes. Use sanitized descriptions. External players should load only after an
explicit visitor click. Normalize YouTube watch/mobile/short links to its
privacy-enhanced embed host and preserve an unlisted Vimeo link's h parameter.
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 | Follow isPurchasable | Merchant backorder limit applies | Same limit, rechecked atomically |
Cap the PDP quantity stepper at maxPurchasableQuantity (product and variant,
SDK 2.13.0). It is exactly the cart rule: stock for HIDE / SHOW_SOLD_OUT,
stock plus safetyStock under BACKORDER, and null when the quantity is not
limited or the shop hides stock counts. The cart API rejects anything above it
with a 400. For BACKORDER, follow the server
isPurchasable flag and render availability.label. A positive merchant
safetyStock bounds the order at current stock plus that allowance; zero
keeps backordering unlimited. Non-TRACKED modes ignore inventory limits.
The catalog checks whether a valid minimum quantity fits without exposing a
hidden stock count. The cart validates the whole basket, including bundle
components and multiple offers backed by the same inventory item. Use cart
quantityControls for the next accepted quantity and handle a stale offer
with the localized API error; catalog availability does not reserve stock.
One of a kind (handmade originals, SDK 2.5.0)
Shops that sell handmade pieces mark products as one of a kind
(isUnique: true). Exactly one piece exists and it is never restocked.
- While available,
availability.labelalready reads "One of a kind" (localized). Hide the quantity stepper; one piece is all there is. - The server never sells an original beyond its stock, whatever the shop's backorder or made-to-order setting is.
- Once sold,
soldAtholds the time,availability.labelreads "Sold" andisPurchasableis false. Do not offer back-in-stock watching: the server refuses it withbe.storefront.uniqueItemNoRestock. Offer "I want a similar one" instead (a contact form or Behio Chat). - The merchant decides what happens to sold originals: they stay in listings for N days as a portfolio, stay forever, or disappear at once. In the last case listings, search and the product page (404) all drop the product.
{product.isUnique && product.soldAt ? (
<SimilarRequestButton productName={product.name} />
) : product.isUnique ? (
<AddToCartButton productId={product.id} quantity={1} />
) : (
<QuantityPicker productId={product.id} />
)}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.
Variant purchase rules
Each variant is a sellable product with its own isPurchasable, availability,
minOrderQuantity, orderQuantityStep, saleStartAt, saleEndAt and
volumePricing. Do not inherit the parent's purchase rules or replace a null
variant price with the parent's price. A tracked variant with no stock can still
be purchasable under BACKORDER; a closed sale window remains blocked.
Merge the selected variant's media and images; use the parent's gallery
when that variant has no own gallery. resolveVariantContent follows that
precedence. Initial selection, descriptions and metadata must be server-rendered.
The initial quantity is ceil(max(1, minimum) / step) * step, using step 1 when
unset. Round a stock or order cap down to a step multiple. If it falls below the
minimum, disable purchase instead of reducing the merchant's minimum. Apply the
same quantity to cards, mobile sticky buttons and cart controls. The cart API
revalidates the combined quantity and remains authoritative when stock is hidden.
When a changed axis conflicts with another selected value, clear the incompatible selection and require a complete purchasable combination. An existing but unavailable combination must not preserve an incompatible selected value. Disable axis values only when no purchasable variant uses them, so zero-stock BACKORDER values remain enabled. Do not trap the shopper in one combination by disabling every route to another published variant.