---
title: "Building a custom search UI with the Search API"
description: "Learn how to build a minimal but functional search results page using Luigi's Box Search API and your own JavaScript."
slug: quickstart/search/building-custom-ui
docKind: guide
hub: quickstart
---

## Introduction

This guide provides a comprehensive walkthrough for building a feature-rich, custom search results page by calling the [Search API](/search/api/) directly from your frontend. This approach gives you complete control over the final look and feel, allowing you to create a user experience that is perfectly tailored to your brand.

By the end of this guide, you will have a fully functional search page with interactive filters, numbered pagination, and dynamic product variant swatches, all powered by your own client-side JavaScript.

:::caution
This is a **demonstration guide**, not production code. In real-world application, for frontend integration, Luigi's Box recommends
using [Search.js](/search/search-js/), which provides a more robust, maintainable, and production-ready code.

The recommended way to use the [Search API](/search/api/) is through your own backend proxy.
:::

### What you'll learn

- How to call the Search API directly from the frontend.
- How to render search results, facets, and pagination controls from the API response.
- How to implement interactive features like filters and variant image previews.
- How to manage the browser's URL to create a shareable search experience.

### Who is this guide for

- Developers building single-page or custom storefront UIs.
- Anyone evaluating the Search API for custom integration.

### Prerequisites

- Your Luigi's Box `TrackerId`.
- The ability to write and serve a standard HTML, CSS, and JavaScript file.
- The ability to make HTTP requests from your frontend code.

:::note[Runnable example HTML]
- **Events API analytics:** [custom-search-ui.html](/examples/search/custom-search-ui.html) — [view source](/examples/search/custom-search-ui.html)
- **DataLayer analytics:** [custom-search-ui-datalayer.html](/examples/search/custom-search-ui-datalayer.html) — [view source](/examples/search/custom-search-ui-datalayer.html)

Right-click any link and choose "Save as" to download the HTML file for local inspection.
:::

## Step-by-step

### Step 1: set up the HTML structure

Start by creating the basic structure of your search page. This will include a search form, placeholders for results and facets, and containers for pagination.

#### Example: basic HTML layout

```html
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Search Demo</title>
</head>
<body>
  <form id="search-form">
    <input id="search-input" type="search" placeholder="Search products..." />
  </form>
  <div id="facets-container"></div>
  <h2 id="results-heading"></h2>
  <div id="results-container"></div>
  <div id="pagination-container"></div>

  <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
  <script>
  // --- DOM ELEMENTS ---
      const searchForm = document.getElementById("search-form");
      const searchInput = document.getElementById("search-input");
      const resultsContainer = document.getElementById("results-container");
      const facetsContainer = document.getElementById("facets-container");
      const resultsHeading = document.getElementById("results-heading");
      const paginationContainer = document.getElementById(
        "pagination-container"
      );

      // --- STATE MANAGEMENT ---
      let activeFilters = {};
      let currentPage = 1;
    // JS will go here
  </script>
</body>
</html>
```

:::note
Styling is omitted in this guide for clarity. You can bring your styles to customize the look and feel.
:::

### Step 2: understand the Search API request

To get results, you send a `GET` request to the Search API endpoint.

#### Endpoint

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

#### Required parameters

- `tracker_id`: Your Luigi's Box Tracker ID.

#### Optional parameters (recommended for a functional search page)

- `q`: The user's search query. While optional (for filter-only pages), this is the primary input for any search box interaction.
- `f[]`: An array of filters using `key:value` syntax. (**Highly recommended**) You should almost always include `f[]=type:product` to ensure you only get results for your main content type.
- `hit_fields`:  A comma-separated list of product attributes to return. (**Highly recommended**) Requesting only the fields you need (e.g., `title,url,price,image_link`) significantly improves performance by reducing the response size.
- `facets`:  A comma-separated list of attributes for which you want to receive filter options (e.g., `brand,category,price_amount`).
- `size`: The number of results per page (default is 10).
- `page`: The page number for pagination (default is 1).

