Skip to main content

Client Methods

All controllers follow the same pattern with reactive state and three ways to subscribe:
  • state — a ReadonlySignal<QueryState<T>> with { data, error, isFetching }
  • execute() — runs the query, returns Result<T, ClientError>
  • subscribe(callback) — reacts to state changes without importing signals
  • dispose() — cleans up subscriptions and aborts pending requests

client.collection() - Browse Collections

Browse products in a collection with filters, sort, and pagination.
import { effect, subscribe } from '@commerce-blocks/sdk'

const collection = client.collection({
  handle: 'shirts',
  defaultSort: 'featured', // optional, uses first sort if omitted
})

// Three ways to subscribe to state:

// 1. Controller subscribe (no signal import needed)
const unsubscribe = collection.subscribe(({ data, error, isFetching }) => {
  if (isFetching) showLoading()
  if (error) showError(error.message)
  if (data) render(data.products)
})

// 2. Standalone subscribe (works with any signal)
const unsubscribe2 = subscribe(collection.state, (state) => {
  console.log('State:', state)
})

// 3. Direct signal access (for custom reactivity)
effect(() => {
  const { data } = collection.state.value
  if (data) console.log('Products:', data.products)
})

// Execute queries
await collection.execute() // initial load
await collection.execute({ page: 2 }) // pagination
await collection.execute({ sort: 'price_asc' }) // change sort
await collection.execute({ filters: { color: 'Red' } })
await collection.execute({ includeMeta: true }) // fetch collection metadata

// Cleanup
collection.dispose()
Options:
ParameterTypeRequiredDescription
handlestringYesCollection URL handle
defaultSortstringNoDefault sort code (uses first configured sort)
Execute parameters:
ParameterTypeDescription
pagenumberPage number (default: 1)
limitnumberProducts per page (default: 24)
sortstringSort option code
filtersunknownFilter criteria
signalAbortSignalPer-call abort signal
includeMetabooleanInclude _meta in response
linkingRecord<string, unknown>Dynamic linking parameters
transformRequest(body) => bodyCustom request body transformation

client.blocks() - Product Recommendations

Product recommendations powered by Layers blocks. Anchored to a product, collection, or cart.
const blocks = client.blocks({
  blockId: 'block-abc123',
  anchor: 'gold-necklace', // product/collection ID or handle
})

await blocks.execute()
await blocks.execute({
  discounts: [
    {
      entitled: { all: true },
      discount: { type: 'PERCENTAGE', value: 10 },
    },
  ],
  context: {
    productsInCart: [{ productId: '123', variantId: '456', quantity: 1 }],
    geo: { country: 'US' },
  },
})

// result.data.block has { id, title, anchor_type, strategy_type, ... }
blocks.dispose()
Options:
ParameterTypeRequiredDescription
blockIdstringYesLayers block ID
anchorstringNoAnchor product/collection ID or handle
Execute parameters:
ParameterTypeDescription
pagenumberPage number (default: 1)
limitnumberProducts per page (default: 24)
filtersunknownFilter criteria
signalAbortSignalPer-call abort signal
discountsDiscountEntitlement[]Discount entitlements to apply
contextBlocksContextCart, geo, and custom context
linkingRecord<string, unknown>Dynamic linking parameters
transformRequest(body) => bodyCustom request body transformation
BlocksContext:
PropertyTypeDescription
productsInCart{ productId, variantId?, quantity? }[]Products in the cart
geo{ country?, province?, city? }Geographic context
customRecord<string, unknown>Custom context data
Full-text search with facets. Options persist across calls — subsequent execute() calls merge with existing options.
const search = client.search({ query: 'ring', limit: 20 })

await search.execute()
await search.execute({ page: 2 }) // page persists
await search.execute({ filters: { vendor: 'Nike' } }) // filters update

// Temporary override (doesn't persist for next call)
await search.execute({ query: 'shoes', temporary: true })

// Prepare search (caches searchId for faster execute)
await search.prepare()
await search.execute() // uses cached searchId

