Skip to content

Building a custom search UI with the Search API

View MD

This guide provides a comprehensive walkthrough for building a feature-rich, custom search results page by calling the 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.

  • 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.
  • Developers building single-page or custom storefront UIs.
  • Anyone evaluating the Search API for custom integration.
  • 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.

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.

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

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

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

  • tracker_id: Your Luigi’s Box Tracker ID.
Section titled “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).

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

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

{
"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"
}
  • 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.

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

Section titled “Example: fetching search results with Axios”
// --- 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);
}
}

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

Section titled “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.
// --- 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);
}
}

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

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);
}
}
});

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 is included on your page.

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

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
}))
}
});
}

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

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

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

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
}
]
}
});
}
}
// 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
}
});

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

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);
}
});

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

Section titled “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.

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.

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;
}
});
  • 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.
  • 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 guide.