#### Example: Search API request URL

`GET` `https://live.luigisbox.com/search?tracker_id=<YOUR_TRACKER_ID>&q=digital+piano&f[]=type:product&facets=brand,category,price_amount&hit_fields=title,url,price_amount,image_link,brand,nested,color_code,color,id&page=1`

### Step 3: understand the API response

The API responds with a JSON object containing everything needed to render the page.

#### Example: Search API response

```json
{
  "results": {
    "query": "digital piano",
    "filters": [
      "type:product"
    ],
    "hits": [
      {
        "url": "/smart-piano-the-one-digital-piano/?variantId=2369748",
        "attributes": {
          "brand": [
            "Smart piano"
          ],
          "color": [
            "Black"
          ],
          "title": "Smart piano The ONE Digital Piano",
          "id": [
            "2369748"
          ],
          "price_amount": 629,
          "image_link": "https://cdn.myshoptet.com/usr/demoshop.luigisbox.com/user/shop/detail/1774257-1.jpg",
          "color_code": [
            "#3d2b1f"
          ]
        },
        "nested": [
          {
            "url": "/digital-pianos/",
            "attributes": {
              "title": "Digital Pianos"
            },
            "type": "category"
          },
          {
            "url": "/smart-piano/",
            "attributes": {
              "title": "Smart piano"
            },
            "type": "brand"
          },
          {
            "url": "",
            "attributes": {
              "id": [
                "2369745"
              ],
              "brand": [
                "Smart piano"
              ],
              "price_amount": 1279,
              "image_link": "https://cdn.myshoptet.com/usr/demoshop.luigisbox.com/user/shop/detail/1774257.jpg",
              "color": [
                "White"
              ],
              "title": "Smart piano The ONE Digital Piano",
              "color_code": [
                "#ffffff"
              ]
            },
            "type": "variant"
          }
        ],
        "type": "product"
      }
    ],
    "facets": [
      {
        "name": "brand",
        "type": "text",
        "values": [
          {
            "value": "Yamaha",
            "hits_count": 37
          }
        ]
      },
      {
        "name": "category",
        "type": "text",
        "values": [
          {
            "value": "Musicians",
            "hits_count": 172
          }
        ]
      },
      {
        "name": "price_amount",
        "type": "float",
        "values": [
          {
            "value": "2.89|213.0",
            "hits_count": 36,
            "normalized_hits_count": 0.21
          }
        ]
      }
    ],
    "total_hits": 172
  },
  "next_page": "https://live.luigisbox.com/search?tracker_id=179075-204259&q=digital%20piano&f[]=type:product&facets=brand,category,price_amount&hit_fields=title,url,price_amount,image_link,brand,nested,color_code,color,id&page=2"
}
```

#### Key fields overview

- `results.total_hits`: The total number of products found for the query, used for building pagination.
- `results.hits`: An array of the product results for the current page. Each `hit` contains:
    - `attributes`: An object with all the product data you've indexed, like `title`, `price_amount`, and `image_link`.
    - `nested`: An array containing product variants (e.g., different colors or sizes) if they exist. This allows you to display variant-specific information, like a different image for each color.
- `results.facets`: An array of filter groups (e.g., "brand," "price"). Each facet contains a `name` and an array of values, where each value has the number of matching products (`hits_count`).
- `next_page`: A pre-built URL to fetch the next page of results, which is useful for "load more" style pagination.

### Step 4: fetch search results

Here's how to call the Search API with filters and optional query text. This function handles pagination and invokes the rendering functions.

#### Example: fetching search results with Axios

