Titouan Mathis

CTO at Studio Meta and ikko

Shopify AJAX collection filtering

05/08/2026 in #shopify #collection-filtering #progressive-enhancement

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

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.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.has(name: string, value?: string): boolean

The has() method of the URLSearchParams interface returns a boolean value that indicates whether the specified parameter is in the search parameters.

MDN Reference

has
('sections')) {
const const tags: string[]tags = 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.getAll(name: string): string[]

The getAll() method of the URLSearchParams interface returns all the values associated with a given search parameter as an array.

MDN Reference

getAll
('tag');
const const sort: string | nullsort = 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
('sort_by');
const const items: anyitems = /* filter + sort the list from the query string */; 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
(
var JSON: JSON

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

@paramvalue A JavaScript value, usually an object or array, to be converted.@paramreplacer A function that transforms the results.@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.@throws{TypeError} If a circular reference or a BigInt value is found.
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-historyFetchShopifySection 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.