Shopify Collection Filters in Liquid: Links or Form

Shopify collection filters in Liquid come out of collection.filters, an array the merchant populates in the admin before your theme sees anything. Each entry is a filter object carrying its label, its URL parameter name and its values. Render those values as checkboxes inside one GET form, then use each value’s url_to_remove for the chips that clear them.

That is the whole shape. The parts that go wrong are the sort order you lose on submit, the price inputs that need a money filter, and the counts you forget to disable.

Where do Shopify collection filters come from?

Nothing in Liquid creates a filter. A merchant creates them in the Shopify admin, and the storefront filtering docs list what they can be built from: availability, category, price, product tags, product type, vendor, variant options and metafields. A store can have 25 of them at most. If a merchant has configured none, collection.filters is empty. Guard the whole block with {%- if collection.filters != empty -%} so an unconfigured store does not get a heading and an Apply button sitting over nothing.

Each filter has a type of boolean, list or price_range, and you branch on that before anything else. Alongside it you get label for the customer-facing heading, param_name (something like filter.v.option.color), values, active_values, inactive_values, and url_to_remove for resetting the whole group.

Filters combine with AND. Values inside one filter combine with OR. Red and size L means both conditions; red or blue means either. There is an operator property that returns AND or OR on boolean and list filters, but the filter object reference limits AND support to product tags and metafields of type list.single_line_text_field and list.metaobject_reference, so “has both of these tags” is a merchant setting rather than something the theme can force.

Each value is a filter_value: label, value, count, active, param_name, url_to_add, url_to_remove, plus swatch or image for visual filters. The older display object is deprecated in favour of swatch, though it still returns a value. Worth knowing, because the sample code on shopify.dev still branches on filter_value.display.type.

Both, for different jobs.

url_to_add and url_to_remove are complete URLs, so a single-select filter can be nothing but anchors. No form, no JavaScript, works everywhere. Multi-select is where that falls apart, because each link encodes the current state plus exactly one new value, and a customer who wants red, blue and size L pays for three page loads to say it.

A GET form fixes that. Checkboxes named after the parameter, one submit, one navigation.

<form>
  {%- for filter in collection.filters -%}
    {%- if filter.type == 'list' -%}
      <fieldset>
        <legend>{{ filter.label }}</legend>
        {%- for value in filter.values -%}
          <label for="Filter-{{ filter.param_name }}-{{ forloop.index }}">
            <input
              type="checkbox"
              name="{{ value.param_name }}"
              value="{{ value.value }}"
              id="Filter-{{ filter.param_name }}-{{ forloop.index }}"
              {% if value.active %}checked{% endif %}
              {% if value.count == 0 and value.active == false %}disabled{% endif %}
            >
            {{ value.label }} ({{ value.count }})
          </label>
        {%- endfor -%}
      </fieldset>
    {%- endif -%}
  {%- endfor -%}
  <button type="submit">Apply</button>
</form>

Look closely at the disabled condition. A value with count == 0 has no results and should be unclickable, but only when it is not already active. Drop the value.active == false half and a customer who has filtered themselves into an empty page finds the checkbox that got them there greyed out and cannot untick it.

The active filters go beside the form as plain links.

{%- for filter in collection.filters -%}
  {%- for value in filter.active_values -%}
    <a href="{{ value.url_to_remove }}">{{ filter.label }}: {{ value.label }}</a>
  {%- endfor -%}
{%- endfor -%}

What the GET form silently drops

Submitting a GET form replaces the query string. The browser serialises that form’s own fields and nothing else, so any parameter already in the URL without a matching input is gone.

Sorting is the one that bites. A customer sorts by price, ticks a filter, and lands back in the collection’s default order with no clue what they did.

The search filter example in Shopify’s own docs points at the fix by carrying the search terms through a hidden field:

<input type="hidden" name="q" value="{{ search.terms }}">

Do the same for sort_by, or put the sort control inside the filter form so it serialises with the checkboxes. Dawn takes the second route. Its snippets/facets.liquid wraps one <form id="FacetFiltersForm"> around both the facet inputs and the <select name="sort_by">, and adds the hidden q and options[prefix] only on search results.