```javascript
// --- CONFIGURATION ---
const TRACKER_ID = 'YOUR_TRACKER_ID';
const API_ENDPOINT = 'https://live.luigisbox.com/search';
const RESULTS_PER_PAGE = 9;


// --- API CALL ---
async function getSearchResults(query, filters = {}, page = 1) {
    const params = {
        tracker_id: TRACKER_ID,
        'f[]': ['type:product'],
        facets: 'brand,category,price_amount',
        hit_fields: 'title,url,price_amount,image_link,brand,nested,color_code,color,id',
        size: RESULTS_PER_PAGE,
        page: page
    };

    if (query) {
        params.q = query;
    }

    for (const key in filters) {
        filters[key].forEach(value => {
            params['f[]'].push(`${key}:${value}`);
        });
    }

    try {
        const response = await axios.get(API_ENDPOINT, { params });
        const data = response.data;

        currentPage = page;
        renderResults(data.results);
        renderFacets(data.results.facets);
        renderPagination(data.results.total_hits);
        updateURL(query, filters, page);

        // Track the search event for analytics (always send, even with zero results)
        trackSearchView(query, data.results.hits);

    } catch (error) {
        console.error("Error fetching search results:", error);
    }
}
```

### Step 5: render search results and filters

These functions take the API response and generate the HTML for product cards, filter checkboxes, and pagination buttons.

#### Example: render products, facets, and page controls

- `renderResults(resultsData)`: This function takes the results object from the API response. It iterates through the hits array to build and display a product card for each item, including its image, title, brand, price, and an "Add to cart" button. It also updates the main heading to show the total number of results found.
- `renderFacets(facetsData)`: This function takes the facets array from the API response. It processes this array to create the filter sidebar, rendering each facet (like "brand" or "price") as a group of checkboxes with the corresponding value and the count of matching items.
- `renderPagination(totalHits)`: This function takes the `total_hits` number from the API response. It calculates how many pages are needed based on the `RESULTS_PER_PAGE` constant and then generates the numbered pagination buttons, including the "next" and "previous" controls.
- `updateURL(query, filters, page)`: This function manages the browser's history. It takes the current search query, active filters, and page number and updates the URL in the address bar. This creates a shareable link for the current search state and allows the browser's back and forward buttons to work correctly.

