Skip to main content

Overview

This is the SDK equivalent of Rendering banners in Liquid. The browse payload, layout selection, and grid math are identical — but with @commerce-blocks/sdk, client.collection() does the HTTP call, the request deduplication, and the caching, and exposes the response (including appliedRules) on a reactive signal you can subscribe to. Use this version when you’ve already installed @commerce-blocks/sdk in your theme. For a no-dependencies version, see the Liquid + Fetch guide.

Prerequisites

  • The Layers Shopify app installed and the theme app extension enabled in Online Store → Themes → Customize → App embeds.
  • The SDK installed and a client created in your theme — see SDK installation.
  • At least one merchandising rule with banners attached. See Add a banner to a rule.

The banner payload

Banners ship under each entry in appliedRules on the collection controller’s data:
data.appliedRules?.flatMap((r) => r.banners ?? [])
// → Array<{
//     id, name, mode: 'overtake' | 'inject', link: string | null, sort_index,
//     web_media:    { src, alt },
//     mobile_media: { src, alt },
//     web_layout:   { placement: 'hero' | 'inline', width, height, position },
//     mobile_layout:{ placement: 'hero' | 'inline', width, height, position },
//   }>
Three things to remember:
  • Per-device media and layout. Pick web_* on desktop and mobile_* on mobile. They’re independent — a hero on web can be an inline tile on mobile.
  • Mode. overtake replaces the product card at position. inject inserts the banner and shifts trailing products by one cell.
  • Link. Only inject banners can carry a link. Wrap them in an anchor; render overtake banners as plain media.

Step 1 — Subscribe to the collection controller

client.collection() returns a reactive controller. Subscribe once and react to both the products and the applied rules from the same data payload.
const collection = window.layers.collection({ handle: COLLECTION_HANDLE })

collection.subscribe(({ data, isFetching, error }) => {
  if (isFetching || error || !data) return
  render({
    products:    data.products ?? [],
    appliedRules: data.appliedRules ?? [],
  })
})

collection.execute({ pagination: { page: 1, limit: 24 } })
The SDK dedupes identical requests, caches the response in localStorage, and re-runs the subscriber whenever you call execute() with new filter or pagination args.

Step 2 — Collect banners across applied rules

Flatten banners across every applied rule and sort by sort_index so the lowest index wins on conflicting cells.
function collectBanners(appliedRules) {
  return appliedRules
    .flatMap((rule) => rule.banners ?? [])
    .sort((a, b) => a.sort_index - b.sort_index)
}

Step 3 — Pick the layout for the current device

A banner only has a usable layout when both the media slot and the layout for the active device are present. Skip the rest.
const mql = window.matchMedia('(max-width: 749px)')
const isMobile = () => mql.matches

function layoutForDevice(banner) {
  const media  = isMobile() ? banner.mobile_media  : banner.web_media
  const layout = isMobile() ? banner.mobile_layout : banner.web_layout
  if (!media?.src || !layout) return null
  return { media, layout, mode: banner.mode, link: banner.link, id: banner.id }
}
Listen for mql.addEventListener('change', ...) and re-render when the breakpoint flips. Banners can have completely different layouts per device — a 2×2 web tile may be a hero on mobile.

Step 4 — Place banners around the products

Hero banners render above the grid. Inline banners slot into the product grid by position (row-major, 0-indexed); overtake replaces the product at that index, inject splices the banner in and shifts trailing products.
function render({ products, appliedRules }) {
  const host = document.querySelector('.collection')
  const grid = document.querySelector('.collection__grid')
  const banners = collectBanners(appliedRules)

  // Clear previously rendered hero banners.
  host.querySelectorAll('.layers-banner--hero').forEach((el) => el.remove())

  // Hero banners — render above the grid.
  for (const banner of banners) {
    const picked = layoutForDevice(banner)
    if (picked?.layout.placement === 'hero') renderHero(host, picked)
  }

  // Inline banners — combined with products into grid cells.
  const cells = buildGridCells(products, banners)
  grid.innerHTML = cells.map(renderCell).join('')
}

