@commerce-blocks/sdk
v2.0.0
Published
ES module SDK for product discovery — browse collections, search, recommendations, and image search via Layers API.
Readme
@commerce-blocks/sdk
ES module SDK for product discovery — browse collections, search, recommendations, and image search via Layers API.
Installation
npm install @commerce-blocks/sdkQuick Start
import { createClient } from '@commerce-blocks/sdk'
const { data: client, error } = createClient({
token: 'your-layers-token',
sorts: [
{ name: 'Featured', code: 'featured' },
{ name: 'Price: Low to High', code: 'price_asc' },
],
facets: [
{ name: 'Color', code: 'options.color' },
{ name: 'Size', code: 'options.size' },
],
})
if (error) throw new Error(error.message)
const collection = client.collection({ handle: 'shirts' })
const result = await collection.execute()
if (result.data) {
console.log(result.data.products)
console.log(result.data.totalResults)
}
collection.dispose()Configuration
| Option | Type | Required | Description |
| --------------- | ------------------------------ | -------- | ------------------------------------ |
| token | string | Yes | Layers API public token |
| sorts | Sort[] | Yes | Sort options { name, code } |
| facets | Facet[] | Yes | Facet fields { name, code } |
| attributes | string[] | No | Product attributes to fetch |
| baseUrl | string | No | Custom API URL |
| fetch | CustomFetch | No | Custom fetch (SSR, testing) |
| currency | string | No | Currency for price formatting |
| formatPrice | (amount, currency) => string | No | Custom price formatter |
| swatches | Swatch[] | No | Color swatch definitions |
| transforms | Transforms | No | Post-process results (see below) |
| filterAliases | FilterAliases | No | URL-friendly filter key mapping |
| cacheLimit | number | No | Max entries in cache |
| cacheLifetime | number | No | TTL in milliseconds |
| storage | StorageAdapter | No | Custom storage adapter |
| initialData | CacheData | No | Pre-populate cache at init |
| restoreCache | boolean | No | Restore from storage (default: true) |
Controllers
All controllers (collection, blocks, search, suggest) follow the same pattern:
state— aReadonlySignal<QueryState<T>>with{ data, error, isFetching }execute()— runs the query, returnsResult<T, ClientError>subscribe(callback)— reacts to state changes without importing signalsdispose()— cleans up subscriptions and aborts pending requests
Subscribing to State
Three ways to consume controller state (pick one):
// 1. Controller subscribe — no signal import needed
const unsubscribe = controller.subscribe(({ data, error, isFetching }) => {
if (isFetching) showLoading()
if (error) showError(error.message)
if (data) render(data.products)
})
// 2. Standalone subscribe — works with any signal
import { subscribe } from '@commerce-blocks/sdk'
const unsubscribe = subscribe(controller.state, (state) => {
/* ... */
})
// 3. Direct signal access — for custom reactivity
import { effect } from '@commerce-blocks/sdk'
effect(() => {
const { data } = controller.state.value
})Shared Query Parameters
These parameters are available on execute() for all controllers except suggest:
| Parameter | Type | Description |
| ------------------ | ------------------------- | ---------------------------------- |
| page | number | Page number (default: 1) |
| limit | number | Products per page (default: 24) |
| filters | unknown | Filter criteria |
| signal | AbortSignal | Per-call abort signal |
| linking | Record<string, unknown> | Dynamic linking parameters |
| transformRequest | (body) => body | Custom request body transformation |
Collection
Browse products in a collection.
const collection = client.collection({
handle: 'shirts',
defaultSort: 'featured', // optional, uses first sort if omitted
})
await collection.execute() // initial load
await collection.execute({ page: 2 }) // paginate
await collection.execute({ sort: 'price_asc' }) // change sort
await collection.execute({ filters: { color: 'Red' } })
await collection.execute({ includeMeta: true }) // fetch collection metadata
collection.dispose()Additional execute() params: sort, includeMeta.
Blocks
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()Additional execute() params: discounts, context.
Search
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()Additional execute() params: query, searchId, tuning, temporary.
SearchTuning controls matching weights: textualWeight, visualWeight, multipleFactor, minimumMatch.
Suggest
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()| Option | Type | Description |
| ------------------- | ------------- | ------------------------------------------- |
| debounce | number | Debounce delay in ms (default: 300) |
| excludeInputQuery | boolean | Remove user's input from suggestions |
| excludeQueries | string[] | Custom strings to filter from suggestions |
| signal | AbortSignal | Shared abort signal (acts like dispose()) |
Image Search
Upload an image, then search by it.
const upload = client.uploadImage({ image: file })
upload.subscribe(({ data }) => {
if (!data) return
const results = client.searchByImage({ imageId: data.imageId })
results.subscribe(({ data }) => {
if (data) console.log('Similar:', data.products)
})
})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.
Product Card
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
}Filters
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')))| Operator | Description |
| ------------------------------ | ----------------------- |
| 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 |
Transforms and Filter Aliases
Configure once at init — applied automatically to all results. The product transform extends every Product with custom fields via the generic Product<T> type:
import type { Product } from '@commerce-blocks/sdk'
type MyProduct = Product<{ description: string; rating: number }>
const { data: client } = createClient({
// ...config
attributes: ['body_html'],
transforms: {
product: ({ raw }) => ({
description: raw.body_html ?? '',
rating: raw.calculated?.average_rating ?? 0,
}),
collection: (result, raw) => result,
search: (result, raw) => result,
block: (result, raw) => result,
filters: (filters) => filters,
},
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' } })
// Products now include description and rating from the product transformError 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 countStorage 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'Controller state is a ReadonlySignal<QueryState<T>>:
interface QueryState<T> {
data: T | null
error: ClientError | null
isFetching: boolean
}All controllers return the same QueryResult shape in data:
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 }.
Singleton Access
After initialization, access the client anywhere:
import { getClient, isInitialized } from '@commerce-blocks/sdk'
if (isInitialized()) {
const { data: client } = getClient()
// Use client
}Response Types
The SDK exports all types from @commerce-blocks/sdk:
import type {
Client,
ClientConfig,
QueryState,
QueryResult,
Product,
ProductBase,
ProductVariant,
OptionGroup,
OptionValue,
PriceData,
PriceRangeData,
ClientError,
NetworkError,
ApiError,
} from '@commerce-blocks/sdk'