search.dispose()
Execute parameters:
ParameterTypeDescription
querystringSearch query
pagenumberPage number (default: 1)
limitnumberProducts per page (default: 24)
filtersunknownFilter criteria
signalAbortSignalPer-call abort signal
searchIdstringCached search ID from prepare()
tuningSearchTuningSearch tuning parameters
temporarybooleanDon’t persist params for next call
linkingRecord<string, unknown>Dynamic linking parameters
transformRequest(body) => bodyCustom request body transformation
SearchTuning:
PropertyTypeDescription
textualWeightnumberWeight for text-based matching (0-1)
visualWeightnumberWeight for visual similarity matching (0-1)
multipleFactornumberFactor for multiple keyword matching
minimumMatchnumberMinimum match threshold
Predictive search with debouncing and local caching. Only full words (trailing space) are cached — partial input filters cached results client-side.
const suggest = client.suggest({ debounce: 300 })

suggest.subscribe(({ data }) => {
  if (data) renderSuggestions(data.matchedQueries)
})

input.addEventListener('input', (e) => {
  suggest.execute(e.target.value) // debounced automatically
})

suggest.dispose()
Options:
OptionTypeDescription
debouncenumberDebounce delay in ms (default: 300)
excludeInputQuerybooleanRemove user’s input from suggestions
excludeQueriesstring[]Custom strings to filter from suggestions
signalAbortSignalShared abort signal (acts like dispose())

client.uploadImage() - Upload Image

Upload an image for image-based search.
const upload = client.uploadImage({ image: file })
upload.subscribe(({ data }) => {
  if (data) console.log('Image ID:', data.imageId)
})

client.searchByImage() - Search by Image

Search products using an uploaded image.
const results = client.searchByImage({ imageId: 'uploaded-image-id' })
results.subscribe(({ data }) => {
  if (data) console.log('Similar:', data.products)
})
Parameters:
ParameterTypeRequiredDescription
imageIdstringYesImage ID from uploadImage()
pagenumberNoPage number (default: 1)
limitnumberNoProducts per page (default: 24)
filtersunknownNoFilter criteria
tuningSearchTuningNoSearch tuning parameters
signalAbortSignalNoExternal abort signal
linkingRecord<string, unknown>NoDynamic linking parameters
transformRequest(body) => bodyNoCustom request body transformation

Product Card Controller

Reactive controller for product cards with variant selection and availability logic. All derived values are computed signals that auto-update when inputs change.
import { createProductCard, effect } from '@commerce-blocks/sdk'

const card = createProductCard({
  product,
  selectedOptions: [{ name: 'Size', value: '7' }],
  breakoutOptions: [{ name: 'Stone', value: 'Ruby' }],
})

// Reactive signals
effect(() => {
  console.log('Variant:', card.selectedVariant.value)
  console.log('Options:', card.options.value) // OptionGroup[]
  console.log('Images:', card.images.value)
  console.log('Price:', card.price.value) // PriceData
})

// Actions
card.selectOption({ name: 'Size', value: 'L' })
card.setSelectedOptions([{ name: 'Size', value: 'L' }]) // merge by name
card.setSelectedVariant(12345) // select by numeric variant ID
card.setCarouselPosition(3) // 1-based manual override

card.subscribe(({ selectedVariant, options, price }) => {
  // Called on any state change
})

card.dispose()
Reactive state (all ReadonlySignal): variants, selectedVariant, options, images, price, priceRange, carouselPosition, isSelectionComplete. Options include availability status baked in:
interface OptionGroup {
  name: string
  values: OptionValue[]
}

interface OptionValue {
  value: string
  status: 'available' | 'backorderable' | 'sold-out' | 'unavailable'
  selected: boolean
  swatch: Swatch | null
}

interface PriceData {
  price: Price | null
  compareAtPrice: Price | null
  isOnSale: boolean
}

interface PriceRangeData {
  priceRange: PriceRange
  compareAtPriceRange: PriceRange | null
}

Abort Signals

Controllers support two levels of abort:
// Shared signal — cancels everything when component unmounts
const ac = new AbortController()
const search = client.search({ query: 'ring', signal: ac.signal })

// Per-call signal — cancels only this request
const req = new AbortController()
await search.execute({ page: 2, signal: req.signal })

// Either aborting cancels the request (they're linked internally)
ac.abort() // cancels all pending + acts like dispose()
Collection and blocks auto-cancel the previous request when a new execute() starts.

Filtering

