Titouan Mathis

CTO at Studio Meta and ikko

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.

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 and @studiometa/js-toolkit, because the search results are another server-rendered section.

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

Without JavaScript the form performs a full search; the dropdown is the enhancement layered on top. Open it in the playground to edit it.

Table of content

Fetch on input, debounced

The Predictive Search API 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 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 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 option is exactly that split:

<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:

import { import registerComponentsregisterComponents } from '@studiometa/js-toolkit';
import { class Action<T extends BaseProps = BaseProps>

Action class.

Action
, class Fetch<T extends BaseProps = BaseProps>

Fetch class.

Fetch
} from '@studiometa/ui';
import registerComponentsregisterComponents(class Action<T extends BaseProps = BaseProps>

Action class.

Action
, class Fetch<T extends BaseProps = BaseProps>

Fetch class.

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 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.

module window
var window: Window & typeof globalThis

The window property of a Window object points to the window object itself.

MDN Reference

window
.function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>fetch = async (input: RequestInfo | URLinput, init: RequestInit | undefinedinit) => {
const const url: URLurl = new var URL: new (url: string | URL, base?: string | URL) => URL

The URL interface is used to parse, construct, normalize, and encode URL.

MDN Reference

URL class is a global reference for import { URL } from 'node:url' https://nodejs.org/api/url.html#the-whatwg-url-api

@sincev10.0.0
URL
(
typeof input: RequestInfo | URLinput === 'string' ? input: stringinput : input: Request | URLinput instanceof
var URL: {
    new (url: string | URL, base?: string | URL): URL;
    prototype: URL;
    canParse(url: string | URL, base?: string | URL): boolean;
    createObjectURL(obj: Blob | MediaSource): string;
    parse(url: string | URL, base?: string | URL): URL | null;
    revokeObjectURL(url: string): void;
}

The URL interface is used to parse, construct, normalize, and encode URL.

MDN Reference

URL class is a global reference for import { URL } from 'node:url' https://nodejs.org/api/url.html#the-whatwg-url-api

@sincev10.0.0
URL
? input: URLinput.URL.href: string

The href property of the URL interface is a string containing the whole URL.

MDN Reference

href
: input: Requestinput.Request.url: string

The url read-only property of the Request interface contains the URL of the request.

MDN Reference

url
,
'http://localhost', ); if (const url: URLurl.URL.pathname: string

The pathname property of the URL interface represents a location in a hierarchical structure.

MDN Reference

pathname
.String.endsWith(searchString: string, endPosition?: number): boolean

Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding elements of this object (converted to a String) starting at endPosition – length(this). Otherwise returns false.

endsWith
('/search/suggest')) {
return new var Response: new (body?: BodyInit | null, init?: ResponseInit) => Response

The Response interface of the Fetch API represents the response to a request.

MDN Reference

Response
(results(const url: URLurl.URL.searchParams: URLSearchParams

The searchParams read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL.

MDN Reference

searchParams
.URLSearchParams.get(name: string): string | null

The get() method of the URLSearchParams interface returns the first value associated to the given search parameter.

MDN Reference

get
('q') ?? ''), {
ResponseInit.headers?: HeadersInit | undefinedheaders: { 'content-type': 'text/html' }, }); } return realFetch(input: RequestInfo | URLinput, init: RequestInit | undefinedinit); };

Swap the mock for your store: delete the window.fetch block, and render a predictive-search section in your theme using the 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 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:

import { import registerComponentsregisterComponents } from '@studiometa/js-toolkit';
import { import IndexableIndexable } from '@studiometa/ui';

class class PredictiveSearchPredictiveSearch extends import IndexableIndexable {
  static PredictiveSearch.config: anyconfig = {
    ...import IndexableIndexable.config,
    name: stringname: 'PredictiveSearch',
    refs: string[]refs: ['option[]'],
  };

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

  // `keyed` is the key service hook; it fires on every keydown/keyup with parsed key flags.
  
PredictiveSearch.keyed({ event, isDown, UP, DOWN }: {
    event: any;
    isDown: any;
    UP: any;
    DOWN: any;
}): void
keyed
({ event: anyevent, isDown: anyisDown, type UP: anyUP, type DOWN: anyDOWN }) {
if (!isDown: anyisDown || !(type UP: anyUP || type DOWN: anyDOWN)) return; event: anyevent.preventDefault(); if (type DOWN: anyDOWN) this.goNext(); if (type UP: anyUP) this.goPrev(); } // Fires on the `index` event; move the highlight and the ARIA active descendant. PredictiveSearch.onIndex(): voidonIndex() { this.$refs.option.forEach((el: anyel, i: anyi) => { el: anyel.classList.toggle('is-active', i: anyi === this.currentIndex); el: anyel.setAttribute('aria-selected',
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(i: anyi === this.currentIndex));
}); } } import registerComponentsregisterComponents(class PredictiveSearchPredictiveSearch);

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 so screen readers announce the active suggestion.

Next article

Last in the series, recommendations, tracking and prefetching lazy-loads a section, then adds analytics and link prefetching.