Skip to content

Storefront filtering in Liquid

Sellerlane exposes configured storefront filters through collection.filters and search.filters. Themes render those objects as an ordinary GET form; the filter object supplies the trusted parameter names, values, counts, and add/remove URLs.

This page is the exact public URL and Liquid contract. It follows Shopify’s current storefront filtering model, while calling out Sellerlane extensions and unsupported sources explicitly.

Complete collection-filter example

The following form handles list, boolean, and range filters. It also preserves the active sort. The request method must be GET because filter state is public URL state, not a cart or account mutation.

{% if collection.filters != blank %}
<form action="{{ collection.url }}" method="get">
{% if collection.sort_by != blank %}
<input type="hidden" name="sort_by" value="{{ collection.sort_by | escape }}">
{% endif %}
{% for filter in collection.filters %}
<fieldset>
<legend>{{ filter.label | escape }}</legend>
{% case filter.type %}
{% when 'price_range', 'range' %}
{% if filter.min_value %}
<label>
Minimum
<input
type="number"
inputmode="decimal"
min="0"
name="{{ filter.min_value.param_name }}"
value="{{ filter.min_value.value | escape }}"
>
</label>
{% endif %}
{% if filter.max_value %}
<label>
Maximum
<input
type="number"
inputmode="decimal"
min="0"
{% if filter.range_max != blank %}max="{{ filter.range_max }}"{% endif %}
name="{{ filter.max_value.param_name }}"
value="{{ filter.max_value.value | escape }}"
>
</label>
{% endif %}
{% else %}
{% for value in filter.values %}
<label>
<input
type="checkbox"
name="{{ value.param_name }}"
value="{{ value.value | escape }}"
{% if value.active %}checked{% endif %}
{% if value.count == 0 and value.active == false %}disabled{% endif %}
>
{{ value.label | escape }} ({{ value.count }})
</label>
{% endfor %}
{% endcase %}
{% if filter.url_to_remove != blank and filter.active_values != blank %}
<a href="{{ filter.url_to_remove }}">Clear {{ filter.label | escape }}</a>
{% endif %}
</fieldset>
{% endfor %}
<button type="submit">Apply filters</button>
</form>
{% endif %}

For a published Hindi locale, selecting the Blue option and a price of ₹50–₹200 produces a URL shaped like:

/hi/collections/shoes?filter.v.option.Color=Blue&filter.v.price.gte=50&filter.v.price.lte=200

The order of query parameters is not significant. Price bounds are public major-unit amounts: 50 means ₹50, $50, or 50 units of the active storefront currency. Sellerlane converts them to the currency’s internal minor-unit representation after parsing the URL, including currencies with zero or three fraction digits.

To use the same UI on search, render search.filters, submit to routes.search_url, and preserve the query:

<form action="{{ routes.search_url }}" method="get">
<input type="hidden" name="q" value="{{ search.terms | escape }}">
{% render 'filter-fields', filters: search.filters %}
<button type="submit">Apply filters</button>
</form>

Because {% render %} has an isolated scope, the filters value must be passed explicitly. See the render tag.

Canonical public parameters

Sellerlane emits and accepts these storefront parameter shapes:

SourcePublic parameterSellerlane contract
Availabilityfilter.v.availabilityUses the value supplied by the trusted filter object.
Minimum pricefilter.v.price.gteDecimal amount in the active currency’s major unit.
Maximum pricefilter.v.price.lteDecimal amount in the active currency’s major unit.
Product tagfilter.p.tagExact merchant tag value.
Merchant product typefilter.p.product_typeThe merchant-authored product type, not Sellerlane’s product-kind enum.
Vendorfilter.p.vendorExact merchant vendor value.
Product categoryfilter.p.t.categoryTaxonomy/category ID; __ can join IDs in one OR value.
Variant optionfilter.v.option.OPTION_NAMEThe option name comes from the enabled filter definition.
Product metafieldfilter.p.m.NAMESPACE.KEYAccepted only for an enabled, published product-owner filter definition.
Variant metafieldfilter.v.m.NAMESPACE.KEYAccepted only for an enabled, published variant-owner filter definition.
Standard product attributefilter.v.t.shopify.KEYNot currently supported or emitted. A trusted non-empty filter manifest ignores an unconfigured key; without a manifest, the compatibility parser can still treat this spelling as an ordinary variant-option filter.
Sellerlane product kindfilter.p.sellerlane_product_kindSellerlane extension for physical, digital, event, gift-card, and related product kinds.

filter.v.price is the only public price spelling. The historical local alias filter.p.price is discarded at the public URL boundary and is never emitted by a filter object.

The namespace and key in filter.p.m.* and filter.v.m.* are not enough to authorize a filter. Sellerlane maps them through the store’s trusted filter manifest, verifies the owner type, and forwards only the corresponding internal definition. A private, disabled, unknown, or cross-owner metafield parameter has no filtering effect.

Multiple values and filter operators

Repeat a parameter to select several values:

/collections/shoes?filter.v.option.Color=Blue&filter.v.option.Color=Black&filter.p.vendor=Acme