Pagination is the opposite case: you want it dropped, and Shopify drops it for you. The docs say of url_to_add that “Any pagination URL parameters are removed”, and of url_to_remove that they “are also removed”. Page 4 of an unfiltered collection has no relationship to page 4 of a filtered one. Keep page out of your form too.

How do the filter URL parameters work?

Every applied filter shows up in the URL in one of two shapes.

filter.<filter_scope>.<attribute>=<value>
filter.<filter_scope>.<attribute>.<attribute_scope>=<value>

filter_scope is p for the product level or v for the variant level. The second form exists for attributes that need qualifying, like which option or which price bound.

filter.p.product_type=shoes
filter.p.tag=new,trending
filter.p.vendor=vendor1
filter.p.m.custom.made_in=canada
filter.v.availability=1
filter.v.option.color=red
filter.v.price.gte=20.40
filter.v.t.shopify.fabric=gid://shopify/Metaobject/1

Multiple values go either as a comma-separated list or as a repeated parameter. filter.v.option.color=red,blue and filter.v.option.color=red&filter.v.option.color=blue mean the same thing, which matters for the form above, because a set of same-named checkboxes produces the second.

Price needs care. The URL takes a plain monetary value in the shop’s currency, 5 or 20.40, while filter.min_value.value in Liquid is money and needs converting before it can sit in a number input. The thousands separator has to go as well.

<input
  type="number"
  name="{{ filter.min_value.param_name }}"
  value="{{ filter.min_value.value | money_without_currency | replace: ',', '' }}"
  min="0"
  max="{{ filter.range_max | money_without_currency | replace: ',', '' }}"
>

min_value.param_name resolves to filter.v.price.gte and max_value.param_name to filter.v.price.lte. range_max is the highest product price in the current collection or search results, which makes a sensible max and a sensible placeholder.

What changes on the product card

Variant-scoped filters quietly rewrite part of the product object. Per the docs, when variant-specific filters are applied, product.featured_media returns the featured media of the first matching variant that has media, and product.url deep links that variant. Filter by the Color option and the grid shows the right colour, and the card link opens the product already on it.

The docs name those two attributes and no others. A card snippet built on product.images and indexed by hand will not follow the filter, so it keeps showing whatever the merchant put first.

Can this update without a full page load?

Yes. The mechanism is the Section Rendering API, and it has nothing to do with filtering: it returns the rendered HTML of a section at whatever storefront URL you ask for. Intercept the submit, build the same query string, and request the current path back with ?section_id= appended. Dawn’s assets/facets.js builds ${window.location.pathname}?section_id=${section.section}&${searchParams}, swaps the returned markup in, and calls history.pushState with the filtered URL so the back button still behaves. The request shape and its limits are covered in our write-up on rendering sections over fetch.

Build the no-JavaScript version first. The form navigates on its own, so the script becomes an enhancement instead of the only path to a filtered page.

Where storefront filtering runs out

Three ceilings. Collections over 5,000 products do not display filters. Search results over 1,000 products do not either. And applying a filter on the search page strips every non-product result, so articles and pages disappear the instant someone ticks a box.

The real limit is the data behind it. Filters can only be built from availability, category, price, tags, product type, vendor, variant options and metafields. Anything outside that list has to be moved into a metafield before it can be filtered on, which turns into a data modelling job of its own (the metafield or metaobject decision).

What I would ship: one GET form holding both the filter checkboxes and the sort select, hidden inputs for anything else the page depends on, url_to_remove anchors for the active chips, and zero JavaScript in the first commit. Section rendering goes on top once that works. I would not reach for a third-party filter app unless a single collection is past 5,000 products, or the filters need data Shopify will not index. Storefront filtering costs nothing extra, and every value arrives with a count that already matches what the filtered page will show.

Whoooop builds and maintains Shopify themes, and the collection and search templates are where most of this work lands. If you want filters that survive sorting, pagination and a merchant adding a sixth filter next month, that is the kind of Shopify work we take on.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch