Getting started

JavaScript SDK

Search, autocomplete and event tracking from a storefront with one small client that runs in the browser, on your server and in edge functions.

What it does

@zaprooecommerce/search-js-sdk wraps the three routes a storefront calls: search, suggest and events. You create a client once with your keys, then call search, suggest and track. It has no dependencies and works anywhere fetch does: browsers, Node 18 and later, and edge runtimes. React, Vue, Nuxt, Next.js and plain JavaScript all use it the same way.

Answers are plain JSON. A search made while your server renders the page can be handed to the browser as is, and its searchId goes with it.

Install

npm install @zaprooecommerce/search-js-sdk

The package also installs with pnpm, yarn or bun.

Create the client

You need the public API key, the search profile's key and, for autocomplete, the suggest key. Integrate a storefront shows where each one is in the cabinet.

import { createZaprooSearch } from '@zaprooecommerce/search-js-sdk';

export const zaprooSearch = createZaprooSearch({
  apiKey: 'zs_pk_...', // public key, allowed on https://shop.example.com
  profileKey: 'Ab3dE9xQk2LmP0sT', // the search profile
  suggestKey: 'Zy8xWv7uTs6rQp5o', // the autocomplete config
});
apiKey
string required
The API key. In the browser, a public key (zs_pk_...) whose Allowed origins list your shop's domains. On your server, a secret key with read access also works. Never put a secret key in browser code.
profileKey
string required
The search profile to search with. Events go to this profile too.
suggestKey
string
The suggest key from the index's Autocomplete page. Only suggest needs it.
baseUrl
string
The API address. Defaults to https://api.search.zaproo.cloud. Set it to switch between staging and live, see Staging and live.
sessionId
string | null
The visitor's session id. See Sessions.
origin
string
Server only. The shop's address, sent as the Origin header so a public key works from your server. The browser sets this header itself and ignores the option.
headers
object
Server only. Extra request headers, such as the shopper's User-Agent. The browser ignores them, because the API accepts only Authorization and Content-Type from a web page.
fetch
function
Your own fetch, for runtimes without a global one or for tests.

Create one client and share it. A client holds no results and no state besides the session id, so the same one can serve every page.

search takes the same body as the search route: the query, the filters, the sort and the page. Everything else comes from the profile.

const results = await zaprooSearch.search({
  q: 'packing tape',
  filters: { color: ['Brown', 'Clear'], price: { lte: 5 } },
  sort: 'price:asc',
  page: 1,
  perPage: 24,
});

results.total; // 42
results.items; // [{ id: 'tape-48', title: 'Brown packaging tape 48mm', price: 1.86, ... }]
results.facets; // [{ type: 'terms', field: 'color', label: 'Color', items: [...] }]
results.searchId; // '8f2Kq9xWv1pLmN3cT6yZ0a', keep it for event tracking

An empty q returns every product that passes the filters, which is how a category page works:

const category = await zaprooSearch.search({ filters: { categories: ['Tapes>Duct tapes'] } });

To search with another profile for one call, pass its key as the second argument:

await zaprooSearch.search({ q: 'tape' }, { profileKey: 'Pq7rS2tUv4WxY6zA' });

Integrate a storefront explains how to draw the results, the pager and the did-you-mean line. Facets explains how each facet type turns into a filter.

Autocomplete

suggest sends what the shopper has typed so far, up to 100 characters. An empty string returns the most searched queries, if autocomplete is set to show them.

const { suggestions, searchProfileKey } = await zaprooSearch.suggest('ta');
// suggestions: [{ text: 'duct tape', kind: 'query', facetHint: { key: 'categories', value: 'Tapes>Duct tapes' } }, ...]

When the shopper types fast, cancel the request that is still running. Every call takes an AbortSignal:

let controller: AbortController | undefined;

