Shopify AJAX collection filtering
05/08/2026 in #shopify #collection-filtering #progressive-enhancementThe series
Part 3 of 7 of the series Building a Shopify storefront with @studiometa/ui.
Collection filtering is a common AJAX feature on a storefront. A customer ticks a facet or changes the sort order, and the product grid updates in place, without a full reload and without losing their scroll position. Shopify already filters and sorts collections server-side from URL parameters, so with @studiometa/ui, built on @studiometa/js-toolkit, you only enhance the form.
Tick a facet or change the sort below. The grid and the count update in place, without a full reload:
The form submits its state to the server, which does the filtering and returns the rendered sections; the browser only swaps them in. This builds directly on the Section Rendering API, so read that first if the ?sections= pattern is new to you.
Table of content
A form drives the request
Shopify already filters and sorts collections from URL parameters. Storefront filtering reflects every applied facet in the URL, and sort_by controls the order. So the browser's job is small: gather the customer's choices into those parameters and ask the server to re-render.
A <form method="get"> is exactly that. Put FetchShopifySection on the form and it intercepts the submit, builds the URL from the form data (this is what a GET form does natively), adds the sections to refresh, fetches it, and swaps them back in:
<form action="/collections/all" method="get"
data-component="FetchShopifySection"
data-option-sections="product-grid,results-count"
data-option-history>
<!-- facet inputs and a sort <select> -->
</form>
Two things carry the behaviour. data-component="FetchShopifySection" enhances the form, and the sections option lists the sections to render — the component appends them to the request as ?sections=… and unwraps the JSON response, so it swaps each section by id with no response boilerplate, exactly as in the Section Rendering article. data-option-history is safe here: FetchShopifySection strips the sections parameter from the URL it pushes, so the address bar keeps the shareable, refresh-safe facet URL and a reload never loads raw section JSON.
Submitting on change
Customers expect the grid to react as soon as they tick a box, so submit the form on change. Action wires that up declaratively: listen for change on the form and submit it.
<form
id="collection-filters"
action="/collections/all"
method="get"
data-component="FetchShopifySection Action"
data-option-sections="product-grid,results-count"
data-option-history
data-on:change="FetchShopifySection(#collection-filters) -> target.$el.requestSubmit()"
data-on:fetch-before="Transition(#loader) -> target.enter()"
data-on:fetch-after="Transition(#loader) -> target.leave()">
…
</form>
requestSubmit() fires the form's submit event, which FetchShopifySection intercepts. The same element carries the loader wiring: Transition shows a spinner between fetch-before and fetch-after. Keep a <noscript> submit button inside the form so filtering still works when JavaScript does not run. The form works fully server-side; the enhancement only removes the reload while keeping the URL shareable.
How the demo simulates it
The playground has no Shopify backend, so the demo mocks window.fetch: it reads the tag and sort_by parameters off the request URL, filters and sorts an in-memory product list, and returns the product-grid and results-count sections as JSON, wrapped in their shopify-section-* elements.
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.searchParams: URLSearchParamsThe searchParams read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL.
searchParams.URLSearchParams.has(name: string, value?: string): booleanThe has() method of the URLSearchParams interface returns a boolean value that indicates whether the specified parameter is in the search parameters.
has('sections')) {
const const tags: string[]tags = 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.getAll(name: string): string[]The getAll() method of the URLSearchParams interface returns all the values associated with a given search parameter as an array.
getAll('tag');
const const sort: string | nullsort = 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('sort_by');
const const items: anyitems = /* filter + sort the list from the query string */;
return new var Response: new (body?: BodyInit | null, init?: ResponseInit) => ResponseThe Response interface of the Fetch API represents the response to a request.
Response(
var JSON: JSONAn intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.
JSON.JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
stringify({ 'product-grid': grid(const items: anyitems), 'results-count': count(const items: anyitems) }),
{ ResponseInit.headers?: HeadersInit | undefinedheaders: { 'content-type': 'application/json' } },
);
}
return realFetch(input: RequestInfo | URLinput, init: RequestInit | undefinedinit);
};
The demo uses a simple tag parameter to keep the mock readable. Open it in the playground to edit it.
Swap the mock for your store: delete the window.fetch block. The form already sends its state as query parameters, and Shopify already filters and sorts from them, so the same form now drives the real collection.
The real wiring: Shopify storefront filtering
On a real store you do not invent facet inputs. Merchants configure filters in the admin, and your theme renders them from the collection.filters object. Each filter value carries the exact URL parameter name Shopify expects, so you output them as-is. The filter URL parameter format is filter.{p|v}.{attribute}=value (p for product scope, v for variant scope), for example filter.p.product_type=shoes or filter.v.availability=1.
<form id="collection-filters" action="{{ collection.url }}" method="get"
data-component="FetchShopifySection Action"
data-option-sections="main-collection-product-grid,collection-results-count"
data-option-history
data-on:change="FetchShopifySection(#collection-filters) -> target.$el.requestSubmit()"
data-on:fetch-before="Transition(#loader) -> target.enter()"
data-on:fetch-after="Transition(#loader) -> target.leave()">
{%- comment -%} Keep the current sort selected when a filter changes {%- endcomment -%}
<select name="sort_by">
{% for option in collection.sort_options %}
<option value="{{ option.value }}" {% if option.value == collection.sort_by %}selected{% endif %}>
{{ option.name }}
</option>
{% endfor %}
</select>
{%- comment -%} Render the merchant-configured filters {%- endcomment -%}
{% for filter in collection.filters %}
<fieldset>
<legend>{{ filter.label }}</legend>
{% for value in filter.values %}
<label>
<input type="checkbox" name="{{ value.param_name }}" value="{{ value.value }}"
{% if value.active %}checked{% endif %}>
{{ value.label }} ({{ value.count }})
</label>
{% endfor %}
</fieldset>
{% endfor %}
<noscript><button type="submit">Apply</button></noscript>
</form>
value.param_name and value.value come straight from Shopify and already encode the filter.* structure, so you never hand-write parameter names. Wrap the product grid and the results count in their own sections (main-collection-product-grid, collection-results-count in Dawn) so they can be requested with ?sections= and swapped independently.
One thing to keep: the form state survives a refresh either way. The <noscript> path navigates to a clean filter.*/sort_by URL, and because the checkboxes are rendered checked from value.active and the sort <option> from collection.sort_by, Shopify rebuilds the exact same state server-side on a reload. The enhanced path pushes that same clean URL through data-option-history — FetchShopifySection keeps the ?sections= parameter out of it — so a shared or reloaded link lands on the filtered page, never on raw section JSON.
Where this goes next
Changing a facet now submits a GET form and swaps the grid and the count in place, keeps the filter URL shareable through data-option-history, and falls back to a plain GET navigation without JavaScript. For the mechanics, see the Section Rendering API example.
Next article
The variant selector reuses the same shape: a control whose state drives a server-rendered section.