Shopify predictive search
26/08/2026 in #shopify #predictive-search #progressive-enhancementThe 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: BaseConfigConfig.
config, name: stringname: 'PredictiveSearchFetch' };
get PredictiveSearchFetch.url: URLThe URL to use for the request.
url() {
const const url: URLurl = super.Fetch<BaseProps>.url: URLThe URL to use for the request.
url; // built from the form: routes.search_url + ?q=…
const url: URLurl.URL.pathname: stringThe pathname property of the URL interface represents a location in a hierarchical structure.
pathname += '/suggest'; // routes.search_url -> routes.predictive_search_url
const url: URLurl.URL.searchParams: URLSearchParamsThe searchParams read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL.
searchParams.URLSearchParams.set(name: string, value: string): voidThe set() method of the URLSearchParams interface sets the value associated with a given search parameter to the given value.
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.
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) => URLThe URL interface is used to parse, construct, normalize, and encode URL.
URL class is a global reference for import { URL } from 'node:url'
https://nodejs.org/api/url.html#the-whatwg-url-api
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.
URL class is a global reference for import { URL } from 'node:url'
https://nodejs.org/api/url.html#the-whatwg-url-api
URL ? input: URLinput.URL.href: stringThe href property of the URL interface is a string containing the whole URL.
href : input: Requestinput.Request.url: stringThe url read-only property of the Request interface contains the URL of the request.
url,
'http://localhost',
);
if (const url: URLurl.URL.pathname: stringThe pathname property of the URL interface represents a location in a hierarchical structure.
pathname.String.endsWith(searchString: string, endPosition?: number): booleanReturns 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) => ResponseThe Response interface of the Fetch API represents the response to a request.
Response(results(const url: URLurl.URL.searchParams: URLSearchParamsThe searchParams read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL.
searchParams.URLSearchParams.get(name: string): string | nullThe get() method of the URLSearchParams interface returns the first value associated to the given search parameter.
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.