@commerce-blocks/sdk
v2.0.3
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) |
| context | Context | No | Market/shopper context (see below) |
| flags | Flags | No | Feature flags (see below) |
Flags
Control which product data the SDK requests from the API.
createClient({
// ...config
flags: { variants: true }, // opt-in for full variants array
})| Flag | Default | Description |
| ---------- | ------- | ----------------------------------------------------------------------------- |
| variants | false | Request full variants array per product. Enable for PDPs or option selectors. |
By default, products are shallow — variants contains only the matched variant, and price/compareAtPrice/featuredMedia/availableForSale are sourced from first_or_matched_variant. This significantly reduces API payload size.
When flags.variants is true, the full variants array is requested and selectedVariant is looked up from it. Use this for product detail pages or cards with variant/option selectors.
Context
Pass market and shopper context to personalize results. Set globally on the client config (applied to all controllers), per-query on execute(), or both — per-query context merges with and overrides the global context.
const { data: client } = createClient({
// ...config
context: {
market: 'US',
geo: { country: 'US' },
shoppingChannel: 'web',
},
})
// Per-query override — merges with global context
await collection.execute({
context: { geo: { country: 'CA', province: 'ON' } },
})
// Effective context: { market: 'US', geo: { country: 'CA', province: 'ON' }, shoppingChannel: 'web' }| Field | Type | Description |
| ------------------- | ------------------------- | --------------------------------------------------- |
| geo | GeoLocation | { country, province, city } |
| market | string | Market identifier |
| productsInCart | CartProduct[] | { title, price, type, productId, variantId, ... } |
| productsPurchased | CartProduct[] | Previously purchased products |
| priorSearches | PriorSearch[] | { searchQuery, hadClick, hasResults } |
| marketing | Marketing | { source, medium, campaign, term } |
| customer | CustomerContext | { signedIn, returning, numberOfOrders, ... } |
| shoppingChannel | 'web' \| 'app' | Shopping channel |
| custom | Record<string, unknown> | Custom key-value pairs |
Controllers
All controllers (collection, blocks, search, suggest, searchContent) 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 |
| context | Context | Per-query context (merges with global) |
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: [{ title: 'Gold Ring', productId: '123', variantId: '456', quantity: 1 }],
geo: { country: 'US', province: 'CA' },
},
})
// result.data.block has { id, title, anchor_type, strategy_type, ... }
blocks.dispose()Additional execute() params: discounts.
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, temporary.
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()) |
Content Search
Search articles and other content types.
const content = client.searchContent({ query: 'styling tips' })
const { data } = await content.execute()
// data.articles, data.totalResults, data.page
await content.execute({ query: 'new arrivals', contentType: 'blog' })
await content.execute({ page: 2, temporary: true })
content.dispose()| Option | Type | Description |
| ------------------ | ---------------- | ----------------------------------------- |
| query | string | Search query |
| contentType | string | Filter by content type |
| page | number | Page number |
| limit | number | Results per page |
| signal | AbortSignal | Abort signal |
| transformRequest | (body) => body | Custom request body transformation |
| context | Context | Per-query context (merges with global) |
| temporary | boolean | Override without persisting for next call |
Result shape:
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: { url: string; width: number; height: number } | null
publishedAt: string
}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
}priceRange requires { name: 'Price', code: 'variants.price' } in your facets configuration. Without it, priceRange will be undefined.
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'