Shopify Storefront API Product Filters: Pass input Back

Shopify Storefront API product filters live on Collection.products. You pass a filters argument made of ProductFilter inputs, and the same connection returns a filters list describing every facet the merchant has enabled, with a ready-made input on each value that you echo back into the next query. The merchant decides which facets exist, in the Search & Discovery app. Your code decides how to draw them and how to keep the selection in the URL.

Everything below was checked against Storefront API version 2026-10, the current stable release in October 2026.

Where do Shopify Storefront API product filters come from?

Not from your code. The merchant configures filters in the Search & Discovery app, and the Storefront API exposes whatever that app has switched on. The filtering tutorial names two filters that are on by default, availability and price. Product type, vendor and category have to be enabled under Filters. Anything beyond those is a custom filter: a product option such as colour or size, or a metafield.

Metafield filters are picky about type. The MetafieldFilter input accepts single_line_text_field, boolean, number_integer and number_decimal; the app also lets merchants expose list text and metaobject references. A rich text or JSON metafield will never become a facet, so if a client wants to filter by it, the data model has to change first.

Two more rules matter before you write a line of GraphQL. A store can have at most 25 filters in total, standard and custom combined, and each source can be used once. Your Headless channel or custom app also needs the unauthenticated_read_product_listings scope, which gates the Filter and FilterValue objects. One oddity: the tutorial’s requirements include an Online Store 2.0 theme installed on the store, even unpublished. A fully headless shop still needs one sitting in the admin.

How do you read the available facets?

Ask for filters on the product connection. It sits beside nodes and pageInfo, so one request fetches the products and the facet panel together.

query CollectionFacets(
  $handle: String!
  $filters: [ProductFilter!]
  $first: Int!
  $after: String
) {
  collection(handle: $handle) {
    products(first: $first, after: $after, filters: $filters) {
      filters {
        id
        label
        type
        presentation
        values {
          id
          label
          count
          input
          swatch { color }
        }
      }
      nodes {
        id
        handle
        title
        availableForSale
        priceRange {
          minVariantPrice { amount currencyCode }
        }
      }
      pageInfo { hasNextPage endCursor }
    }
  }
}

The Filter object carries three things you render from. type is LIST, PRICE_RANGE or BOOLEAN: checkboxes, a range slider or a single toggle. presentation is only populated for LIST filters and is TEXT, IMAGE or SWATCH; for SWATCH each value’s swatch field holds the colour or pattern the merchant uploaded, for IMAGE the image field is set instead. label already reflects any value grouping done in the app (Onyx, Ebony and Midnight collapsed into Black, say).

Each FilterValue has a count, the number of matching products under the filters currently applied, and an input. The input field is typed JSON! and holds an object that already matches the ProductFilter schema, for example {"productType":"shoes"}. You never construct a filter by hand from a label. You take input, store it, and pass it back.

How do the filters combine?

The filters argument on Collection.products takes a list of up to 250 ProductFilter values. Different filter kinds are ANDed and repeats of the same kind are ORed. So [{productType: "shoes"}, {productVendor: "bestshop"}, {variantOption: {name: "color", value: "blue"}}] means shoes from bestshop that come in blue, and two variantOption entries for blue and red mean either colour.

The ProductFilter input has ten fields in 2026-10: available, category, price, productMetafield, productType, productVendor, tag, taxonomyMetafield, variantMetafield and variantOption. Price is the exception to the OR rule. PriceRangeFilter takes min (defaults to 0) and max (empty means no ceiling), and if you pass more than one price filter everything after the first is ignored. Build a single range from the slider and send exactly one.

Note what is missing. The top-level products query has no filters argument at all; it takes a query string with its own syntax (product_type:, vendor:, tag: and friends). Facets with counts only exist on a collection or on search results, so a headless “all products” page that wants real filtering should read from the all-products collection, not the root query.

How do you keep the selection in the URL?

The selection has to survive a refresh, a shared link and the back button, which means it belongs in the query string, not in component state. Since input is already the exact object the API wants, the simplest encoding is one filter parameter per selected value, each holding the JSON.

type ProductFilter = Record<string, unknown>;

/** Read every ?filter= value and turn it into a ProductFilter for the query variables. */
export function filtersFromUrl(url: URL): ProductFilter[] {
  const filters: ProductFilter[] = [];
  for (const raw of url.searchParams.getAll('filter')) {
    try {
      filters.push(JSON.parse(raw) as ProductFilter);
    } catch {
      // A hand-edited or truncated value should not take the page down.
    }
  }
  return filters;
}

/** Build the href for a facet value: adds it if absent, removes it if present. */
export function toggleFilter(url: URL, input: unknown): URL {
  const serialised = typeof input === 'string' ? input : JSON.stringify(input);
  const next = new URL(url);
  const current = next.searchParams.getAll('filter');
  const remaining = current.filter((value) => value !== serialised);
  if (remaining.length === current.length) remaining.push(serialised);

  next.searchParams.delete('filter');
  next.searchParams.delete('cursor');
  for (const value of remaining) next.searchParams.append('filter', value);
  return next;
}

Two details are deliberate. The cursor parameter is dropped whenever the filter set changes, because page three of the unfiltered list means nothing in the filtered one; our Hydrogen pagination post covers how that cursor gets into the URL. And the toggle compares serialised strings, which only works if you always serialise the API’s own input and never a reformatted copy.

Pass filtersFromUrl(url) as $filters and the loop closes: counts reflect the current selection, each value’s input becomes a link, and a value is “checked” when its serialised input is present in the URL.

What about search results?

The search query takes productFilters: [ProductFilter!], same 250-value cap, and returns productFilters as [Filter!]!, so one facet panel component serves both pages. Its totalCount is capped at 1,000 and the help page confirms that search facet counts are computed from the first 1,000 results only. Past 100,000 matching products no filters come back at all. The search query reference has the remaining arguments, including unavailableProducts for hiding sold-out items.

Which limits will you hit first?

A collection with more than 5,000 products returns no filters. That is the first one to check when a client’s biggest category shows an empty facet panel while smaller ones work.

Values are capped twice. The app lists up to 1,000 values per filter, but customers are shown at most 100 and anything past that is hidden. For a long tail of tags or vendors the fix is grouping in the app: each group can hold 200 unique values and a store can have 1,000 groups. The category filter does not support grouping.

The help page also says the price filter is not displayed for currencies other than the shop’s default. If you sell into other markets through @inContext, test what filters returns for a non-default market before promising a price slider there; our note at /blog/shopify-storefront-api-incontext covers what @inContext does and does not change.

What I would build

Treat the merchant’s Search & Discovery configuration as the contract and your code as a renderer for it. Query filters alongside nodes, switch on type and presentation to pick a control, and never invent a ProductFilter from a label when input is sitting right there. Hold the selection in repeated filter URL parameters, clear the cursor on every change, send one price range at most.

I would not build this on the top-level products query, and I would not promise faceted search on collections above 5,000 products until the client has seen the empty panel for themselves. The same settings drive Liquid themes too, so a store that keeps a theme alongside the headless build gets consistency for free; our Liquid collection filters write-up shows the other side of the same configuration.

Whoooop builds headless Shopify storefronts on Hydrogen and Next.js, including collection pages whose filtering, pagination and localisation hold up on real catalogues. If a filter panel is where your build has stalled, our Shopify development work covers exactly this.

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