---
date: 2026-08-01
title: Shopify predictive search
description: Build Shopify predictive search with @studiometa/ui. A debounced suggestions dropdown over the Predictive Search API with progressive enhancement.
tags: shopify, predictive-search, ajax-search, progressive-enhancement, studiometa-ui
---

# Shopify predictive search

01/08/2026 in #shopify #predictive-search #progressive-enhancement

> **The series**
>
> **Part 6 of 7** of the series [Building a Shopify storefront with @studiometa/ui](/articles/building-a-shopify-storefront-with-studiometa-ui).
>

Predictive search shows product suggestions as the customer types, in a dropdown under the search field. It needs debouncing so you are not firing a request per keystroke, and it needs to stay accessible from the keyboard. Both stay small with [`@studiometa/ui`](https://ui.studiometa.dev) and [`@studiometa/js-toolkit`](https://js-toolkit.studiometa.dev), because the search results are another server-rendered section.

Type in the field below. Suggestions are fetched, debounced, and swapped in:

```twig
<!-- demo.twig -->
<div class="max-w-md space-y-3">
  <form
    id="predictive-search-form"
    action="/search"
    method="get"
    role="search"
    data-component="Fetch Action"
    data-option-src="/search/suggest?section_id=predictive-search"
    data-on:input.debounce300="Fetch(#predictive-search-form) -> target.$el.requestSubmit()"
    data-on:fetch-before="Transition(#search-loader) -> target.enter()"
    data-on:fetch-after="Transition(#search-loader) -> target.leave()">
    <!-- action is the no-JS search page; data-option-src repoints the enhanced
         request to the suggestions endpoint, keeping section_id out of the no-JS URL. -->
    <input type="hidden" name="resources[type]" value="product" />

    <div class="flex items-center gap-2">
      <input
        type="search"
        name="q"
        placeholder="Search products…"
        autocomplete="off"
        role="combobox"
        aria-controls="shopify-section-predictive-search"
        aria-autocomplete="list"
        class="w-full border rounded px-3 py-2 bg-transparent" />
      <span
        id="search-loader"
        data-component="Transition"
        data-option-enter-from="opacity-0"
        data-option-leave-to="opacity-0"
        data-option-leave-keep
        class="text-sm text-current/60 opacity-0">
        …
      </span>
    </div>
  </form>

  <!-- Fetch swaps this section by id when the search results come back. -->
  <div id="shopify-section-predictive-search">
    <p class="text-sm text-current/60">Start typing to see suggestions (try “ca” or “tote”).</p>
  </div>
</div>
```

```ts
// demo.ts
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, Fetch, Transition } from '@studiometa/ui';

// --- Simulated Predictive Search API ----------------------------------------
// Shopify's GET /search/suggest?q=…&section_id=predictive-search returns the
// rendered section HTML. We return the same shape (a #shopify-section-predictive-search
// wrapper Fetch can swap by id). Delete this block to hit the real endpoint.
const products = [
  { title: 'Canvas Cap', handle: 'canvas-cap' },
  { title: 'Wool Beanie', handle: 'wool-beanie' },
  { title: 'Leather Tote', handle: 'leather-tote' },
  { title: 'Ceramic Mug', handle: 'ceramic-mug' },
  { title: 'Cotton Tee', handle: 'cotton-tee' },
];

function section(inner: string) {
  return `<div id="shopify-section-predictive-search">${inner}</div>`;
}

function results(query: string) {
  const q = query.trim().toLowerCase();
  if (!q) {
    return section('<p class="text-sm text-current/60">Start typing to see suggestions.</p>');
  }
  const matches = products.filter((p) => p.title.toLowerCase().includes(q));
  if (!matches.length) {
    return section(`<p class="text-sm text-current/60">No results for “${query}”.</p>`);
  }
  const items = matches
    .map(
      (p) =>
        `<li role="option"><a href="/products/${p.handle}" class="block px-3 py-2 hover:bg-current/10">${p.title}</a></li>`,
    )
    .join('');
  return section(`<ul role="listbox" class="border rounded divide-y divide-current/10">${items}</ul>`);
}

const realFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
  const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url, 'http://localhost');
  if (url.pathname.endsWith('/search/suggest')) {
    await new Promise((resolve) => setTimeout(resolve, 250)); // fake latency
    return new Response(results(url.searchParams.get('q') ?? ''), {
      headers: { 'content-type': 'text/html' },
    });
  }
  return realFetch(input, init);
};
// ---------------------------------------------------------------------------

registerComponents(Action, Fetch, Transition);
```

Without JavaScript the form performs a full search; the dropdown is the enhancement layered on top. [Open it in the playground](https://ui.studiometa.dev/play/#html=eNqNVMGO0zAQve9XDFkOXQk31a7EAdoiLhwQJwonhJATTxprE9trO21z2w%2BBKx%2FGlzB2vE1aFu3mksQz82bmzRsvhdxB2XDnVlnLD2zPWgHO8BJZz26y9QXAstK2pTeAFKvMWBSy9HKHzCG3Zc2COYt2TudarbJ8sAyHLfpaU%2BAW%2FXBgdYOrbOoiuOes1K3RCpVfZR%2FQlzW8j2gTD23CAXO2PKbIXbfdovPvHEbvH1TiPxVOIdQbqUzn5wIL3akSbxaLlG92%2BXhrV8DW4Lml%2BucvsZlbvOso46YrWulnV6fgVUBiBVIg9fjFcuVkqGt2mRAbzQXaKSZ1jPZxHF6R6ZkwDfIdEsw64ixfMJbGAdKBrxGUZh83MMSD4Vt8e84qWDRaKj%2F4o6o5ESQiXnxS5%2BB1dEjUU7AjZxFDX8EtopFqC%2BNAQHcedDUp4uvnT3NgLJUaBwK%2BN8RYLYVAlYHiLf1ZdLqzJbpvwfo9gx1vOgwa1KIrfQb5%2BmLAmKi4avAA0mPrWBm5hS037DoR85Dv2NWQdyqU8Az578YD09BK1LoRYSCbROJQhvtz%2F3t05J3XQcoNekLQVTWaBuGTsdCFPkxCrAzyV54cqAFXayOrniUG2X%2F0fIw8zdhI50eHxMmeVV3TQKEt1U91dEqgAHNgN2B6dg3FlvkgMsMtURZ5TWTRkTqihf0%2FEeCY6HyFR82e%2BSS5xcmwyuqWSKLrRvqeLR53jcpmXj%2FTMQjwvH%2BPB89cC%2FFddjZ0mb9ewAi4PobQNB%2Baz0P3SaU5SSzehnm4FKLu4pINd5XbcxPWhnYtjQ2KnviCfY1q2JZBMyTprqENI6oQCl7ePixClHAk%2BMnxp4rME91l6w3dDXGzwkLS1jo83dqZtz31%2B7Pkf%2B5%2Fgbbh22uP9Hc1X%2BZm6Dc2nl5%2FAb8i%2FNI%3D&script=eNqtVs1u4zYQvvsppm5QyYAlpbtoD4nldJtu2wWyRbBxkUNRNLQ0trihSJocxTECA3mQ9toHy5N09OfESRfdQ32wpeH8fJzv49CytMYR3IHDpfSE7tSwRaMmD1tYOFNC8J2nKpemRBLJRx%2BRMepaUnA8kH3wm4yk0WP4ESkrxjBzQntZm%2F41RyU5dpAkEEURXMiyUoIwh3OHueRENwgXKFxWwJvzd7XPZ33qfBeFsXKxCTz89HYGiW%2ByJL5aLtHTySp9uP%2F7K48N1j9kntpdwah15SZQ5bQHKrDO51DnyE7QBcHPs%2FdnMVxi51j7gRclfxXCIoQCvvQthqgLiV4UqROvnbAWXdsvyIQGvxYW5huQ%2BSiGH1AhIWeXHubKZNdABgpJTT2HQgEDs0ZqigeZ0Z7AOpNXGXOWwm8DYEZIksIjCE6FvhEeToUNxlAInTfWrLFGGVthO94LuGR24XsUWuLTiDWbo3lrfh5yhoKBOZgZ2otRrZ0FQy%2BDTtGJUmbwvlruIWvNUcnmFyGGiEmY4V6VrLFGhG2N31lai0q3fHUkhFJrdEfgyUm9HMEdp%2B0YvJrk8oa7ng7%2Fk7jh9OCuSbSdJBw0vToebJ%2FUcugrRT5cVeg2%2B7VajlZMTrMY81IZjmIyZ2bNJ054DEfH7CcXEH6xamN2CPs9BBMLmRLep0PCW4p8Cc1vVjnWKSXfHg6nFyT4PNLGculaMx5Zmq38OYWPJ4mdBk2p7Q5WKViDWCunF1G8kIonQRjaEaRTsHFDwD7cWOpMVTnyfkeP2LtcsUK9pOITG7n6jI38Yvp%2BwsI4eLj%2F8%2BCu6d324f6vZhtXz7YhCct6Ex2EpnBcChs2TwDtZroXYN6VBGcUpkNja1jD6URA4XCRDpO%2BEcnBnY1boW2HPeb2QNrb6DXYTfQKCnPD2povd%2Fi%2FPqyV0nWNtSKmk0TJ6dW4KT5qkX3k0xsGLRcv%2BlOpDprikTw3t4%2B1jeOBxIsVD6YcWIUyx2jTP%2BwjaDrC9SvVNIu12naqHiDt4ElhLXVu1vGifo3n%2FBK2FvZ%2FusSewm90BnySbEVjkFpSI45HeVdOsZvGNfz64SxkDaJZQOMOaZpC0B6IAE4641H3KzlY6Kz25sB%2BOa6p6H1izj2GoCCyR0nC%2FReqMJ6CnfB4PbY8bDTP4phno7%2BUVITBsxsgGPWKFGvB07TGes63k2RBhyw3o26w2ZRHmskSTUW9eQyvvjlkoQMP74W4RqivLJ1tnuq7TvcBPd%2BenK%2BfBjWyFsW54Mnm4yVSGKyCEZycAPM%2F7hABFCiYW3%2FEA49nmub0FNVdDHjG1ecjKahU3Uxk4e%2Fk31XfsbpHEdN%2B3N20%2F9dnMHj5VyH85B8ARvAPYlm5sA%3D%3D&theme=light) to edit it.

## Fetch on input, debounced

The [Predictive Search API](https://shopify.dev/docs/api/ajax/reference/predictive-search) has two endpoints. `GET /search/suggest.json` returns JSON, and `GET /search/suggest` returns rendered section HTML when you pass a `section_id`. Use the HTML endpoint here, so [`Fetch`](https://ui.studiometa.dev/components/Fetch/) can swap the results section by `id` with no parsing.

`Fetch` fires on link clicks and form submits, not on keystrokes. So the trigger is an [`Action`](https://ui.studiometa.dev/components/Action/) listening for `input`, with a `debounce` modifier, that submits the search form. The catch: the form needs two destinations, a full search page without JavaScript and the lighter suggestions endpoint once `Fetch` takes over. The [`src`](https://ui.studiometa.dev/components/Fetch/) option is exactly that split:

```html
<form
  id="predictive-search-form"
  action="{{ routes.search_url }}"
  method="get"
  data-component="Fetch Action"
  data-option-src="{{ routes.predictive_search_url }}?section_id=predictive-search"
  data-on:input.debounce300="Fetch(#predictive-search-form) -> target.$el.requestSubmit()">
  <input type="hidden" name="resources[type]" value="product">
  <input
    type="search"
    name="q"
    autocomplete="off"
    role="combobox"
    aria-controls="shopify-section-predictive-search"
    aria-autocomplete="list">
</form>

<div id="shopify-section-predictive-search" role="listbox"><!-- results land here --></div>
```

The form's `action` is `{{ routes.search_url }}` on purpose: with JavaScript off, submitting runs an ordinary search and lands on the full results page, the honest no-JS baseline. `data-option-src` points the _enhanced_ request elsewhere, at the lighter suggestions endpoint (`{{ routes.predictive_search_url }}`, that is `/search/suggest`). When `Fetch` runs, `src` takes precedence over the form's `action`, and the GET form fields (`q`, `resources[type]`) are still merged onto it, so the request becomes `/search/suggest?section_id=…&q=…`. Because `section_id` rides on `src` rather than a hidden input, it never leaks into the no-JS `action` URL:

```js twoslash
// @noErrors
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, Fetch } from '@studiometa/ui';

registerComponents(Action, Fetch);
```

`data-on:input.debounce300` waits 300ms after the last keystroke before submitting, the same interval Dawn uses. `requestSubmit()` fires the form's submit; `Fetch` builds the request from `src` plus the form fields and swaps the `#shopify-section-predictive-search` section it gets back. So the native path goes to the search page and the enhanced path to `/search/suggest`, from the same markup.

The `debounce` modifier accepts a delay in milliseconds (`debounce300`); js-toolkit also exports a standalone [`debounce`](https://js-toolkit.studiometa.dev/utils/debounce.html) utility if you need it outside `Action`.

## How the demo simulates it

The playground has no store, so the demo mocks `GET /search/suggest`: it filters an in-memory product list by the `q` parameter and returns the results wrapped in a `#shopify-section-predictive-search` element, the same shape Shopify's section response has.

```js twoslash
// @noErrors
window.fetch = async (input, init) => {
  const url = new URL(
    typeof input === 'string' ? input : input instanceof URL ? input.href : input.url,
    'http://localhost',
  );
  if (url.pathname.endsWith('/search/suggest')) {
    return new Response(results(url.searchParams.get('q') ?? ''), {
      headers: { 'content-type': 'text/html' },
    });
  }
  return realFetch(input, init);
};
```

**Swap the mock for your store:** delete the `window.fetch` block, and render a `predictive-search` section in your theme using the [`predictive_search`](https://shopify.dev/docs/api/liquid/objects/predictive_search) Liquid object. The same form then queries the real endpoint. Use locale-aware URLs on a live theme (`{{ routes.search_url }}` for the form action and `{{ routes.predictive_search_url }}` for `data-option-src`), and keep the `name="q"` search input inside a real search form so a full search still works without JavaScript.

## Keyboard navigation with Indexable

A suggestions dropdown should be operable with the arrow keys and Enter. [`Indexable`](https://ui.studiometa.dev/components/Indexable/) manages exactly that: it tracks a `currentIndex` within a range, exposes `goNext()`, `goPrev()` and `goTo()`, and emits an `index` event when the active item changes. It manages the index; you decide what the keys do and how the active item looks. A small component built on it:

```js twoslash
// @noErrors
import { registerComponents } from '@studiometa/js-toolkit';
import { Indexable } from '@studiometa/ui';

class PredictiveSearch extends Indexable {
  static config = {
    ...Indexable.config,
    name: 'PredictiveSearch',
    refs: ['option[]'],
  };

  // Derive the range from the current results instead of a fixed total.
  get length() {
    return this.$refs.option?.length ?? 0;
  }

  // `keyed` is the key service hook; it fires on every keydown/keyup with parsed key flags.
  keyed({ event, isDown, UP, DOWN }) {
    if (!isDown || !(UP || DOWN)) return;
    event.preventDefault();
    if (DOWN) this.goNext();
    if (UP) this.goPrev();
  }

  // Fires on the `index` event; move the highlight and the ARIA active descendant.
  onIndex() {
    this.$refs.option.forEach((el, i) => {
      el.classList.toggle('is-active', i === this.currentIndex);
      el.setAttribute('aria-selected', String(i === this.currentIndex));
    });
  }
}

registerComponents(PredictiveSearch);
```

Set `data-option-boundary="loop"` so arrowing past the last suggestion returns to the first. Because the results section is re-rendered on each search, re-resolve the options after a swap by listening for the bubbling `fetch-update-after` event and refreshing the index bounds. Pair this with the ARIA (Accessible Rich Internet Applications) `combobox`/`listbox` roles from Shopify's [predictive search guide](https://shopify.dev/docs/storefronts/themes/navigation-search/search/predictive-search) so screen readers announce the active suggestion.

> **Next article**
>
> Last in the series, [recommendations, tracking and prefetching](/articles/shopify-recommendations-tracking-and-prefetching) lazy-loads a section, then adds analytics and link prefetching.
