Semantic search in Shopify with XEYE

Adding semantic search to a Shopify store takes four steps: export the products to JSON, import and train them in XEYE, deploy a small proxy that holds your API key, and add a section to the theme that queries it. The proxy is needed because the key is secret and the code of a theme is public.

By Joan Martorell

What do I need before I start?

  • An XEYE account and an API key, created on the API keys page of the console.
  • Access to edit the theme code of your store (Online Store 2.0).
  • A free Cloudflare account for the proxy, and Node.js on your computer.

The code on this page follows the official Shopify and Cloudflare documentation. The proxy is tested against the XEYE API; we have not tested the theme section on a real Shopify store, so try it first on a copy of your theme.

Step 1: how do I export the products?

XEYE imports a JSON file with a list of objects. Each product needs a text, and optionally a description and some params with whatever you want to get back in each result. This script reads the published products of your store and writes that format:

export-shopify.mjs
// node export-shopify.mjs https://your-shop.com > products.json   (Node 18+)
const shop = process.argv[2]
const out = []

for (let page = 1; ; page++) {
  const response = await fetch(`${shop}/products.json?limit=250&page=${page}`)
  const { products } = await response.json()
  if (!products.length) break
  for (const product of products) {
    out.push({
      text: product.title,
      description: (product.body_html || '')
        .replace(/<[^>]+>/g, ' ')
        .replace(/\s+/g, ' ')
        .trim(),
      params: {
        id: product.id,
        handle: product.handle,
        url: `/products/${product.handle}`,
        sku: product.variants[0]?.sku,
        price: product.variants[0]?.price,
        image: product.images[0]?.src,
      },
    })
  }
}

console.log(JSON.stringify(out, null, 2))

It uses the /products.json address of the store, which returns the published products in pages of 250. It works in practice, but Shopify does not document it and it does not respond on password-protected stores. The official alternative is to export from the admin, under Products, then Export, and convert the CSV to the same format.

Step 2: how do I load them into XEYE?

  1. In the console, create a list called products and mark it as public.
  2. Open it and use “Import from file” with the products.json from the previous step.
  3. On the Trainings tab, choose a model and launch the training.
  4. When it finishes, try a few searches in the playground. It is free.

Step 3: how do I protect the API key?

With a proxy: a small program on a server that receives the search from the browser, adds your key and calls XEYE. A Cloudflare Worker is enough. Create a project with these two files:

wrangler.jsonc
// wrangler.jsonc
{
  "name": "xeye-proxy",
  "main": "src/index.js",
  "compatibility_date": "2026-10-01",
  "vars": { "ALLOWED_ORIGIN": "https://your-shop.com" }
}
src/index.js
// src/index.js
const json = (data, status, headers) =>
  new Response(JSON.stringify(data), {
    status,
    headers: { ...headers, 'Content-Type': 'application/json' },
  })

export default {
  async fetch(request, env) {
    const cors = {
      'Access-Control-Allow-Origin': env.ALLOWED_ORIGIN,
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
      'Access-Control-Max-Age': '86400',
      Vary: 'Origin',
    }
    // A JSON POST from the browser is always preceded by a preflight.
    if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors })
    if (request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405, cors)
    if (request.headers.get('Origin') !== env.ALLOWED_ORIGIN) {
      return json({ error: 'forbidden' }, 403, cors)
    }

    let body
    try {
      body = await request.json()
    } catch {
      return json({ error: 'bad_json' }, 400, cors)
    }
    const q = typeof body?.q === 'string' ? body.q.trim() : ''
    if (q.length < 2 || q.length > 200) return json({ error: 'bad_query' }, 400, cors)
    const limit = Math.min(Math.max(parseInt(body.limit, 10) || 5, 1), 20)

    const upstream = await fetch('https://search.xeye.es/api/v1/search', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-API-Key': env.XEYE_API_KEY },
      // The list name is fixed here: the browser cannot query other lists.
      body: JSON.stringify({ list_name: 'products', search_term: q, limit }),
    })
    if (!upstream.ok) return json({ error: 'search_unavailable' }, 502, cors)
    const data = await upstream.json()
    return json({ results: data.results }, 200, cors)
  },
}

Change ALLOWED_ORIGIN to the domain of your store. Then save the key as a secret and deploy:

bash
npx wrangler secret put XEYE_API_KEY
npx wrangler deploy

The deployment gives you an address ending in workers.dev. That is the one the theme will query.

Step 4: how do I add the search box to the theme?

With a new section. In the theme code editor, create the file sections/xeye-search.liquid with this content and put the address of your Worker in ENDPOINT:

