Skip to main content
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, Shopify GID, 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, so 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()
temporarybooleanDon’t persist params for next call
linkingRecord<string, unknown>Dynamic linking parameters
transformRequest(body) => bodyCustom request body transformation
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
signalAbortSignalNoExternal abort signal
linkingRecord<string, unknown>NoDynamic linking parameters
transformRequest(body) => bodyNoCustom request body transformation

client.searchContent() - Search articles and blog content

Search articles and blog content with pagination. Options persist across calls, so subsequent execute() calls merge with existing options.
const content = client.searchContent({ query: 'shipping', limit: 10 })

await content.execute()
await content.execute({ page: 2 }) // page persists
await content.execute({ contentType: 'article' }) // filter by content type

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

content.subscribe(({ data, error, isFetching }) => {
  if (isFetching) showLoading()
  if (error) showError(error.message)
  if (data) renderArticles(data.articles)
})

content.dispose()
Execute parameters:
ParameterTypeDescription
querystringSearch query (required)
contentTypestringFilter by content type (e.g., ‘article’)
pagenumberPage number (default: 1)
limitnumberResults per page (default: 24)
signalAbortSignalPer-call abort signal
transformRequest(body) => bodyCustom request body transformation
temporarybooleanDon’t persist params for next call
Response:
interface SearchContentResult {
  articles: Article[]
  query: string
  contentType: string | null
  totalResults: number
  page: number
  resultsPerPage: number
}

interface Article {
  title: string
  handle: string
  summary: string
  author: string
  tags: string[]
  image: ArticleImage | null
  publishedAt: string
}

interface ArticleImage {
  url: string
  width: number
  height: number
}

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.

Next steps