Ask the Storefront API for the variant rather than downloading every variant and matching in JavaScript. selectedOrFirstAvailableVariant resolves a shopper’s option choices server side, options.optionValues gives you the grid to render, and encodedVariantExistence tells you which combinations a variant actually exists for. Everything below is written against Storefront API 2026-07.
That matters more than it used to. Shopify raised the variant ceiling to 2048 for every merchant on 15 October 2025, up from the old cap of 100. A product page query that worked fine against 100 variants now has a ceiling twenty times higher to worry about.
Why fetching every variant stopped working
The pattern most storefronts still ship is three steps. Request variants on the product, get back the full list with selectedOptions on each one, then run a find in the browser whenever someone clicks a swatch.
At a dozen variants nobody notices. At two thousand you are paginating a connection to build a picker that cannot render until the last page lands, and shipping a payload of option names and values that the API could have matched for you in one hop. Combined listings make it worse, because some option values belong to a different product entirely and no single variants list can represent them.
The Product object has had the replacement fields for a while. Most codebases have not moved.
Which field turns a selection into a variant?
Two fields do it, with different contracts.
selectedOrFirstAvailableVariant is documented as “Find an active product variant based on selected options, availability or the first variant.” Its selectedOptions argument is optional, so a bare /products/merino-crew with no query string still resolves to something sensible. ignoreUnknownOptions defaults to true and caseInsensitiveMatch defaults to false.
variantBySelectedOptions is the strict one. selectedOptions is required, it returns null when nothing matches, and ignoreUnknownOptions defaults to false.
That default flip is the part that bites. Same argument name, opposite default, so a query string you pass through untouched will be shrugged off by one field and refused by the other. If you feed either of them raw search params, set the argument explicitly instead of trusting the default you happen to remember.
caseInsensitiveMatch is false on both, which means ?colour=navy will not match an option stored as Colour / Navy. Shopify’s own storefront URLs use the exact casing from the option, so this only hurts when a link has been hand-written or lower-cased by a redirect rule.
The input type is small: SelectedOptionInput is a name and a value, both non-null strings, and the docs cap the input at 250 values.
What a Storefront API variant selection query looks like
query VariantPicker($handle: String!, $selectedOptions: [SelectedOptionInput!]!) {
product(handle: $handle) {
id
title
handle
encodedVariantExistence
encodedVariantAvailability
options {
name
optionValues {
name
swatch {
color
image {
previewImage {
url
altText
}
}
}
firstSelectableVariant {
id
selectedOptions {
name
value
}
}
}
}
selectedOrFirstAvailableVariant(
selectedOptions: $selectedOptions
ignoreUnknownOptions: true
caseInsensitiveMatch: true
) {
id
title
availableForSale
price {
amount
currencyCode
}
selectedOptions {
name
value
}
}
adjacentVariants(selectedOptions: $selectedOptions) {
id
availableForSale
selectedOptions {
name
value
}
}
}
}
One request, whatever the variant count.
firstSelectableVariant is the field that makes the swatches clickable without a second round trip. It returns “the product variant that combines this option value with the lowest-position option values for all other options”, so every swatch already knows a real variant it can link to. swatch { color image } gives you the colour chip or the image to render it with.
How do you disable combinations that have no variant?
encodedVariantExistence and encodedVariantAvailability are both plain String fields with no arguments. The first encodes every option value combination that has a variant. The second encodes every combination whose variant is currently available for sale. Four control characters carry the structure: : starts a new option, , ends a repeated prefix, a space marks a gap in a sequence and - covers a continuous range.
You do not have to parse that by hand. @shopify/hydrogen-react is an ordinary npm package and the two decoders work anywhere, including a Next.js or Astro storefront:
import {
decodeEncodedVariant,
isOptionValueCombinationInEncodedVariant,
} from '@shopify/hydrogen-react';
decodeEncodedVariant('v1_0:0-2,1:2,');
// [[0, 0], [0, 1], [0, 2], [1, 2]]
const existence = 'v1_0:0-1,1:2,';
isOptionValueCombinationInEncodedVariant([0, 0], existence); // true
isOptionValueCombinationInEncodedVariant([0, 2], existence); // false
isOptionValueCombinationInEncodedVariant([2], existence); // false
The numbers are positions, not ids. [0, 2] means the first value of the first option combined with the third value of the second option, indexed into options and optionValues in the order the API returned them. Sort either array for display and the indices stop lining up, so keep the API ordering and sort at render time.
Holding both strings gives each swatch three states, not two. Exists and available, so it is enabled. Exists but sold out, so it renders struck through and still links somewhere. No variant at all, so it is disabled or hidden.
What is adjacentVariants for?
The docs define it as “a list of variants whose selected options differ with the provided selected options by one, ordered by variant id”. One hop from where the shopper is standing.
That is the set you prefetch. Pull the price, the image and the availability for every neighbour on the first request, and clicking a swatch repaints from data you already hold instead of waiting on a network round trip.
The reference carries its own warning. With a small number of options and a large number of values per option, the array gets big, and Shopify says to avoid the field in that case. A shoe in forty sizes and one colour is exactly that shape.
Reading the selection back out of the URL
Shopify’s storefronts put the selection in the query string as ?Colour=Navy&Size=M, which is worth copying: it survives a refresh, it can be shared, and it is what Hydrogen’s useSelectedOptionInUrlParam writes.
type SelectedOptionInput = {name: string; value: string};
/** Turn ?Colour=Navy&Size=M into SelectedOptionInput[] for the Storefront API. */
export function getSelectedOptions(request: Request): SelectedOptionInput[] {
const {searchParams} = new URL(request.url);
const ignored = new Set(['variant', 'srsltid', 'gclid', 'fbclid']);
const options: SelectedOptionInput[] = [];
for (const [name, value] of searchParams) {
if (ignored.has(name.toLowerCase()) || name.startsWith('utm_')) continue;
options.push({name, value});
}
return options.slice(0, 250);
}
The filter is doing real work. Ad platforms and Google Shopping append their own parameters, and variant is a legacy id that is not an option name. The slice keeps you inside the documented 250 limit if something upstream ever loops.
Point your canonical tag at the bare product URL. Two thousand variants multiplied out into query strings is not a set of pages you want a crawler treating as distinct.
On Hydrogen, getProductOptions assembles all of this for you. Hand it the handle, both encoded strings, options.name, optionValues.name, optionValues.firstSelectableVariant, selectedOrFirstAvailableVariant and adjacentVariants, and each option value comes back with available, exists, selected, handle, variantUriQuery, swatch and isDifferentProduct. That last flag is the combined listing case, where the colour a shopper picks lives on a separate product and the swatch has to link out to it.
Below roughly fifty variants, keep the list-and-match approach. It is less code and one fewer dependency, and nobody browsing the site will feel the difference. Above that, or on any catalogue where a merchant might plausibly add a third option or turn on combined listings, move to the resolver fields now. The work is a query rewrite plus a picker rewrite, and the picker rewrite is the one that grows teeth once real traffic and a support queue are attached to it. The same reasoning applies to the Storefront cart mutations and to localised pricing through @inContext: let the API do the matching, and keep the client thin.
We build Shopify storefronts and fix the ones that have outgrown their first product page, whether that is a theme, a Hydrogen app or a Next.js front end talking to the Storefront API. If a variant picker is the slowest part of your product page, our Shopify development work is where that usually gets sorted.