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=200The 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:
| Source | Public parameter | Sellerlane contract |
|---|---|---|
| Availability | filter.v.availability | Uses the value supplied by the trusted filter object. |
| Minimum price | filter.v.price.gte | Decimal amount in the active currency’s major unit. |
| Maximum price | filter.v.price.lte | Decimal amount in the active currency’s major unit. |
| Product tag | filter.p.tag | Exact merchant tag value. |
| Merchant product type | filter.p.product_type | The merchant-authored product type, not Sellerlane’s product-kind enum. |
| Vendor | filter.p.vendor | Exact merchant vendor value. |
| Product category | filter.p.t.category | Taxonomy/category ID; __ can join IDs in one OR value. |
| Variant option | filter.v.option.OPTION_NAME | The option name comes from the enabled filter definition. |
| Product metafield | filter.p.m.NAMESPACE.KEY | Accepted only for an enabled, published product-owner filter definition. |
| Variant metafield | filter.v.m.NAMESPACE.KEY | Accepted only for an enabled, published variant-owner filter definition. |
| Standard product attribute | filter.v.t.shopify.KEY | Not 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 kind | filter.p.sellerlane_product_kind | Sellerlane 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=AcmeFor 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 valueFeatures: match every selected valueVariant 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 itsvariantparameter;product.selected_variant;product.selected_or_first_available_variant;product.featured_mediaandproduct.featured_image; andvariant.matchedfor every item inproduct.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%2F42If 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:
blueEcoNew ArrivalThe related collection and blog fields have deliberately different meanings:
| Field | Meaning |
|---|---|
collection.all_tags | Tags across the unfiltered published collection. |
collection.tags | Tags belonging to products that remain in the current filtered view; nil when none remain. |
collection.current_tags | Active tag-route state for the current view. |
blog.all_tags | Tags across all published articles in the blog. |
blog.tags | Tags in the current filtered blog view. |
blog.current_tags | Active 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 ArrivalLimits and suppression
Sellerlane keeps configuration limits separate from request-abuse limits:
| Limit | Behavior |
|---|---|
| Configured storefront filters | At most 25 attributes per filter set and per returned manifest. |
| Filter keys in one search request | At most 30 distinct normalized keys. Repeated values share one key, and .gte/.lte bounds form one range key. |
| Values for one filter parameter | At most 50 distinct values. |
| Values across one request | At most 500. |
| Collection filter UI | Suppressed when the unfiltered collection has more than 5,000 products. |
| Search filter UI | Suppressed when the matching search has more than 100,000 products. |
| Pagination page size | 1–250. |
| Reachable paginated item | 25,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
- Ordinary resource and metafield paginators use the documented
pageparameter. Independently pageable theme-setting arrays receive a deterministic hashedpaginate.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 thepaginatetag 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=AcmeThemes should use supplied resource and filter URLs rather than prepend a locale manually. See Locales and translation.
Troubleshooting
| Symptom | Check |
|---|---|
| No filter UI | Confirm the definition is enabled and the collection/search suppression threshold wasn’t exceeded. |
| Metafield parameter has no effect | Confirm its definition is published for storefront filtering and that product versus variant ownership matches the URL grammar. |
| Price is 100× too large or small | Submit a major-unit decimal through filter.v.price.gte or .lte; don’t send internal minor units. |
| Product image doesn’t match the selected option | Link with product.url and render product.featured_media from the filtered product Drop. |
| A deep filtered page is empty | Do not retain a stale page or hashed page parameter when filter state changes. |
| Locale disappears | Use value.url_to_add, value.url_to_remove, filter.url_to_remove, and resource URLs instead of rebuilding paths. |