Navigation

Quickstart: Rendering banner campaigns with the Search API

Introduction

This guide is for developers who already build a custom search results page with the Search API and now want to render banner campaigns in their own search UI.

Search banner campaigns are returned inside the results.campaigns array in the standard Search API response. There is no dedicated banners endpoint. Your job is to read those campaign definitions and render them into the appropriate positions in your page. See what you'll build »

Conceptually, banner campaigns are additional rendering instructions attached to the same search response you already use for products, facets, and pagination. You still render the normal results.hits array as before, but now you also inspect results.campaigns and decide whether a banner should appear above the list, in the side panel, inside the list, or below it.

What you'll learn

  • How to read banner campaign data from the Search API response.
  • How to render the supported search banner positions.
  • How to insert a list banner directly into the search results grid.
  • How to keep your existing custom search implementation and extend it with banners.

Who is this guide for

  • Developers already using the Search API directly.
  • Teams building custom search pages instead of Search.js.
  • Developers who need full control over where search banners appear.

Prerequisites

Before you start, please ensure you have the following in place:

  • A working custom search page that already calls the Search API.
  • The ability to make HTTP GET requests and render search results.
  • Your Luigi's Box trackerId.
  • Banner campaigns configured in the Luigi's Box application for at least one search query.

Important This guide assumes you already understand the basics from Quickstart: Building a custom Search UI with the Search API. If you do not already render results.hits, start there first.

Step-by-step

Step 1: Keep your normal Search API request

Banner campaigns are returned through the normal search response.

This means your first goal is not to redesign the request, but to understand that the existing request already contains everything you need. If a banner campaign matches the current query, the API simply enriches the response with the results.campaigns array.

Endpoint

GET https://live.luigisbox.com/search

Required parameters

  • tracker_id: Your unique site identifier.
  • q: The current search query.
  • f[]: Filters, usually including type:product.
  • hit_fields: Only the attributes you want to render, such as title,url,price_amount,image_link,brand.
  • size: Number of results per page.

Example

The code below follows the same request pattern as a normal custom search UI. The only banner-specific addition is that after reading response.data.results, you also read results.campaigns and pass that data into a dedicated campaign renderer.

const TRACKER_ID = 'YOUR_TRACKER_ID';
const SEARCH_API_URL = 'https://live.luigisbox.com/search';

async function loadSearchResults(query) {
  const response = await axios.get(SEARCH_API_URL, {
    params: {
      tracker_id: TRACKER_ID,
      q: query,
      'f[]': ['type:product'],
      size: 8,
      hit_fields: 'title,url,price_amount,image_link,brand',
    },
  });

  const resultsData = response.data.results || {};
  const hits = resultsData.hits || [];
  const campaigns = resultsData.campaigns || [];

  const listBanner = renderCampaigns(campaigns);
  renderResults(resultsData, listBanner);
}

The only addition is that you now read results.campaigns alongside the regular search hits.

That separation matters. results.hits still drives your core result cards, while results.campaigns acts as optional merchandising content that can be rendered into specific layout slots.

Step 2: Understand the search banner response

Search banner campaigns are returned inside results.campaigns.

Each item in results.campaigns is one campaign definition that matched the current query. In practice, each campaign object answers three questions:

  • what campaign matched, via id
  • where the click should go, via target_url
  • which search positions have images available, via the banners object

The banners object is a dictionary keyed by fixed Luigi's Box position names. Those keys are the contract between the application and your frontend. Your UI can decide how those positions look visually, but the keys themselves are stable and should be treated as identifiers for placement.

Example response fragment

{
  "results": {
    "query": "guitar",
    "hits": [
      {
        "url": "https://yourshop.com/products/acoustic-guitar",
        "attributes": {
          "title": "Acoustic Guitar",
          "price_amount": 499,
          "image_link": "https://yourshop.com/images/guitar.jpg",
          "brand": ["Fender"]
        },
        "type": "product"
      }
    ],
    "campaigns": [
      {
        "id": 13,
        "target_url": "https://yourshop.com/sale",
        "banners": {
          "search_header": {
            "desktop_url": "https://yourshop.com/banners/header-desktop.jpg",
            "mobile_url": "https://yourshop.com/banners/header-mobile.jpg"
          },
          "search_footer": {
            "desktop_url": "https://yourshop.com/banners/footer-desktop.jpg",
            "mobile_url": "https://yourshop.com/banners/footer-mobile.jpg"
          }
        }
      }
    ]
  }
}

Notice that campaigns sits inside results, next to hits, facets, and the other search-specific data. That is why this guide reads response.data.results.campaigns rather than a top-level campaigns array.

Also note that a single campaign can provide banners for multiple positions at once. For example, one campaign may have both search_header and search_footer, while another query might return only a search_list banner.

The supported search banner positions are:

  • search_header
  • search_panel
  • search_list
  • search_footer