async function onInput(text: string) {
  controller?.abort();
  controller = new AbortController();
  try {
    const { suggestions } = await zaprooSearch.suggest(text, { signal: controller.signal });
    showSuggestions(suggestions);
  } catch (error) {
    if ((error as Error).name !== 'AbortError') throw error;
  }
}

When the shopper picks a suggestion, search for its text with the profile the suggest answer named. If the suggestion has a facetHint, add it as a filter:

import type { Suggestion } from '@zaprooecommerce/search-js-sdk';

async function onPick(pick: Suggestion, searchProfileKey: string | null) {
  const filters = pick.facetHint ? { [pick.facetHint.key]: [pick.facetHint.value] } : {};
  zaprooSearch.track({ type: 'suggestion_click', suggestion: pick.text });
  return zaprooSearch.search({ q: pick.text, filters }, { profileKey: searchProfileKey ?? undefined });
}

Track what shoppers do

Events tell the reports which results got clicked, put in the cart and bought. Send each event with the searchId of the search that showed the product. position is the result's place on the page, starting at 1.

const { searchId, items } = await zaprooSearch.search({ q: 'packing tape' });

// The shopper opens the second result.
await zaprooSearch.track({ type: 'click', searchId: searchId!, itemId: items[1].id, position: 2 });

// They put it in the cart. price is one unit.
await zaprooSearch.track({ type: 'add_to_cart', searchId: searchId!, itemId: 'tape-48', quantity: 3, price: 1.86, currency: 'EUR' });

// They place the order. Send every order, also those that did not come from search.
await zaprooSearch.track({
  type: 'purchase',
  orderId: '100042',
  currency: 'EUR',
  items: [
    { itemId: 'tape-48', quantity: 3, price: 1.86, searchId: searchId! },
    { itemId: 'box-300', quantity: 2, price: 0.32 },
  ],
});

track takes one event or an array of them and resolves to { accepted }, the number of events the API took. A long array goes out in batches of 100. The request is sent with keepalive, so a click that opens another page still arrives.

searchId is null for a search that is not logged, such as one with analytics: false. The ! in the examples assumes a logged search; check for null if you turn analytics off.

The cart and the checkout usually come long after the search, when its id is gone. Send the events anyway, with the same session id. The API ties an event without a searchId to the latest search of its session in the last 24 hours. Event tracking explains how events are matched to searches.

Sessions

The session id groups one visitor's searches and events. It keeps paging stable and lets the cart and the order find the search that led to them.

sessionIdIn the browserOn the server
left outA random id made when the client is created, kept in memory. A full page reload starts a new one.None.
a stringSent with every search and event.Sent with every search and event.
nullNone.None.

The SDK never writes cookies or browser storage. A visitor id that survives page loads is an analytics identifier, and in many countries storing one needs the visitor's consent. Your shop decides when to store it: set a first-party cookie after the visitor agrees, and pass its value to the client in the browser and on the server. The Nuxt example below does this.

A sessionId you put in a search body or an event yourself is never replaced.

Server rendering

Searching while the server renders the page puts the results in the HTML, which helps search engines and the first paint. Two things differ from the browser:

  • A server sends no Origin header. Use a secret key with read access, limited to the shop's index (see API keys). If you prefer the public key, set origin to your shop's address instead.
  • The API sees your server, not the shopper. Pass the shopper's User-Agent in headers so reports count the right device. Rate limits per client IP count your server as one client, see Rate limits.

Keep the keys in runtime config. NUXT_PUBLIC_ZAPROO_SEARCH_BASE_URL then switches the environment without a rebuild.

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    zaprooSearchServerKey: '', // NUXT_ZAPROO_SEARCH_SERVER_KEY, a secret key with read access
    public: {
      zaprooSearchApiKey: '', // the public key
      zaprooSearchProfileKey: '',
      zaprooSearchSuggestKey: '',
      zaprooSearchBaseUrl: 'https://api.search.zaproo.cloud',
    },
  },
});
// composables/useZaprooSearch.ts
import { createZaprooSearch } from '@zaprooecommerce/search-js-sdk';