function buildGridCells(products, banners) {
  const cells = products.map((p) => ({ type: 'product', product: p }))

  const inline = banners
    .map((b) => ({ banner: b, picked: layoutForDevice(b) }))
    .filter(({ picked }) => picked && picked.layout.placement === 'inline')
    .sort((a, b) => a.banner.sort_index - b.banner.sort_index)

  for (const { picked } of inline) {
    const position = picked.layout.position ?? 0
    const cell = {
      type: 'banner',
      mode: picked.mode,
      link: picked.link,
      media: picked.media,
      width: picked.layout.width,
      height: picked.layout.height,
      id: picked.id,
    }

    if (picked.mode === 'overtake') {
      if (position < cells.length) cells[position] = cell
      else cells.push(cell)
    } else {
      cells.splice(Math.min(position, cells.length), 0, cell)
    }
  }

  return cells
}

function renderCell(cell) {
  if (cell.type === 'product') return renderProductCell(cell.product)

  const inner = `<img src="${cell.media.src}" alt="${cell.media.alt ?? ''}" />`
  const style = `--span-x:${cell.width};--span-y:${cell.height}`
  const classes = `layers-banner layers-banner--inline layers-banner--${cell.mode}`

  if (cell.mode === 'inject' && cell.link) {
    return `<a href="${cell.link}" class="${classes}" data-banner-id="${cell.id}" style="${style}">${inner}</a>`
  }
  return `<div class="${classes}" data-banner-id="${cell.id}" style="${style}">${inner}</div>`
}

function renderHero(host, picked) {
  const wrapper = picked.mode === 'inject' && picked.link
    ? `<a href="${picked.link}" class="layers-banner layers-banner--hero" data-banner-id="${picked.id}">`
    : `<div class="layers-banner layers-banner--hero" data-banner-id="${picked.id}">`
  const closing = picked.mode === 'inject' && picked.link ? '</a>' : '</div>'

  host.insertAdjacentHTML(
    'afterbegin',
    `${wrapper}<img src="${picked.media.src}" alt="${picked.media.alt ?? ''}" />${closing}`,
  )
}
Use grid-column: span <width>; grid-row: span <height> (or the --span-x / --span-y properties) on banner cells so 2×2 tiles actually cover four grid cells.

Re-running on filter or pagination changes

The same controller and the same subscriber are reused — just call execute() with new args. The banner payload is recomputed by Layers on every request, so banners are always in sync with the current appliedRules for the active filter set.
collection.execute({
  filters: activeFilters,
  pagination: { page, limit: 24 },
})

Fallback rules

Per Banner Injection:
  • A banner without both the device’s media slot and layout is treated as disabled — layoutForDevice returns null and you skip it.
  • A banner without web_media or mobile_media filled is never enabled by Layers, so it won’t appear in the payload in the first place.
  • Banners with mode: "overtake" ignore any link value — only inject banners are clickable.

What the app embed handles for you

The Layers theme app extension does not render banners — your theme owns that. The embed does handle:
  • Authenticating the browse request (token, cart context, identity).
  • Sending the storefront pixel events that track shopper engagement on the underlying grid.
Banner click tracking is not automatic. If you need it, send your own Tracking API event from the anchor click handler, keyed on data-banner-id.

Why the SDK over Fetch

ConcernFetch versionSDK version
Driving the requestfetch to /browse/{handle}collection.execute()
Re-rendersDIY DOM updatesSignal subscriber
Caching across navDIYRestored from localStorage
Re-rendering on filter changeManual orchestrationSame controller, just execute() again
Banner placementSame — your theme owns the grid mathSame — your theme owns the grid math

Troubleshooting

data.appliedRules is empty. No merchandising rule matched the request, or every matching rule had no banners. Confirm at least one active rule on this collection has banners attached and both media slots filled. Banners only appear on first load, not after filtering. Your subscriber probably re-renders products but not banners. Re-read data.appliedRules from the same payload on every state update — they ship together. Inline banner is at the wrong cell. position is row-major and 0-indexed. Verify the device’s *_layout.position matches the cell index you expect after flattening the grid to a single array. 2×2 inline tile renders as a 1×1. Your CSS isn’t honoring width/height. Apply grid-column: span <width>; grid-row: span <height> on banner cells.

See also