Each position object inside banners typically contains:

  • desktop_url for larger screens
  • mobile_url for smaller screens

At the campaign level, you will also typically use:

  • target_url as the click destination
  • id for debugging or diagnostics

In other words, the campaign object tells you which banner belongs to which slot, while the nested position object tells you which image asset to display there.

Step 3: Extract the first banner for each position

As with autocomplete banners, it is useful to normalize the campaign data before rendering.

The raw API response is campaign-oriented, but your UI is usually slot-oriented. Your page might have a dedicated header banner area, a panel area, and a footer area. A small helper lets you ask a simple question such as “give me the first search_header banner” instead of repeatedly scanning the full campaign array in each render branch.

Example

The helper below filters the campaigns for the requested position and returns the first match in a simplified structure that is easy to pass into rendering functions.

function firstBannerForPosition(campaigns, position) {
  return campaigns
    .filter((campaign) => campaign.banners && campaign.banners[position])
    .map((campaign) => ({
      targetUrl: campaign.target_url,
      banner: campaign.banners[position],
      position,
    }))[0];
}

This helper keeps the rendering code simple and position-based.

Most banner positions can be rendered directly into dedicated containers in your layout.

Before looking at the code, it helps to split the responsibility into two layers:

  • a small function that turns one banner entry into HTML
  • a coordinator function that chooses which campaign belongs in each page slot

That approach keeps each piece narrow and makes it easier to test and debug.

In the example below, bannerMarkup handles the HTML generation for a single banner entry. renderCampaigns then calls it for the header, panel, and footer containers, and separately returns the list banner for use inside the product grid.

function isMobileViewport() {
  return window.matchMedia('(max-width: 767px)').matches;
}

function bannerMarkup(entry, altLabel, className = 'campaign-card') {
  if (!entry) {
    return `<div class="placeholder">No ${altLabel} banner returned for this query.</div>`;
  }

  const imageUrl = isMobileViewport()
    ? entry.banner.mobile_url || entry.banner.desktop_url
    : entry.banner.desktop_url || entry.banner.mobile_url;

  if (!imageUrl) {
    return `<div class="placeholder">No usable ${altLabel} image URL returned.</div>`;
  }

  return `
    <a class="${className}" href="${entry.targetUrl}" target="_blank" rel="noreferrer noopener">
      <img src="${imageUrl}" alt="${altLabel} banner campaign">
    </a>
  `;
}

function renderCampaigns(campaigns) {
  document.getElementById('search-header').innerHTML = bannerMarkup(
    firstBannerForPosition(campaigns, 'search_header'),
    'search header'
  );

  document.getElementById('search-panel').innerHTML = bannerMarkup(
    firstBannerForPosition(campaigns, 'search_panel'),
    'search panel'
  );

  document.getElementById('search-footer').innerHTML = bannerMarkup(
    firstBannerForPosition(campaigns, 'search_footer'),
    'search footer'
  );

  return firstBannerForPosition(campaigns, 'search_list');
}

This function also returns the search_list banner so you can place it inside the results grid.

That extra return value is important because search_list behaves differently from the other positions. It does not belong in a static container around the page. It belongs inside the list of rendered results.

Step 5: Insert the list banner into the search results

The search_list position is different because it belongs inside the list of rendered results.

Instead of rendering search_list above or below the results, you usually inject it into the array of already-rendered product cards. This is best treated as a normal rendering decision: build your product card markup first, then splice the banner markup into the desired index.

Example

The example below builds the normal product cards, then inserts the list banner after the first three results if one is available.

function renderResults(resultsData, listBanner) {
  const hits = resultsData.hits || [];

  const cards = hits.map((result) => {
    const attributes = result.attributes || {};
    const title = attributes.title || 'Untitled';
    const image = attributes.image_link || '';
    const url = result.url || '#';

    return `
      <article class="product-card">
        <a href="${url}">
          <img src="${image}" alt="${title}">
        </a>
        <div class="product-body">${title}</div>
      </article>
    `;
  });

  if (listBanner) {
    const bannerCard = bannerMarkup(listBanner, 'search list', 'banner-in-list');
    const insertAt = Math.min(3, cards.length);
    cards.splice(insertAt, 0, bannerCard);
  }

  document.getElementById('results-grid').innerHTML = cards.join('');
}

The insertion point is entirely your choice. This example inserts the banner after the first three product cards.

In your own implementation, that insertion point can be based on design, merchandising goals, or device type. The important part is that search_list should be treated as one more tile in the rendered sequence.

Step 6: Keep your existing analytics approach

Banner campaigns do not replace the normal manual analytics requirements for Search API integrations. Continue sending analytics for the search results page as described in Quickstart: Building a custom Search UI with the Search API.

Note Banners are purely additional rendering data. You do not need to change your request format to enable them. If a query matches an active campaign, Luigi's Box automatically includes results.campaigns in the response.

Finished example

You can see the complete working example here:

Next steps