Titouan Mathis

CTO at Studio Meta and ikko

Shopify predictive search

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

<form id="predictive-search-form" action="{{ routes.search_url }}" method="get"
  data-component="PredictiveSearchFetch Action"
  data-on:input.debounce300="PredictiveSearchFetch(#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. The enhancement, though, wants the lighter suggestions endpoint at /search/suggest ({{ routes.predictive_search_url }}). Since Fetch fetches whatever the form's action is, a small subclass repoints the enhanced request and adds the section_id, so that parameter never leaks into the no-JS 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';
class class PredictiveSearchFetchPredictiveSearchFetch extends class Fetch<T extends BaseProps = BaseProps>

Fetch class.

Fetch
{
static
PredictiveSearchFetch.config: {
    name: string;
    debug?: boolean;
    log?: boolean;
    refs?: string[];
    emits?: string[];
    components?: BaseConfigComponents;
    options?: OptionsSchema;
}

Config.

config
= { ...class Fetch<T extends BaseProps = BaseProps>

Fetch class.

Fetch
.Fetch<T extends BaseProps = BaseProps>.config: BaseConfig

Config.

config
, name: stringname: 'PredictiveSearchFetch' };
get PredictiveSearchFetch.url: URL

The URL to use for the request.

url
() {
const const url: URLurl = super.Fetch<BaseProps>.url: URL

The URL to use for the request.

url
; // built from the form: routes.search_url + ?q=…
const url: URLurl.URL.pathname: string

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

MDN Reference

pathname
+= '/suggest'; // routes.search_url -> routes.predictive_search_url
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.set(name: string, value: string): void

The set() method of the URLSearchParams interface sets the value associated with a given search parameter to the given value.

MDN Reference

set
('section_id', 'predictive-search');
return const url: URLurl; } } import registerComponentsregisterComponents(class Action<T extends BaseProps = BaseProps>

Action class.

Action
, class PredictiveSearchFetchPredictiveSearchFetch);

data-on:input.debounce300 waits 300ms after the last keystroke before submitting, the same interval Dawn uses. requestSubmit() fires the form's submit; PredictiveSearchFetch builds the URL from the form fields (q, resources[type]), reroutes it to /search/suggest, 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, window.Shopify.routes.root in JavaScript), 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 (js-toolkit drops the `on` prefix); 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.

Where this goes next

The field above debounces input and swaps in a keyboard-navigable suggestions dropdown, from one attribute plus a small Indexable subclass. Like collection filtering, it is a control whose state drives a server-rendered section through Fetch.

Next article

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