Loading...
Loading...
Apply when implementing search functionality, faceted navigation, or autocomplete in a headless VTEX storefront. Covers product_search, autocomplete_suggestions, facets, banners, correction_search, and top_searches endpoints, plus analytics event collection. Use for any custom frontend that integrates VTEX Intelligent Search API for product discovery and search result rendering.
npx skill4agent add vtex/skills headless-intelligent-searchheadless-bff-architectureheadless-checkout-proxyheadless-caching-strategysortpagelocaleoperatorfuzzy| Endpoint | Method | Purpose |
|---|---|---|
| GET | Search products by query and/or facets |
| GET | Get available filters for a query |
| GET | Get term and product suggestions while typing |
| GET | Get the 10 most popular search terms |
| GET | Get spelling correction for a misspelled term |
| GET | Get suggested terms similar to the search term |
| GET | Get banners configured for a query |
sp.vtex.com/event-api// search-analytics.ts — sends events to Intelligent Search Events API
const ACCOUNT_NAME = "mystore";
const EVENTS_URL = `https://sp.vtex.com/event-api/v1/${ACCOUNT_NAME}/event`;
interface SearchEvent {
type: "search.query" | "search.click" | "search.add_to_cart";
text: string;
misspelled: boolean;
match: number;
operator: string;
locale: string;
agent: string;
url: string;
products?: Array<{ productId: string; position: number }>;
}
export async function sendSearchEvent(event: SearchEvent): Promise<void> {
try {
await fetch(EVENTS_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(event),
keepalive: true, // ensures event is sent even during navigation
});
} catch (error) {
// Analytics failures should not break the UI
console.warn("Failed to send search event:", error);
}
}
// Usage: call after rendering search results
function onSearchResultsRendered(query: string, products: Product[]): void {
sendSearchEvent({
type: "search.query",
text: query,
misspelled: false,
match: products.length,
operator: "and",
locale: "en-US",
agent: "my-headless-store",
url: window.location.href,
products: products.map((p, i) => ({
productId: p.productId,
position: i + 1,
})),
});
}// Search works but NO analytics events are sent — search ranking degrades
async function searchProducts(query: string): Promise<Product[]> {
const response = await fetch(
`https://mystore.vtexcommercestable.com.br/api/io/_v/api/intelligent-search/product_search/?query=${query}&locale=en-US`
);
const data = await response.json();
return data.products;
// Missing: no call to sendSearchEvent() — Intelligent Search cannot learn
}operatorfuzzynulloperatorfuzzyoperator: "or"fuzzy: "0"operatorfuzzy// Properly manages operator/fuzzy across pagination
interface SearchState {
query: string;
page: number;
count: number;
locale: string;
facets?: string;
operator: string | null; // null for first page, then persisted
fuzzy: string | null; // null for first page, then persisted
}
async function searchProducts(state: SearchState): Promise<SearchResponse> {
const { query, page, count, locale, facets = "", operator, fuzzy } = state;
const params = new URLSearchParams({
query,
locale,
page: String(page),
count: String(count),
});
// Only add operator/fuzzy if this is not the first page
if (operator !== null) params.set("operator", operator);
if (fuzzy !== null) params.set("fuzzy", fuzzy);
const baseUrl = `https://${ACCOUNT}.vtexcommercestable.com.br`;
const facetPath = facets ? `/${facets}` : "";
const url = `${baseUrl}/api/io/_v/api/intelligent-search/product_search${facetPath}?${params}`;
const response = await fetch(url);
const data = await response.json();
// Store operator/fuzzy from response for next page
if (page === 0) {
state.operator = data.operator;
state.fuzzy = data.fuzzy;
}
return data;
}
// Usage: first page
const state: SearchState = {
query: "running shoes",
page: 0,
count: 24,
locale: "en-US",
operator: null, // API will decide
fuzzy: null, // API will decide
};
const firstPage = await searchProducts(state);
// Usage: subsequent pages
state.page = 1;
const secondPage = await searchProducts(state); // reuses operator/fuzzy// Using fixed operator/fuzzy values — causes bad results
async function searchProducts(query: string, page: number): Promise<SearchResponse> {
const params = new URLSearchParams({
query,
locale: "en-US",
page: String(page),
count: "24",
operator: "or", // WRONG: fixed value instead of API-provided
fuzzy: "0", // WRONG: fixed value instead of API-provided
});
const response = await fetch(
`https://mystore.vtexcommercestable.com.br/api/io/_v/api/intelligent-search/product_search/?${params}`
);
return response.json();
}
// Or forgetting operator/fuzzy entirely on subsequent pages
async function searchProductsPage2(query: string): Promise<SearchResponse> {
const params = new URLSearchParams({
query,
locale: "en-US",
page: "1", // WRONG: no operator/fuzzy from first page
count: "24",
});
// ...
}// Frontend — calls Intelligent Search directly (no BFF needed)
const VTEX_SEARCH_BASE = `https://${ACCOUNT}.vtexcommercestable.com.br/api/io/_v/api/intelligent-search`;
export async function getAutocomplete(term: string, locale: string): Promise<AutocompleteResponse> {
const params = new URLSearchParams({ query: term, locale });
const response = await fetch(`${VTEX_SEARCH_BASE}/autocomplete_suggestions?${params}`);
return response.json();
}
export async function getTopSearches(locale: string): Promise<TopSearchesResponse> {
const params = new URLSearchParams({ locale });
const response = await fetch(`${VTEX_SEARCH_BASE}/top_searches?${params}`);
return response.json();
}
export async function getFacets(facetPath: string, query: string, locale: string): Promise<FacetsResponse> {
const params = new URLSearchParams({ query, locale });
const response = await fetch(`${VTEX_SEARCH_BASE}/facets/${facetPath}?${params}`);
return response.json();
}// BFF proxy for Intelligent Search — unnecessary overhead
// server/routes/search.ts
router.get("/api/bff/search", async (req, res) => {
const { query, from, to, locale } = req.query;
// This just forwards to VTEX with no added value
const response = await fetch(
`https://mystore.vtexcommercestable.com.br/api/io/_v/api/intelligent-search/product_search/?query=${query}&from=${from}&to=${to}&locale=${locale}`
);
const data = await response.json();
res.json(data); // Added latency for no benefit
});Frontend (Browser)
│
├── GET /api/io/_v/api/intelligent-search/product_search/...
│ └── Returns: products, pagination info, operator, fuzzy
│
├── GET /api/io/_v/api/intelligent-search/facets/...
│ └── Returns: available filters with counts
│
├── GET /api/io/_v/api/intelligent-search/autocomplete_suggestions?query=...
│ └── Returns: suggested terms + suggested products
│
└── POST https://sp.vtex.com/event-api/v1/{account}/event
└── Sends: search impressions, clicks, add-to-cart events// lib/intelligent-search-client.ts
const ACCOUNT = "mystore";
const ENVIRONMENT = "vtexcommercestable";
const BASE_URL = `https://${ACCOUNT}.${ENVIRONMENT}.com.br/api/io/_v/api/intelligent-search`;
interface ProductSearchParams {
query?: string;
page: number;
count?: number;
locale: string;
facets?: string;
sort?: "price:asc" | "price:desc" | "orders:desc" | "name:asc" | "name:desc" | "release:desc" | "discount:desc";
hideUnavailableItems?: boolean;
operator?: string | null;
fuzzy?: string | null;
// Optional parameters
simulationBehavior?: "default" | "skip" | "only1P";
}
interface ProductSearchResponse {
products: Product[];
recordsFiltered: number;
// Pagination info
pagination: {
count: number;
current: { index: number };
before: Array<{ index: number }>;
after: Array<{ index: number }>;
perPage: number;
next: { index: number } | null;
previous: { index: number } | null;
first: { index: number };
last: { index: number };
};
// Search metadata to persist for next pages
operator: string;
fuzzy: string;
// Spelling correction info
correction?: {
misspelled: boolean;
text: string;
correction: string;
};
// Search behavior
translated: boolean;
locale: string;
query: string;
}
export async function productSearch(params: ProductSearchParams): Promise<ProductSearchResponse> {
const { facets = "", operator, fuzzy, ...queryParams } = params;
const searchParams = new URLSearchParams();
if (queryParams.query) searchParams.set("query", queryParams.query);
searchParams.set("page", String(queryParams.page));
searchParams.set("locale", queryParams.locale);
if (queryParams.count) searchParams.set("count", String(queryParams.count));
if (queryParams.sort) searchParams.set("sort", queryParams.sort);
if (queryParams.hideUnavailableItems) searchParams.set("hideUnavailableItems", "true");
if (queryParams.simulationBehavior) searchParams.set("simulationBehavior", queryParams.simulationBehavior);
// Only add operator/fuzzy if not null (i.e., not first page)
if (operator !== null && operator !== undefined) searchParams.set("operator", operator);
if (fuzzy !== null && fuzzy !== undefined) searchParams.set("fuzzy", fuzzy);
const facetPath = facets ? `/${facets}` : "";
const url = `${BASE_URL}/product_search${facetPath}?${searchParams}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
return response.json();
}operatorfuzzypaginationcorrection.misspelledrecordsFilteredproducts// lib/facets.ts
export async function getFacets(
facetPath: string,
query: string,
locale: string
): Promise<FacetsResponse> {
const params = new URLSearchParams({ query, locale });
const url = `${BASE_URL}/facets/${facetPath}?${params}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Facets fetch failed: ${response.status}`);
}
return response.json();
}
// Build facet path from selected filters
export function buildFacetPath(selectedFilters: Record<string, string[]>): string {
const parts: string[] = [];
for (const [key, values] of Object.entries(selectedFilters)) {
for (const value of values) {
parts.push(`${key}/${value}`);
}
}
return parts.join("/");
}// lib/autocomplete.ts
export function createDebouncedAutocomplete(delayMs: number = 300) {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function debouncedAutocomplete(
query: string,
locale: string,
callback: (suggestions: AutocompleteResponse) => void
): void {
if (timeoutId) clearTimeout(timeoutId);
if (query.length < 2) {
callback({ searches: [] });
return;
}
timeoutId = setTimeout(async () => {
const params = new URLSearchParams({ query, locale });
const response = await fetch(`${BASE_URL}/autocomplete_suggestions?${params}`);
const suggestions = await response.json();
callback(suggestions);
}, delayMs);
};
}// search-page.ts — framework-agnostic search orchestration
import { productSearch } from "./lib/intelligent-search-client";
import { getFacets, buildFacetPath } from "./lib/facets";
import { createDebouncedAutocomplete } from "./lib/autocomplete";
import { sendSearchEvent } from "./search-analytics";
interface SearchState {
query: string;
page: number;
count: number;
locale: string;
selectedFilters: Record<string, string[]>;
operator: string | null;
fuzzy: string | null;
results: ProductSearchResponse | null;
facets: FacetsResponse | null;
}
const state: SearchState = {
query: "",
page: 0,
count: 24,
locale: "en-US",
selectedFilters: {},
operator: null, // API decides on first page
fuzzy: null, // API decides on first page
results: null,
facets: null,
};
async function executeSearch(): Promise<void> {
const facetPath = buildFacetPath(state.selectedFilters);
const [searchResults, facetResults] = await Promise.all([
productSearch({
query: state.query,
page: state.page,
count: state.count,
locale: state.locale,
facets: facetPath,
operator: state.operator,
fuzzy: state.fuzzy,
}),
getFacets(facetPath, state.query, state.locale),
]);
// Store operator/fuzzy from first page for subsequent pages
if (state.page === 0) {
state.operator = searchResults.operator;
state.fuzzy = searchResults.fuzzy;
}
state.results = searchResults;
state.facets = facetResults;
// Send analytics event after results are rendered
sendSearchEvent({
type: "search.query",
text: state.query,
misspelled: searchResults.correction?.misspelled ?? false,
match: searchResults.recordsFiltered,
operator: searchResults.operator,
locale: state.locale,
agent: "my-headless-store",
url: window.location.href,
products: searchResults.products.map((p, i) => ({
productId: p.productId,
position: state.page * searchResults.products.length + i + 1,
})),
});
}
// Handle product click from search results — send click event
function onProductClick(productId: string, position: number): void {
sendSearchEvent({
type: "search.click",
text: state.query,
misspelled: false,
match: state.results?.recordsFiltered ?? 0,
operator: state.results?.operator ?? "and",
locale: state.locale,
agent: "my-headless-store",
url: window.location.href,
products: [{ productId, position }],
});
}
// Handle page change
function goToPage(newPage: number): void {
state.page = newPage;
executeSearch(); // reuses operator/fuzzy from first page
}
// Handle new search (reset pagination state)
function newSearch(query: string): void {
state.query = query;
state.page = 0;
state.operator = null; // reset for new search
state.fuzzy = null; // reset for new search
executeSearch();
}correction.misspelledoperatorcorrection.misspelledoperatorconst response = await productSearch({ query: "red nike shoes", page: 0, locale: "en-US" });
// Extract values from API response
const isMisspelled = response.correction?.misspelled ?? false;
const operatorUsed = response.operator; // "and" or "or"
// CRITICAL: Send these exact values to analytics
sendSearchEvent({
type: "search.query",
text: response.query,
misspelled: isMisspelled, // Must match API's correction.misspelled
operator: operatorUsed, // Must match API's operator value
match: response.recordsFiltered,
locale: state.locale,
agent: "my-headless-store",
url: window.location.href,
products: response.products.map((p, i) => ({
productId: p.productId,
position: i + 1,
})),
});localelocalelocale// Always include locale in search parameters
const params = new URLSearchParams({
query: "shoes",
locale: "en-US", // Required for correct language processing
page: "0",
count: "24",
});operator: "or"fuzzy: "0"// Store operator/fuzzy from first page, reuse on subsequent pages
let searchState = { operator: null, fuzzy: null };
const firstPage = await productSearch({
query: "shoes",
page: 0,
count: 24,
locale: "en-US"
});
searchState.operator = firstPage.operator; // Store from API response
searchState.fuzzy = firstPage.fuzzy;
// Page 2 reuses these values
const secondPage = await productSearch({
query: "shoes",
page: 1,
count: 24,
locale: "en-US",
operator: searchState.operator, // Reuse from first page
fuzzy: searchState.fuzzy,
});/product_search/product_search/facets// Fetch products and facets in parallel
const [products, filters] = await Promise.all([
productSearch({ query: "shoes", page: 0, locale: "en-US" }),
getFacets("", "shoes", "en-US"), // Separate call for filters
]);hideUnavailableItemshideUnavailableItems: trueconst response = await productSearch({
query: "shoes",
page: 0,
locale: "en-US",
hideUnavailableItems: true, // Filter out-of-stock products
});sort// Use API-level sorting — don't re-sort in the frontend
const results = await productSearch({
query: "shirt",
sort: "price:asc", // API handles sorting across entire result set
locale: "en-US",
from: 0,
to: 23,
facets: "category-1/clothing/color/red", // API handles filtering across entire catalog
});pagelocaleoperatorfuzzycorrection.misspelledoperatorsp.vtex.com/event-apisort/facets/product_searchhideUnavailableItemstrue