> ## Documentation Index
> Fetch the complete documentation index at: https://docs.uselayers.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Rendering banners in Liquid

> Render merchandising banners from the browse response in a Shopify theme — hero rows above the grid and inline overtake / inject tiles slotted into the product grid by position.

## Overview

A [merchandising rule](/platform/merchandising/banners) can attach up to five banners as a consequence. When one or more rules match a browse request, the response carries a top-level `banners` array — flat, already merged across every applied rule and sorted for you. 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](/developers/rendering-banners-with-the-sdk).

## Prerequisites

* The Layers Shopify app installed and configured. See [Liquid integration](/developers/liquid-integration).
* A working browse implementation that reads from the Layers [Browse API](/api-reference/browse) — for example, the patterns shown in [Rendering facets in Liquid](/developers/rendering-facets-in-liquid).
* At least one merchandising rule with banners attached. See [Add a banner to a rule](/help/merchandising/add-banner-to-rule).

## The banner payload

Banners ship as a top-level `banners` array on the browse response. The IDs of the rules that produced them are available on `_meta.appliedRules`.

```json theme={null}
{
  "results": [ /* ... */ ],
  "banners": [
    {
      "id": "01KWD5S5R50690NXFW32PXM0V2",
      "name": "Willie Nelson Flannel",
      "mode": "inject",
      "link": null,
      "sort_index": 0,
      "web_media": {
        "id": "gid://shopify/MediaImage/30292299612213",
        "alt": null,
        "src": "https://cdn.shopify.com/.../banner-web.jpg",
        "width": 750,
        "height": 1100,
        "source": "store_file",
        "mediaContentType": "IMAGE"
      },
      "mobile_media": {
        "id": "gid://shopify/MediaImage/30292299612213",
        "alt": null,
        "src": "https://cdn.shopify.com/.../banner-mobile.jpg",
        "width": 750,
        "height": 1100,
        "source": "store_file",
        "mediaContentType": "IMAGE"
      },
      "web_layout":    { "placement": "inline", "width": 1, "height": 1, "position": 1 },
      "mobile_layout": { "placement": "inline", "width": 1, "height": 1, "position": 1 }
    }
  ],
  "_meta": {
    "appliedRules": ["bd2d34eb-0d87-4299-90ee-ffeb572a12f6"]
  }
}
```

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 — Read banners from the browse response

Banners are already flat and pre-sorted by `sort_index` — you just need to guard against a missing array.

```js theme={null}
function collectBanners(browseResponse) {
  return browseResponse.banners ?? []
}
```

## 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.

```js theme={null}
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, so `position` is not meaningful for them — render heroes in `sort_index` order.

```js theme={null}
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.

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

  // Inline banners only. The response already sorts by sort_index.
  const inline = banners
    .map((b) => ({ banner: b, picked: layoutForDevice(b) }))
    .filter(({ picked }) => picked && picked.layout.placement === 'inline')

  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:

```js theme={null}
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](/platform/merchandising/banners#limits-and-behavior):

* 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](/shopify-integration/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](/tracking-api/overview) 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](/platform/merchandising/banners#scheduling-and-targeting), 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

* [Banner Injection](/platform/merchandising/banners) — payload reference and dashboard model.
* [Rendering banners with the SDK](/developers/rendering-banners-with-the-sdk) — SDK equivalent.
* [Add a banner to a rule](/help/merchandising/add-banner-to-rule) — merchandiser walkthrough.
* [Browse API](/api-reference/browse) — request and response reference.