Build filters using the DSL:
import { filter, and, or, eq, gte, lte, inValues } from '@commerce-blocks/sdk'

await collection.execute({
  filters: filter(and(eq('options.color', 'Red'), eq('options.size', 'Medium'))),
})

// Price range
filter(and(gte('price', 50), lte('price', 200)))

// Multiple values
filter(or(eq('vendor', 'Nike'), eq('vendor', 'Adidas')))

Filter Operators

OperatorDescription
eq(property, value)Equals
notEq(property, value)Not equals
inValues(property, values[])In list
notIn(property, values[])Not in list
gt(property, number)Greater than
gte(property, number)Greater than or equal
lt(property, number)Less than
lte(property, number)Less than or equal
exists(property)Property exists
notExists(property)Property does not exist

Filter Aliases

Configure once at init — applied automatically to all results:
import { createClient } from '@commerce-blocks/sdk'

const { data: client } = createClient({
  // ...config
  filterAliases: {
    color: 'options.color',
    size: 'options.size',
    brand: { property: 'vendor', values: { nike: 'Nike', adidas: 'Adidas' } },
  },
})

// Aliases resolve automatically
await collection.execute({ filters: { color: 'Red', brand: 'nike' } })

Response Types

All controllers return the same QueryResult shape in data:
interface QueryState<T> {
  data: T | null
  error: ClientError | null
  isFetching: boolean
}

interface QueryResult {
  products: Product[]
  totalResults: number
  totalPages: number
  page: number
  facets: Record<string, Record<string, number>>
  priceRange?: PriceRange
  attributionToken?: string
}
Blocks results add a block field with { id, title, anchor_type, strategy_type, strategy_key }.

Error Handling

All methods return { data, error } instead of throwing. Errors are discriminated by _tag:
const result = await collection.execute()

if (result.error) {
  switch (result.error._tag) {
    case 'NetworkError':
      // code: 'TIMEOUT' | 'CONNECTION_FAILED' | 'DNS_FAILED' | 'SSL_ERROR' | 'ABORTED' | 'OFFLINE'
      break
    case 'ApiError':
      // code: 'NOT_FOUND' | 'RATE_LIMITED' | 'UNAUTHORIZED' | ...
      // status: HTTP status code, source: 'layers'
      break
    case 'ValidationError':
      // operation: which method failed, fields: [{ field, code, message }]
      break
    case 'ConfigError':
      // field: which config field, expected: what was expected
      break
  }
}
isRetryable classifies errors by tag, code, and status — use it standalone or as a shouldRetry predicate:
import { isRetryable } from '@commerce-blocks/sdk'

if (result.error && isRetryable(result.error)) {
  const delay = result.error.retryAfterMs ?? 1000
  setTimeout(() => collection.execute(), delay)
}

Cache and Storage

The client exposes a reactive cache:
const { cache } = client

cache.get('cache-key') // CacheEntry<QueryResult> | null
cache.invalidate('browse') // invalidate keys containing 'browse'
cache.persist() // save to storage
cache.restore() // restore from storage
cache.clear() // clear all
cache.stats.entries // current entry count

Storage Adapters

import { localStorageAdapter, fileStorage } from '@commerce-blocks/sdk'

// Browser (returns null if unavailable)
const browserAdapter = localStorageAdapter('my-cache-key')

// Node.js
import fs from 'fs'
const nodeAdapter = fileStorage('./cache.json', fs)
Custom adapter — implement StorageAdapter:
const adapter: StorageAdapter = {
  read: () => sessionStorage.getItem('key'),
  write: (data) => sessionStorage.setItem('key', data),
  remove: () => sessionStorage.removeItem('key'),
}

Signals

The SDK re-exports @preact/signals-core primitives for reactive state:
import { signal, computed, effect, batch } from '@commerce-blocks/sdk'

Singleton Access

After initialization, access the client anywhere:
import { getClient, isInitialized } from '@commerce-blocks/sdk'

if (isInitialized()) {
  const { data: client } = getClient()
  // Use client
}

Technical Details

  • Runtime: Browser (ESM)
  • TypeScript: Full type definitions included
  • Dependencies: @preact/signals-core
  • Bundle: Tree-shakeable ES modules
  • Caching: Built-in LRU cache with configurable TTL