export function useZaprooSearch() {
  const config = useRuntimeConfig();
  const sid = useCookie<string | null>('zs_sid');
  return createZaprooSearch({
    apiKey: import.meta.server ? config.zaprooSearchServerKey : config.public.zaprooSearchApiKey,
    profileKey: config.public.zaprooSearchProfileKey,
    suggestKey: config.public.zaprooSearchSuggestKey,
    baseUrl: config.public.zaprooSearchBaseUrl,
    sessionId: sid.value ?? null,
    headers: import.meta.server ? useRequestHeaders(['user-agent']) : undefined,
  });
}

// Call the returned function from your consent banner once the visitor agrees.
export function useZaprooSearchConsent() {
  const sid = useCookie<string | null>('zs_sid', { maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' });
  return () => {
    sid.value ??= crypto.randomUUID();
  };
}
<!-- pages/search.vue -->
<script setup lang="ts">
const route = useRoute();
const zaprooSearch = useZaprooSearch();
const { data } = await useAsyncData('search', () => zaprooSearch.search({ q: String(route.query.q ?? '') }), {
  watch: [() => route.query.q],
});

function open(itemId: string, position: number) {
  if (data.value?.searchId) zaprooSearch.track({ type: 'click', searchId: data.value.searchId, itemId, position });
  return navigateTo(`/product/${itemId}`);
}
</script>

The server renders the first results. In the browser, data already holds them, searchId included, so the click is tracked against the search the shopper saw.

Staging and live

baseUrl decides which Zaproo Search instance the client talks to. Read it from an environment variable, so the same code runs against staging in your test shop and against live in production:

const zaprooSearch = createZaprooSearch({
  apiKey: process.env.ZAPROO_SEARCH_API_KEY!,
  profileKey: process.env.ZAPROO_SEARCH_PROFILE_KEY!,
  baseUrl: process.env.ZAPROO_SEARCH_BASE_URL, // left empty, the client uses https://api.search.zaproo.cloud
});

Keys, profiles and suggest keys belong to one instance. When you switch baseUrl, switch the keys with it.

Errors

A request the API refuses rejects with a ZaprooSearchError. It carries the HTTP status, the API's error code, the message and the details:

import { ZaprooSearchError } from '@zaprooecommerce/search-js-sdk';

try {
  await zaprooSearch.search({ q: 'tape', sort: 'weight:asc' });
} catch (error) {
  if (error instanceof ZaprooSearchError && error.code === 'SORT_NOT_ALLOWED') {
    // fall back to the profile's default sort
  } else {
    throw error;
  }
}
codeWhen
VALIDATION_FAILEDA field in the body is wrong. details names it.
ORIGIN_NOT_ALLOWEDA public key from a domain that is not on its Allowed origins, or from a server without origin.
SORT_NOT_ALLOWED, FILTER_NOT_ALLOWEDA sort or filter the profile does not offer.
RATE_LIMITEDToo many requests, see Rate limits.
HTTP_502 and the likeThe answer was not an API error, for example a proxy's error page.
NO_SUGGEST_KEYsuggest was called without a suggest key. Nothing was sent.

A request you cancel with an AbortSignal rejects with the runtime's AbortError, not a ZaprooSearchError. A network failure rejects with the runtime's own error as well.

Types

The package ships TypeScript types for every request and answer: SearchParams, SearchResponse, SearchHit, FacetResult, Filters, SuggestResponse, Suggestion and AnalyticsEvent. FacetResult and AnalyticsEvent are unions on type, so a switch on it narrows the fields:

import type { FacetResult } from '@zaprooecommerce/search-js-sdk';

function facetValues(facet: FacetResult) {
  switch (facet.type) {
    case 'terms':
    case 'range':
    case 'hierarchy':
      return facet.items.map((item) => `${item.key} (${item.count})`);
    case 'stats':
      return [`${facet.min} to ${facet.max}`];
  }
}