```javascript
// --- RENDERING FUNCTIONS ---
function renderResults(resultsData) {
  const queryText = resultsData.query
    ? ` for "${resultsData.query}"`
    : "";
  resultsHeading.textContent = `Showing ${resultsData.hits.length} of ${resultsData.total_hits} results${queryText}`;

  if (resultsData.hits.length === 0) {
    resultsContainer.innerHTML = "<p>No products found.</p>";
    return;
  }

  resultsContainer.innerHTML = resultsData.hits
    .map((result) => {
      const { url, attributes, nested } = result;
      const imageUrl =
        attributes.image_link ||
        "https://placehold.co/400x400/eee/ccc?text=No+Image";
      const title = attributes.title || "No Title Available";
      const brand = attributes.brand ? attributes.brand[0] : "";
      const price = attributes.price_amount
        ? `${attributes.price_amount} EUR`
        : "";
      const productId = attributes.id ? attributes.id[0] : "";

      return `
              <div class="product-card" data-main-image="${imageUrl}">
                  <a href="${url}" target="_blank" class="product-link"
                     data-product-id="${productId}">
                      <img src="${imageUrl}" alt="${title}" class="product-image">
                  </a>
                  <div class="product-info">
                      <h3 class="product-title">${title}</h3>
                      <p class="product-brand">${brand}</p>
                      <div class="product-price-section">
                          <div class="product-price">${price}</div>
                          <button class="add-to-cart-btn"
                                  data-product-id="${productId}">
                              Add to cart
                          </button>
                      </div>
                  </div>
              </div>
          `;
    })
    .join("");
}

function renderFacets(facetsData) {
  if (!facetsData) {
    facetsContainer.innerHTML = "";
    return;
  }
  facetsContainer.innerHTML = facetsData
    .map((facet) => {
      const content = facet.values
        .map((val) => {
          if (
            val.hits_count === 0 &&
            !activeFilters[facet.name]?.includes(val.value)
          ) {
            return "";
          }
          const isChecked = activeFilters[facet.name]?.includes(val.value)
            ? "checked"
            : "";
          let label = val.value;
          if (facet.name === "price_amount") {
            const [min, max] = val.value.split("|");
            label = `${parseInt(min, 10)} - ${parseInt(max, 10)} EUR`;
          }
          return `
                  <li class="facet-item">
                      <label>
                          <input type="checkbox" name="${facet.name}"
                                 value="${val.value}" ${isChecked}>
                          <span>${label}</span>
                          <span class="count">${val.hits_count}</span>
                      </label>
                  </li>
              `;
        })
        .join("");

      const displayTitle =
        facet.name === "price_amount" ? "Price" : facet.name;

      return `
              <div class="facet-block">
                  <h3 class="facet-title">${displayTitle}</h3>
                  <ul class="facet-list">${content}</ul>
              </div>
          `;
    })
    .join("");
}

function renderPagination(totalHits) {
  const totalPages = Math.ceil(totalHits / RESULTS_PER_PAGE);
  paginationContainer.innerHTML = "";
  if (totalPages <= 1) return;

  let paginationHTML = `<button class="pagination-button" data-page="1" ${
    currentPage === 1 ? "disabled" : ""
  }>&lt;&lt;</button>`;
  paginationHTML += `<button class="pagination-button" data-page="${
    currentPage - 1
  }" ${currentPage === 1 ? "disabled" : ""}>&lt;</button>`;

  let startPage, endPage;
  if (totalPages <= 5) {
    startPage = 1;
    endPage = totalPages;
  } else {
    if (currentPage <= 3) {
      startPage = 1;
      endPage = 5;
    } else if (currentPage + 2 >= totalPages) {
      startPage = totalPages - 4;
      endPage = totalPages;
    } else {
      startPage = currentPage - 2;
      endPage = currentPage + 2;
    }
  }

  if (startPage > 1) {
    paginationHTML += `<button class="pagination-page" data-page="1">1</button>`;
    if (startPage > 2)
      paginationHTML += `<span class="pagination-ellipsis">...</span>`;
  }

  for (let i = startPage; i <= endPage; i++) {
    paginationHTML += `<button class="pagination-page ${
      i === currentPage ? "active" : ""
    }" data-page="${i}">${i}</button>`;
  }

  if (endPage < totalPages) {
    if (endPage < totalPages - 1)
      paginationHTML += `<span class="pagination-ellipsis">...</span>`;
    paginationHTML += `<button class="pagination-page" data-page="${totalPages}">${totalPages}</button>`;
  }

  paginationHTML += `<button class="pagination-button" data-page="${
    currentPage + 1
  }" ${currentPage === totalPages ? "disabled" : ""}>&gt;</button>`;
  paginationHTML += `<button class="pagination-button" data-page="${totalPages}" ${
    currentPage === totalPages ? "disabled" : ""
  }>&gt;&gt;</button>`;

  paginationContainer.innerHTML = paginationHTML;
}

// --- URL MANAGEMENT ---
function updateURL(query, filters, page) {
  const urlParams = new URLSearchParams();
  if (query) urlParams.set("q", query);
  if (page > 1) urlParams.set("page", page);

  for (const key in filters) {
    filters[key].forEach((value) => {
      urlParams.append("f[]", `${key}:${value}`);
    });
  }

  const newRelativePath = `?${urlParams.toString()}`;
  if (window.location.search !== newRelativePath) {
    history.pushState({ query, filters, page }, "", newRelativePath);
  }
}
```

### Step 6: handle user interactions

Wire up event listeners for the search form and facet checkboxes. These will trigger new API calls whenever the user refines their search.

#### Example: search and filter events

```javascript
document.addEventListener("DOMContentLoaded", () => {
  const urlParams = new URLSearchParams(window.location.search);
  const query = urlParams.get("q") || "";
  const page = parseInt(urlParams.get("page"), 10) || 1;
  const filtersFromUrl = {};
  urlParams.getAll("f[]").forEach((filterString) => {
    const [key, value] = filterString.split(":", 2);
    if (key && value) {
      if (!filtersFromUrl[key]) filtersFromUrl[key] = [];
      filtersFromUrl[key].push(value);
    }
  });

  searchInput.value = query;
  activeFilters = filtersFromUrl;
  currentPage = page;

  getSearchResults(query, activeFilters, currentPage);
});