sections/xeye-search.liquid
{%- comment -%} sections/xeye-search.liquid {%- endcomment -%}
<input type="search" id="xeye-q" placeholder="Search products" autocomplete="off">
<ul id="xeye-results"></ul>

<script>
  (() => {
    // Your Worker URL, or '/apps/xeye' if you use an app proxy.
    const ENDPOINT = 'https://xeye-proxy.YOUR-ACCOUNT.workers.dev'
    const input = document.getElementById('xeye-q')
    const list = document.getElementById('xeye-results')
    let timer
    let controller

    input.addEventListener('input', () => {
      clearTimeout(timer)
      const q = input.value.trim()
      if (q.length < 2) {
        list.replaceChildren()
        return
      }
      // Wait until the user stops typing: one search, not one per keystroke.
      timer = setTimeout(async () => {
        controller?.abort()
        controller = new AbortController()
        try {
          const response = await fetch(ENDPOINT, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ q, limit: 5 }),
            signal: controller.signal,
          })
          const { results = [] } = await response.json()
          list.replaceChildren(
            ...results.map((result) => {
              const item = document.createElement('li')
              const link = document.createElement('a')
              link.href = result.params?.url || '#'
              link.textContent = result.item
              item.append(link)
              return item
            }),
          )
        } catch (error) {
          if (error.name !== 'AbortError') list.replaceChildren()
        }
      }, 300)
    })
  })()
</script>

{% schema %}
{ "name": "XEYE search", "settings": [], "presets": [{ "name": "XEYE search" }] }
{% endschema %}

The section appears in the theme editor as “XEYE search” and you can add it to any template. It waits 300 milliseconds after the last keystroke before searching, so that one search is one or two calls. Styling is up to you.

Is it safe to leave the proxy open?

The origin check in the Worker stops another website from using it from a browser, but it does not stop someone from calling it with a program. Since every search spends credit, it is worth adding a rate limiting rule in Cloudflare and creating a balance alert in XEYE.

The more robust option: an app proxy

Shopify can forward requests for https://tu-tienda/apps/xeye to your Worker and sign them. That way the search box calls your own domain and the Worker can verify that the request comes from Shopify. The steps are:

  1. Create an app in the Shopify Dev Dashboard. Custom apps created from the store admin do not support a proxy.
  2. In a version of the app, add the write_app_proxy scope and configure the proxy with prefix apps, subpath xeye and the address of your Worker.
  3. Release the version and install the app on your store.
  4. Save the client secret of the app in the Worker with npx wrangler secret put SHOPIFY_APP_SECRET.
  5. Replace the origin check in the Worker with the signature verification, and change ENDPOINT to /apps/xeye in the section.
src/index.js
// Replace the Origin check in the Worker with this signature check.
async function verifyShopifyProxy(requestUrl, secret) {
  const params = new URL(requestUrl).searchParams
  const signature = params.get('signature') || ''
  if (!/^[0-9a-f]{64}$/.test(signature)) return false

  // key=value pairs (repeated keys joined with ","), sorted and concatenated.
  const keys = [...new Set(params.keys())].filter((key) => key !== 'signature')
  const message = keys
    .map((key) => `${key}=${params.getAll(key).join(',')}`)
    .sort()
    .join('')

  const encoder = new TextEncoder()
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify'],
  )
  const bytes = Uint8Array.from(signature.match(/../g), (hex) => parseInt(hex, 16))
  return crypto.subtle.verify('HMAC', key, bytes, encoder.encode(message))
}

// In fetch():
// if (!(await verifyShopifyProxy(request.url, env.SHOPIFY_APP_SECRET))) {
//   return json({ error: 'forbidden' }, 403, {})
// }

How do I keep the catalogue up to date?

There is no automatic sync. When you add products, export them, import only the new ones and launch a training. Importing adds elements and does not replace the existing ones, so if you import the whole catalogue again you will get duplicates. Changes to existing products are edited in the console.

If your catalogue changes daily and you need automatic sync, facets or an installable app, a tool built for that will suit you better. We explain it in XEYE versus Algolia.

Frequently asked questions

Does it replace the Shopify search?

No. This tutorial adds a search box of its own. The native search and the predictive search of the theme keep working, and you can keep both or remove the native one from the theme.

Do I need to create a Shopify app?

For the quick option, no: the Worker and the theme section are enough. You only need one if you want the signed app proxy.

What do I store in `params`?

Whatever you need to render a result without another query. The script stores the identifier, the handle, the URL, the SKU, the price and the image of the product.

Can I filter by collection or by price?

XEYE does not filter. You can store the collection or the price in params and filter the results in your code, or create one list per collection.

Try it with your own data

Create an account, upload a list and run your first search in about five minutes. You start with €5 of credit, no card required.

Keep reading