Skip to main content

Overview

A merchandising rule can attach up to five banners as a consequence. When the rule matches a browse request, the response carries a banners array under that rule’s applied-rule entry — your theme is responsible for placing each banner into the grid. A banner is either:
  • A hero row that renders full-width above the first product cell, or
  • An inline tile (1×1 or 2×2) that takes a specific position in the grid, either overtake (replacing a product) or inject (inserting alongside products and optionally wrapped in a link).
Use this version when you’re scripting against the storefront API directly with fetch. For the SDK version, see Rendering banners with the SDK.

Prerequisites

The banner payload

Banners ship under each entry in appliedRules:
{
  "appliedRules": [
    {
      "id": "01J...rule",
      "banners": [
        {
          "id": "01JC...banner",
          "name": "Summer Sale Hero",
          "mode": "inject",
          "link": "/collections/summer-sale",
          "sort_index": 0,
          "web_media":   { "src": "https://cdn.shopify.com/.../hero-web.jpg",   "alt": "Summer Sale" },
          "mobile_media":{ "src": "https://cdn.shopify.com/.../hero-mobile.jpg", "alt": "Summer Sale" },
          "web_layout":   { "placement": "hero",   "width": 1, "height": 1, "position": null },
          "mobile_layout":{ "placement": "hero",   "width": 1, "height": 1, "position": null }
        },
        {
          "id": "01JC...banner2",
          "name": "Featured Bundle Tile",
          "mode": "overtake",
          "link": null,
          "sort_index": 1,
          "web_media":   { "src": "https://cdn.shopify.com/.../bundle-web.jpg",   "alt": "Bundle" },
          "mobile_media":{ "src": "https://cdn.shopify.com/.../bundle-mobile.jpg", "alt": "Bundle" },
          "web_layout":   { "placement": "inline", "width": 2, "height": 2, "position": 4 },
          "mobile_layout":{ "placement": "inline", "width": 1, "height": 1, "position": 2 }
        }
      ]
    }
  ]
}
Three things to notice:
  • Per-device media and layout. Pick web_media + web_layout on desktop and mobile_media + mobile_layout 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 at position and shifts trailing products by one cell.
  • Link. Only inject banners can carry a link. Wrap inject banners in an anchor; render overtake banners as plain media.

Step 1 — Collect banners from the browse response

Flatten the banners across all applied rules in one pass. Sort by sort_index so the lowest index wins when two banners target the same cell.
function collectBanners(browseResponse) {
  return (browseResponse.appliedRules ?? [])
    .flatMap((rule) => rule.banners ?? [])
    .sort((a, b) => a.sort_index - b.sort_index)
}

Step 2 — Pick the right layout for the device

A banner only has a usable layout when both the media slot and the layout for the active device are present. Skip banners that don’t have both filled.
function isMobile() {
  return window.matchMedia('(max-width: 749px)').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 }
}

Step 3 — Render hero banners above the grid

Hero banners (placement: "hero") render as a row above the first product cell. They never displace products.
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}`,
  )
}
host is the container that wraps the grid section — render hero banners on the outside, before the product grid.

Step 4 — Slot inline banners into the grid

Inline banners take a position (row-major, 0-indexed) and span width × height grid cells. Walk the products and insert banners at their target index. For overtake, replace the product at position. For inject, splice the banner in at position and let trailing products shift.
function buildGridCells(products, banners) {
  const cells = products.map((p) => ({ type: 'product', product: p }))

  // Inline banners only.
  const inline = banners
    .map((b) => ({ banner: b, picked: layoutForDevice(b) }))
    .filter(({ picked }) => picked && picked.layout.placement === 'inline')
    // Apply lowest sort_index first so it takes priority on conflicting cells.
    .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') {
      // Replace the product at this position (no shift).
      if (position < cells.length) {
        cells[position] = cell
      } else {
        cells.push(cell)
      }
    } else {
      // inject — insert and shift trailing products.
      cells.splice(Math.min(position, cells.length), 0, cell)
    }
  }

  return cells
}

function renderGrid(grid, cells) {
  grid.innerHTML = cells
    .map((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>`
    })
    .join('')
}
Use the --span-x / --span-y custom properties (or grid-column: span 2; grid-row: span 2) in your CSS so 2×2 inline banners actually cover four grid cells.

Putting it together

The end-to-end flow on a collection page:
async function loadCollection() {
  const response = await fetch(
    `${window.layersConfig.apiBase}/browse/${COLLECTION_HANDLE}`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Storefront-Access-Token': window.layersConfig.storefrontApiToken,
      },
      body: JSON.stringify({ pagination: { page: 1, limit: 24 } }),
    },
  )

  const browse = await response.json()
  const banners = collectBanners(browse)

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

  // Inline banners — combined with products into grid cells.
  const cells = buildGridCells(browse.results ?? [], banners)
  renderGrid(document.querySelector('.collection__grid'), cells)
}

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 — that’s your theme’s responsibility. The embed does handle:
  • Authenticating the browse request (the embed forwards the storefront token and identity).
  • Sending the storefront pixel events that track shopper engagement on the underlying collection grid.
Banner click tracking is not automatic for direct-fetch integrations. If you need to measure clicks, add your own Tracking API event with a block_view/product_click-style payload keyed on data-banner-id.

Troubleshooting

Banners never appear. Confirm the rule is active, both web_media and mobile_media are filled, and the rule’s contextual conditions match the request. In the dashboard rule preview, banners are shown regardless of contextual conditions — so a banner that appears in preview but not on the storefront usually means a condition didn’t match. 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 when the grid has been flattened to a single array. Overtake banner dropped a product. That’s by design — overtake replaces the product at position. Use inject if you want to keep all products and shift the trailing ones. 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> (or the --span-x/--span-y properties from the snippet above) on banner cells.

See also