window.addEventListener("popstate", (e) => {
  if (e.state) {
    searchInput.value = e.state.query || "";
    activeFilters = e.state.filters || {};
    currentPage = e.state.page || 1;
    getSearchResults(e.state.query, e.state.filters, e.state.page);
  }
});

searchForm.addEventListener("submit", (e) => {
  e.preventDefault();
  getSearchResults(searchInput.value, activeFilters, 1);
});

facetsContainer.addEventListener("change", (e) => {
  if (e.target.type === "checkbox") {
    const facetName = e.target.name;
    const facetValue = e.target.value;

    if (!activeFilters[facetName]) activeFilters[facetName] = [];

    if (e.target.checked) {
      activeFilters[facetName].push(facetValue);
    } else {
      activeFilters[facetName] = activeFilters[facetName].filter(
        (v) => v !== facetValue
      );
      if (activeFilters[facetName].length === 0)
        delete activeFilters[facetName];
    }

    getSearchResults(searchInput.value, activeFilters, 1);
  }
});

paginationContainer.addEventListener("click", (e) => {
  if (e.target.matches(".pagination-button, .pagination-page")) {
    const page = parseInt(e.target.dataset.page, 10);
    if (page && page !== currentPage && !e.target.disabled) {
      getSearchResults(searchInput.value, activeFilters, page);
    }
  }
});
```

### Step 7: track analytics events manually

To help Luigi's Box learning models improve result relevance, you must manually track search and interaction events. This is the most critical step for a manual integration.

You have two options for sending analytics: the **DataLayer Collector** (recommended for web integrations that already use a `dataLayer`) or the **Events API** (recommended for backend or mobile integrations). If you choose the DataLayer Collector, make sure the [LBX script](/lbx-script/) is included on your page.

#### Track the search view event

After displaying the search results, you must immediately send a view event. This tells Luigi's Box which products were shown to the user.

```javascript+dataLayer
function trackSearchView(query, hits) {
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: "view_item_list",
      ecommerce: {
        item_list_name: "Search Results",
        search_term: query,
        items: hits.map((hit, index) => ({
          item_id: hit.url,                // Must match your catalog identity
          item_name: hit.attributes.title,
          index: (currentPage - 1) * RESULTS_PER_PAGE + index + 1,
          price: hit.attributes.price_amount
        }))
      }
    });
}
```

```javascript+API
// --- ANALYTICS CONFIGURATION ---
const ANALYTICS_API_URL = "https://api.luigisbox.com/";
// In a real application, this should be a persistent ID stored in a cookie or localStorage
const CLIENT_ID = Math.floor(Math.random() * 1e18);

async function sendAnalyticsEvent(payload) {
    try {
        await axios.post(ANALYTICS_API_URL, payload);
        console.log('Analytics event sent:', payload.type, payload);
    } catch (error) {
        console.error('Failed to send analytics event:', error);
    }
}

