JavaScript SDK
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
});
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.suggest needs it.https://api.search.zaproo.cloud. Set it to switch between staging and live, see Staging and live.Origin header so a public key works from your server. The browser sets this header itself and ignores the option.User-Agent. The browser ignores them, because the API accepts only Authorization and Content-Type from a web page.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
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.
sessionId | In the browser | On the server |
|---|---|---|
| left out | A random id made when the client is created, kept in memory. A full page reload starts a new one. | None. |
| a string | Sent with every search and event. | Sent with every search and event. |
null | None. | 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
Originheader. Use a secret key with read access, limited to the shop's index (see API keys). If you prefer the public key, setoriginto your shop's address instead. - The API sees your server, not the shopper. Pass the shopper's
User-Agentinheadersso 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.
Create the client per request in a server component, with the shopper's cookie and user agent:
// app/search/page.tsx
import { createZaprooSearch } from '@zaprooecommerce/search-js-sdk';
import { cookies, headers } from 'next/headers';
import { Results } from './results';
export default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
const zaprooSearch = createZaprooSearch({
apiKey: process.env.ZAPROO_SEARCH_SERVER_KEY!, // a secret key with read access
profileKey: process.env.NEXT_PUBLIC_ZAPROO_SEARCH_PROFILE_KEY!,
baseUrl: process.env.NEXT_PUBLIC_ZAPROO_SEARCH_BASE_URL,
sessionId: (await cookies()).get('zs_sid')?.value ?? null,
headers: { 'User-Agent': (await headers()).get('user-agent') ?? '' },
});
const results = await zaprooSearch.search({ q: (await searchParams).q ?? '' });
return <Results results={results} />;
}
Track clicks from a client component with a browser client:
// app/search/results.tsx
'use client';
import { createZaprooSearch, type SearchResponse } from '@zaprooecommerce/search-js-sdk';
const zaprooSearch = createZaprooSearch({
apiKey: process.env.NEXT_PUBLIC_ZAPROO_SEARCH_API_KEY!,
profileKey: process.env.NEXT_PUBLIC_ZAPROO_SEARCH_PROFILE_KEY!,
baseUrl: process.env.NEXT_PUBLIC_ZAPROO_SEARCH_BASE_URL,
// client components also render on the server, where there is no document
sessionId: typeof document === 'undefined' ? null : (document.cookie.match(/(?:^|; )zs_sid=([^;]+)/)?.[1] ?? null),
});
export function Results({ results }: { results: SearchResponse }) {
return (
<ul>
{results.items.map((hit, i) => (
<li key={hit.id}>
<a
href={`/product/${hit.id}`}
onClick={() => results.searchId && zaprooSearch.track({ type: 'click', searchId: results.searchId, itemId: hit.id, position: i + 1 })}
>
{String(hit.title)}
</a>
</li>
))}
</ul>
);
}
A search box in a single-page React app, with suggestions that cancel the previous request:
import { createZaprooSearch, type Suggestion } from '@zaprooecommerce/search-js-sdk';
import { useEffect, useState } from 'react';
const zaprooSearch = createZaprooSearch({
apiKey: import.meta.env.VITE_ZAPROO_SEARCH_API_KEY,
profileKey: import.meta.env.VITE_ZAPROO_SEARCH_PROFILE_KEY,
suggestKey: import.meta.env.VITE_ZAPROO_SEARCH_SUGGEST_KEY,
baseUrl: import.meta.env.VITE_ZAPROO_SEARCH_BASE_URL,
});
export function SearchBox() {
const [q, setQ] = useState('');
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
useEffect(() => {
const controller = new AbortController();
const timer = setTimeout(() => {
zaprooSearch.suggest(q, { signal: controller.signal }).then((r) => setSuggestions(r.suggestions), () => {});
}, 150);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [q]);
return (
<>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search" />
<ul>
{suggestions.map((s) => <li key={s.text}>{s.text}</li>)}
</ul>
</>
);
}
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;
}
}
code | When |
|---|---|
VALIDATION_FAILED | A field in the body is wrong. details names it. |
ORIGIN_NOT_ALLOWED | A public key from a domain that is not on its Allowed origins, or from a server without origin. |
SORT_NOT_ALLOWED, FILTER_NOT_ALLOWED | A sort or filter the profile does not offer. |
RATE_LIMITED | Too many requests, see Rate limits. |
HTTP_502 and the like | The answer was not an API error, for example a proxy's error page. |
NO_SUGGEST_KEY | suggest 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}`];
}
}