For a configured filter, values use the AND or OR operator in the trusted manifest and different filter sources combine with AND. That manifest wins over a forged internal .all suffix. When no filter manifest is available, the compatibility parser still accepts .all and interprets it as AND; themes must not emit that internal spelling or treat the manifest-free fallback as a merchant-configured operator.

The filter objects expose that decision as filter.operator:

{% for filter in collection.filters %}
<p>
{{ filter.label }}:
{% if filter.operator == 'AND' %}match every selected value{% else %}match any selected value{% endif %}
</p>
{% endfor %}

Example output:

Color: match any selected value
Features: match every selected value

Variant option and variant-metafield predicates are evaluated within one variant. A product cannot pass by taking the color from one variant and the finish metafield from another.

Relevant variants

When a variant-scoped filter matches, Sellerlane retains the matching variant IDs while hydrating the product. The most relevant valid variant then drives:

  • product.url, including its variant parameter;
  • product.selected_variant;
  • product.selected_or_first_available_variant;
  • product.featured_media and product.featured_image; and
  • variant.matched for every item in product.variants.

Use that contract directly in a product card:

<article>
<a href="{{ product.url }}">
{% if product.featured_image %}
{{ product.featured_image | image_url: width: 720 | image_tag }}
{% endif %}
<h2>{{ product.title }}</h2>
</a>
{% for variant in product.variants %}
{% if variant.matched %}
<p>Matched option: {{ variant.title }}</p>
{% endif %}
{% endfor %}
</article>

For a blue variant whose ID is gid://sellerlane/ProductVariant/42, the link is shaped like:

/fr/products/trail-shoe?variant=gid%3A%2F%2Fsellerlane%2FProductVariant%2F42

If no variant filter is active, every variant reports matched == true. Ordinary collection sorting alone does not synthesize relevant-variant state. Search can still select a relevant variant from the search hit; any stale or cross-product variant ID is rejected after the product’s real variants are loaded.

This mirrors the relevant-media and URL behavior documented on Shopify’s current product object.

Tags are data, not URL slugs

Sellerlane preserves a merchant tag’s display spelling. It does not lowercase, slugify, or replace spaces in product.tags, collection.tags, blog.tags, or filter labels. URL encoding is applied only when a URL is built.

{% for tag in product.tags %}
<span>{{ tag | escape }}</span>
{% endfor %}

Given tags saved as New Arrival, blue, and Eco, product.tags is returned in alphabetical order:

blue
Eco
New Arrival

The related collection and blog fields have deliberately different meanings:

FieldMeaning
collection.all_tagsTags across the unfiltered published collection.
collection.tagsTags belonging to products that remain in the current filtered view; nil when none remain.
collection.current_tagsActive tag-route state for the current view.
blog.all_tagsTags across all published articles in the blog.
blog.tagsTags in the current filtered blog view.
blog.current_tagsActive blog tag-route state.

Example:

{% if collection.tags %}
{% for tag in collection.tags %}{{ tag }}{% unless forloop.last %}, {% endunless %}{% endfor %}
{% else %}
No tags in this result set
{% endif %}

Possible output after filtering:

blue, New Arrival

Limits and suppression

Sellerlane keeps configuration limits separate from request-abuse limits:

LimitBehavior
Configured storefront filtersAt most 25 attributes per filter set and per returned manifest.
Filter keys in one search requestAt most 30 distinct normalized keys. Repeated values share one key, and .gte/.lte bounds form one range key.
Values for one filter parameterAt most 50 distinct values.
Values across one requestAt most 500.
Collection filter UISuppressed when the unfiltered collection has more than 5,000 products.
Search filter UISuppressed when the matching search has more than 100,000 products.
Pagination page size1–250.
Reachable paginated item25,000; a larger count is represented by the 25,001 sentinel.

When facets are suppressed, products still render. collection.filters or search.filters is empty, so the theme should omit the filter UI rather than showing controls with incomplete counts.

Applying, removing, or clearing a filter resets the relevant paginator to page

  1. Ordinary resource and metafield paginators use the documented page parameter. Independently pageable theme-setting arrays receive a deterministic hashed paginate.page_param; ordinary resource paginators should be isolated by section or Section Rendering when a page contains more than one. Filter URLs remove the affected page parameter without disturbing unrelated safe query state. See the paginate tag and Shopify’s current pagination-limit changelog.

Locales and generated URLs

Every filter, removal, product, sort, and pagination URL retains the active published locale prefix. The primary locale remains unprefixed.

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

Expected URL while browsing Hindi:

/hi/collections/shoes?filter.p.vendor=Acme

Themes should use supplied resource and filter URLs rather than prepend a locale manually. See Locales and translation.

Troubleshooting

SymptomCheck
No filter UIConfirm the definition is enabled and the collection/search suppression threshold wasn’t exceeded.
Metafield parameter has no effectConfirm its definition is published for storefront filtering and that product versus variant ownership matches the URL grammar.
Price is 100× too large or smallSubmit a major-unit decimal through filter.v.price.gte or .lte; don’t send internal minor units.
Product image doesn’t match the selected optionLink with product.url and render product.featured_media from the filtered product Drop.
A deep filtered page is emptyDo not retain a stale page or hashed page parameter when filter state changes.
Locale disappearsUse value.url_to_add, value.url_to_remove, filter.url_to_remove, and resource URLs instead of rebuilding paths.