function trackSearchView(query, hits) {
    const analyticsPayload = {
        id: crypto.randomUUID(),
        type: "event",
        tracker_id: TRACKER_ID,
        client_id: CLIENT_ID,
        lists: {
            "Search Results": {
                query: { string: query },
                items: hits.map((hit, index) => ({
                    title: hit.attributes.title,
                    url: hit.url,
                    position: (currentPage - 1) * RESULTS_PER_PAGE + index + 1
                }))
            }
        }
    };
    sendAnalyticsEvent(analyticsPayload);
}
```

#### Track click events

You must also track when a user clicks on a product link.

```javascript+dataLayer
function trackClickEvent(productId) {
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: "select_item",
      ecommerce: {
        items: [
          {
            item_id: productId
          }
        ]
      }
    });
}
```

```javascript+API
function trackClickEvent(productId) {
    const clickPayload = {
        id: crypto.randomUUID(),
        type: "click",
        tracker_id: TRACKER_ID,
        client_id: CLIENT_ID,
        action: {
            type: "click",
            resource_identifier: productId
        }
    };
    sendAnalyticsEvent(clickPayload);
}
```

#### Track conversion events

Track when a user performs a meaningful action like adding an item to their cart.

```javascript+dataLayer
function trackConversionEvent(type, productId) {
    window.dataLayer = window.dataLayer || [];
    if (type === "add-to-cart") {
        window.dataLayer.push({
          event: "add_to_cart",
          ecommerce: {
            currency: "EUR",    // Adjust to your currency
            value: 0,           // Set to the actual item price
            items: [
              {
                item_id: productId
              }
            ]
          }
        });
    }
}
```

```javascript+API
function trackConversionEvent(type, productId) {
    const conversionPayload = {
        id: crypto.randomUUID(),
        type: "click", // Conversion events are a type of click
        tracker_id: TRACKER_ID,
        client_id: CLIENT_ID,
        action: {
            type: type, // e.g., "add-to-cart"
            resource_identifier: productId
        }
    };
    sendAnalyticsEvent(conversionPayload);
}
```

#### Tracking no results

:::caution
You must send an analytics event even when a search returns **zero results**. This helps Luigi's Box identify queries that need improvement. Send the same search view event but with an empty items array.
:::

```javascript+dataLayer
// DataLayer Collector: No Results
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
  event: "view_item_list",
  ecommerce: {
    item_list_name: "Search Results",
    search_term: query,
    items: [] // Empty array for no results
  }
});
```

```javascript+API
// Events API: No Results
function trackSearchView(query, hits) {
    const analyticsPayload = {
        id: crypto.randomUUID(),
        type: "event",
        tracker_id: TRACKER_ID,
        client_id: CLIENT_ID,
        lists: {
            "Search Results": {
                query: { string: query },
                items: [] // Empty array for no results
            }
        }
    };
    sendAnalyticsEvent(analyticsPayload);
}
```

#### Tracking clicks and conversions

Finally, add a listener to the `resultsContainer` to track clicks on product cards and "Add to cart" buttons.

```javascript
resultsContainer.addEventListener('click', function(e) {
    const productLink = e.target.closest('.product-link');
    const cartButton = e.target.closest('.add-to-cart-btn');

    if (productLink && !cartButton) {
        const productId = productLink.dataset.productId;
        trackClickEvent(productId);
    }

    if (cartButton) {
        e.preventDefault();
        const productId = cartButton.dataset.productId;
        trackConversionEvent('add-to-cart', productId);
    }
});
```

### Step 8: add variant swatches (optional)

To enhance the user experience, you can display product variants as swatches. This allows users to see different colors or styles without leaving the search results page.

#### Example: adding variant swatches to product cards

Modify the `renderResults` function to include variant swatches. This example assumes that each product has a `nested` array containing variant information.

```javascript
function renderResults(resultsData) {
  const queryText = resultsData.query
    ? ` for "${resultsData.query}"`
    : "";
  resultsHeading.textContent = `Showing ${resultsData.hits.length} of ${resultsData.total_hits} results${queryText}`;

  if (resultsData.hits.length === 0) {
    resultsContainer.innerHTML = "<p>No products found.</p>";
    return;
  }

  resultsContainer.innerHTML = resultsData.hits
    .map((result) => {
      const { url, attributes, nested } = result;
      const imageUrl =
        attributes.image_link ||
        "https://placehold.co/400x400/eee/ccc?text=No+Image";
      const title = attributes.title || "No Title Available";
      const brand = attributes.brand ? attributes.brand[0] : "";
      const price = attributes.price_amount
        ? `${attributes.price_amount} EUR`
        : "";
      const productId = attributes.id ? attributes.id[0] : "";

      let variantSwatches = "";
      const variants = nested?.filter((v) => v.type === "variant") || [];
      if (variants.length > 0) {
        const uniqueColors = new Map();
        variants.forEach((variant) => {
          const colorName = variant.attributes.color?.[0];
          if (colorName) {
            const normalizedColorName = colorName.toLowerCase();
            if (!uniqueColors.has(normalizedColorName)) {
              uniqueColors.set(normalizedColorName, {
                colorCode: variant.attributes.color_code?.[0] || "#ccc",
                image: variant.attributes.image_link || imageUrl,
              });
            }
          }
        });

        if (uniqueColors.size > 0) {
          variantSwatches = '<div class="variant-swatches">';
          uniqueColors.forEach((data) => {
            variantSwatches += `<div class="variant-swatch"
              style="background-color: ${data.colorCode};"
              data-image="${data.image}"></div>`;
          });
          variantSwatches += "</div>";
        }
      }

      return `
              <div class="product-card" data-main-image="${imageUrl}">
                  <a href="${url}" target="_blank" class="product-link"
                     data-product-id="${productId}">
                      <img src="${imageUrl}" alt="${title}" class="product-image">
                  </a>
                  <div class="product-info">
                      <h3 class="product-title">${title}</h3>
                      <p class="product-brand">${brand}</p>
                      ${variantSwatches}
                      <div class="product-price-section">
                          <div class="product-price">${price}</div>
                          <button class="add-to-cart-btn"
                                  data-product-id="${productId}">
                              Add to cart
                          </button>
                      </div>
                  </div>
              </div>
          `;
    })
    .join("");
}
```

Add event listeners to handle swatch hovers and update the main product image accordingly.

```javascript
document.addEventListener("mouseover", function (e) {
  if (e.target.classList.contains("variant-swatch")) {
    const card = e.target.closest(".product-card");
    const imageElement = card.querySelector(".product-image");
    const newImage = e.target.dataset.image;
    if (imageElement && newImage) imageElement.src = newImage;
  }
});

