> ## Documentation Index
> Fetch the complete documentation index at: https://www.commercengine.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Catalog

> The Catalog module is the cornerstone of your Commerce Engine storefront. It defines everything you sell – products, their variations, how they're organized, priced, and described. Mastering these APIs allows you to build compelling product listings, detailed descriptions, category navigation, powerful search, customer reviews, and insightful recommendations.

The Catalog API supports public browsing without creating a user session. Use the public SDK accessor for products, categories, search, variants, reviews, recommendations, and store-facing catalog pages.

## Product, Variant, and Item

Commerce Engine exposes three related models:

| Model | Meaning | Best use |
| - | - | - |
| `Product` | Grouped product concept; may contain variants | PDPs and PLPs that show one card per product |
| `Variant` | One selectable option combination with its own price, stock, image, and SKU | PDP selection and Add to Cart |
| `Item` | Flattened sellable unit combining product and variant data | Faceted search and SKU-level listings |

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
Product: Cotton Shirt
├─ Variant: Blue / Small  → sellable Item
├─ Variant: Blue / Large  → sellable Item
└─ Variant: White / Large → sellable Item
```

A product without variants is still sellable. Its cart identity is the product ID plus `variant_id: null`.

## Choose the listing operation

| Requirement | Use |
| - | - |
| One card per product, grouped variants | `listProducts()` |
| Flat SKU/variant grid without facets | `listSkus()` |
| Search, filters, sorting, or facets | `searchProducts()` |
| One product page | `getProductDetail()` |
| Variant details not included in product response | `listProductVariants()` |

<Warning>
  Do not build a faceted PLP with `listProducts()`. `searchProducts()` returns flattened `Item` records plus facet distribution and statistics.
</Warning>

## Public accessor

Browser SPA:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const publicSdk = storefront.public();
```

Framework wrappers:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const publicSdk = storefront.publicStorefront();
```

Public accessors are safe for prerendering and build-time reads. They do not create anonymous users or write session tokens.

## Product listing page

### Grouped products

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const { data, error } = await publicSdk.catalog.listProducts({
  page: 1,
  limit: 20,
  category_id: [categoryId],
});

if (error) throw new Error(error.message);

const products = data?.products ?? [];
```

Use this model when the card represents the product and variant choice belongs on the PDP.

### Flattened Items

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const { data, error } = await publicSdk.catalog.listSkus({
  page: 1,
  limit: 20,
});

if (error) throw new Error(error.message);

const items = data?.skus ?? [];
```

Use `product_id` and `variant_id` from the Item for Add to Cart. Use product and variant slugs from the response to construct stable URLs when available.

## Category routes

Resolve the route slug to a category before issuing a scoped product query.

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const { data: categoryData, error: categoryError } =
  await publicSdk.catalog.listCategories();

if (categoryError) throw new Error(categoryError.message);

const category = (categoryData?.categories ?? []).find(
  (candidate) =>
    candidate.slug === routeSlug ||
    candidate.id === routeSlug,
);

if (!category) {
  // Confirmed category miss: use the framework's not-found behaviour.
}

const { data, error } = await publicSdk.catalog.listSkus({
  page: 1,
  limit: 20,
  category_id: [category.id],
});
```

<Warning>
  Do not omit `category_id` when category resolution is still pending. That silently broadens the request to the entire catalog and produces a convincing but incorrect category page.
</Warning>

Cache categories according to the store's update frequency, but invalidate them when catalog configuration changes.

## Faceted search

Retrieve the exact current method signature from:

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
https://llm-docs.commercengine.io/storefront/operations/search-products.md
```

A typical request:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const { data, error } = await publicSdk.catalog.searchProducts({
  query: searchTerm,
  filter: [
    "product_type = physical",
    ["categories.name = footwear", "categories.name = apparel"],
  ],
  sort: ["pricing.selling_price:asc"],
  facets: [
    "categories.name",
    "pricing.selling_price",
    "attributes.brand",
  ],
  page: 1,
  limit: 20,
});

if (error) throw new Error(error.message);

const items = data?.skus ?? [];
const facetDistribution = data?.facet_distribution ?? {};
const facetStats = data?.facet_stats ?? {};
```

Search returns Items/SKUs, not grouped Products.

### Filter safety

When constructing filter strings from user-visible catalog values:

* allow only known filter fields and operators
* escape quotes and backslashes in values
* do not concatenate arbitrary filter expressions supplied by the browser
* keep URL state separate from the compiled API filter

### URL state

Store search and filter state in the URL so pages can be shared, refreshed, indexed where appropriate, and restored after back/forward navigation.

Typical parameters:

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
/search?q=running+shoes&brand=Acme&price=100-500&sort=price-asc&page=2
```

Debounce text input, but update structured filter controls immediately unless the UI benefits from an explicit Apply action.

### Request supersession

A slower previous request must not replace a newer query's state. Use the data library's cancellation signal or a request sequence:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
let latestRequest = 0;

async function runSearch(input: SearchInput) {
  const requestId = ++latestRequest;
  const result = await search(input);

  if (requestId !== latestRequest) return;
  commitSearchResult(result);
}
```

Reset captured base facets when the query changes; facets from an earlier query must not constrain the new one.

<Frame caption="Faceted search with stale-response protection">
  <iframe title="Faceted search with stale-response protection" allowFullScreen width="100%" height="700" src="https://mermaid.tarkai.dev/embed?definition=sequenceDiagram%0A%20%20%20%20participant%20User%0A%20%20%20%20participant%20UI%20as%20Search%20and%20PLP%20UI%0A%20%20%20%20participant%20SDK%20as%20Public%20Catalog%20SDK%0A%20%20%20%20participant%20CE%20as%20Commerce%20Engine%20API%0A%20%20%20%20User-%3E%3EUI%3A%20Searches%20for%20red%20shirt%0A%20%20%20%20UI-%3E%3EUI%3A%20Increment%20request%20sequence%0A%20%20%20%20UI-%3E%3ESDK%3A%20searchProducts%28query%2C%20facets%2C%20page%29%0A%20%20%20%20SDK-%3E%3ECE%3A%20POST%20%2Fcatalog%2Fproducts%2Fsearch%0A%20%20%20%20CE--%3E%3ESDK%3A%20skus%20%2B%20facet_distribution%20%2B%20facet_stats%20%2B%20pagination%0A%20%20%20%20SDK--%3E%3EUI%3A%20Search%20result%0A%20%20%20%20UI-%3E%3EUI%3A%20Render%20only%20if%20response%20is%20latest%0A%20%20%20%20User-%3E%3EUI%3A%20Selects%20Size%20%3D%20M%0A%20%20%20%20UI-%3E%3EUI%3A%20Reset%20page%20and%20update%20URL%2Ffilter%20state%0A%20%20%20%20UI-%3E%3ESDK%3A%20searchProducts%28query%2C%20filters%2C%20facets%29%0A%20%20%20%20SDK-%3E%3ECE%3A%20POST%20%2Fcatalog%2Fproducts%2Fsearch%0A%20%20%20%20CE--%3E%3ESDK%3A%20Filtered%20skus%20%2B%20updated%20facets%0A%20%20%20%20SDK--%3E%3EUI%3A%20Filtered%20result%0A%20%20%20%20UI-%3E%3EUI%3A%20Ignore%20stale%20responses%20and%20render%20current%20state" />
</Frame>

## Product detail page

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const { data, error, response } =
  await publicSdk.catalog.getProductDetail({
    product_id: routeSlug,
  });

if (error) {
  if (response?.status === 404) {
    // Confirmed product miss.
  } else {
    // Recoverable upstream error state.
  }
}

const product = data?.product;
```

Some SDK versions expose a parameter name such as `product_id_or_slug`; retrieve the exact operation contract before implementation rather than guessing the key.

Prefer the variants included in product detail. Call `listProductVariants()` only when the response omits them or the feature needs the dedicated operation.

## Variant option model

For `has_variant: true`, option query parameters are the canonical state:

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
/product/cotton-shirt?color=Blue&size=Large
```

The optional `variant` parameter can be derived or used to backfill missing option parameters, but it should not replace the customer-visible option state.

### Resolve a variant

1. Read option groups in `product.variant_options` order.
2. Normalize values consistently:
   * color option → compare `value.name`; use `value.hexcode` for UI
   * single-select option → compare the string value
3. Match every selected option against `variant.associated_options`.
4. Resolve a variant only when all required option keys are selected.
5. Update displayed price, image, stock, SKU, and description from the resolved variant.

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
function matchesCompleteSelection(
  variant: Variant,
  selection: Record<string, string>,
  requiredOptionKeys: string[],
) {
  if (requiredOptionKeys.some((key) => !selection[key])) return false;

  return requiredOptionKeys.every((key) => {
    const expected = selection[key];
    const option = variant.associated_options?.[key];
    if (!option) return false;

    const value =
      option.type === "color"
        ? option.value.name
        : option.value;

    return value === expected;
  });
}
```

Build `requiredOptionKeys` from the normalized option groups rendered by the PDP. Keep a separate partial-selection matcher for disabling impossible choices; do not use a partial match as the resolved purchasable variant.

### Disable impossible choices

For each displayed option value, check whether at least one variant matches the current partial selection and remains purchasable. Disable values that cannot lead to a valid selection.

A variant is purchasable when:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
variant.stock_available || variant.backorder
```