document.addEventListener("mouseout", function (e) {
  if (e.target.classList.contains("variant-swatch")) {
    const card = e.target.closest(".product-card");
    const imageElement = card.querySelector(".product-image");
    const mainImage = card.dataset.mainImage;
    if (imageElement && mainImage) imageElement.src = mainImage;
  }
});
```


## Best practices

- **Analytics is not optional:** When building a custom UI, you are responsible for sending all analytics events. This is not an optional step; it is essential for the learning models that power search relevance and personalization.
- **Use a persistent `CLIENT_ID`:** In this example, we generate a random `CLIENT_ID` on each page load. In a production environment, you should generate this ID once and store it in a long-term cookie or localStorage to track users across sessions.
- **Provide user feedback:** Always provide clear feedback to the user. This includes showing a loading state while fetching data and displaying a "No results found" message when the API returns an empty set.
- **Handle data fallbacks:** Your rendering code should be robust. Always check for the existence of data before trying to access it (e.g., `attributes.brand ? attributes.brand[0] : ''`) and provide sensible fallbacks, like a placeholder image if `image_link` is missing.

## Next steps

- **Consider a backend proxy:** For a production application, consider moving the API call to a backend proxy. This avoids potential browser CORS issues and allows you to securely add custom business logic before sending the data to the frontend.
- **Explore dynamic facets:** To make your filters even more relevant, add the `dynamic_facets_size` parameter to your API calls. This will tell the Luigi's Box AI to return the most appropriate filters for each specific query.
- **Influence ranking:** Learn how to promote certain products or adjust the default sorting by reading our [Understanding and influencing result ranking](/quickstart/search/understanding-rankings/) guide.