### Add to Cart

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const variantId = product.has_variant
  ? resolvedVariant?.id
  : null;

if (product.has_variant && !variantId) {
  // Keep Add to Cart disabled and request the missing options.
}
```

With Hosted Checkout:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
addToCart(product.id, variantId, 1);
```

With a custom cart, use the cart skill's first-add or existing-cart operation.

## Attributes versus variant options

Attributes enrich product data and power filtering/specification display. Variant options create distinct sellable variants.

A product without variants can still expose color or single-select attributes that a brand wants to render with option-style UI. Keep one normalized display model, but make those values read-only and do not invent a variant ID.

Avoid rendering the same key twice when an attribute overlaps a variant option.

## Pricing and customer groups

B2B stores can apply group-specific catalog pricing and promotions. Set authoritative customer-group context through the SDK's supported headers/configuration rather than adding ad hoc parameters to every request.

A public page without known customer-group identity should render the store's public/default pricing. After login or group resolution, invalidate affected catalog queries.

## Reviews and recommendations

Read reviews publicly where allowed. Submitting a review is a session-bound write and can require a logged-in user and eligible order context.

Use recommendation operations for:

* similar products
* upsell products
* cross-sell products

Run independent detail, review, and recommendation reads in parallel when doing so does not delay the essential PDP content.

## SEO and structured data

Use [`@commercengine/seo`](/docs/seo-ai/seo) for production PDP/PLP SEO rather than hand-authoring Product JSON-LD and metadata in each page. It consumes the same canonical `ProductDetail`, `Category`, `Variant`, and pricing data used by the catalog UI and generates:

* product/category title, description, canonical, Open Graph, Twitter, and Markdown alternate metadata
* `Product` or `ProductGroup` JSON-LD with offers, variant axes, availability, pricing, ratings, images, and catalog attributes when valid
* safe JSON-LD serialization
* route-resolved product/category URLs
* Markdown mirrors, `llms.txt`, robots, and XML sitemaps

Breadcrumb hierarchy remains page-specific. Build it with `seo.breadcrumbJsonLd(...)`, but resolve product/category links with `seo.productUrl()` and `seo.categoryUrl()` instead of concatenating route strings.

See the [framework guides](/docs/seo-ai/overview#framework-map) for the native head adapter and [Deployment & Indexability](/docs/seo-ai/deployment) for server-versus-static serving.

## Rendering and performance

* prerender/cache public catalog routes according to the framework and catalog-change frequency
* use ISR or equivalent revalidation when appropriate
* parallelize independent reads
* provide fixed image dimensions or aspect-ratio containers
* render meaningful skeletons that match the final layout
* keep session bootstrap out of public rendering
* paginate full-catalog build discovery rather than assuming one page contains every route
* warn in build logs when catalog discovery fails; do not silently publish a partial route set

## Failure handling

Distinguish:

* successful response with no matching resource → confirmed not found
* 404 operation response → confirmed not found where contractually applicable
* timeout, 5xx, network error → recoverable upstream failure
* empty search results → valid empty state

Do not turn a transient catalog outage into a permanent 404 or silently show the entire catalog for an unresolved filter.

## Production checklist

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
[ ] correct Product vs Item listing model chosen
[ ] searchProducts used for facets and filters
[ ] category resolved before scoped request
[ ] filter values escaped
[ ] stale search responses cannot overwrite newer state
[ ] option query parameters restore PDP state
[ ] all variant option keys required for resolution
[ ] impossible combinations disabled
[ ] stock and backorder rules applied
[ ] confirmed not-found separated from upstream failure
[ ] public pages prerender/cache without session creation
[ ] metadata and safely serialized JSON-LD present
[ ] build discovers the complete intended route set
```

<Card title="Catalog skills and production examples" icon="github" href="https://github.com/tark-ai/ce-starter-projects">
  Use the Commerce Engine catalog skill for decision logic, the LLM-first reference for exact contracts, and the starters for integrated PLP/PDP patterns.
</Card>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.