To sort products with the Shopify Storefront API, pass sortKey and reverse on the connection you are paginating. The catch is that collection.products takes ProductCollectionSortKeys and the root products query takes ProductSortKeys, which are different enums: newest first is CREATED on one and CREATED_AT on the other. There is no sort by metafield or stock on either.
Everything here was written against Storefront API 2026-10, the latest stable version on Shopify’s versioning page as of November 2026. The queries were run against mock.shop, Shopify’s public mock Storefront API, on Node 24.12. I have not run them against a production store, and the behaviour I describe below as observed is what mock.shop returned, not something the reference documents.
Which sort keys can you use to sort products in the Storefront API?
Three product connections take a sortKey, and each has its own enum.
Collection.products uses ProductCollectionSortKeys: BEST_SELLING, COLLECTION_DEFAULT, CREATED, ID, MANUAL, PRICE, RELEVANCE and TITLE. Its default is COLLECTION_DEFAULT.
The root products query uses ProductSortKeys: BEST_SELLING, CREATED_AT, ID, PRICE, PRODUCT_TYPE, RELEVANCE, TITLE, UPDATED_AT and VENDOR. Its default is ID, so an all-products page with no sortKey comes back in ID order, an order no merchant ever set.
The search query gets two: PRICE and RELEVANCE.
So the collection side has MANUAL and COLLECTION_DEFAULT and spells the date key CREATED. The root side has UPDATED_AT, VENDOR and PRODUCT_TYPE and spells it CREATED_AT. Reuse one fragment for both and one of them breaks. Hard-code the wrong value and mock.shop answers with:
Argument 'sortKey' on Field 'products' has an invalid value (CREATED_AT). Expected type 'ProductCollectionSortKeys'.
Pass it as a variable instead (my test query named it $k) and the message changes to Variable $k of type ProductCollectionSortKeys was provided invalid value, with the list of legal values in extensions.problems. On mock.shop both errors came back with HTTP 200, so a client that only checks res.ok would miss them.
RELEVANCE appears in the collection enum, but Collection.products has no query argument in 2026-10, only filters, first, last, after, before, reverse and sortKey. The enum description says not to use RELEVANCE without a search query. When I sent it anyway on mock.shop, it did not error; it returned the collection’s default order.
How do the theme’s sort_by values map to sortKey?
A headless build that replaces a Liquid theme should keep the theme’s ?sort_by= URLs, because they are already indexed and linked. The Liquid collection.sort_options example lists the values a theme emits: manual, most-relevant, best-selling, title-ascending, title-descending, price-ascending, price-descending, created-ascending and created-descending.
The API has no direction in its keys. Direction is reverse. So price-descending is PRICE with reverse: true, and created-descending is CREATED (or CREATED_AT) with reverse: true. On mock.shop, TITLE with reverse: false came back A to Z and CREATED with reverse: false came back oldest first, matching each product’s createdAt.
Here is the translation for both connections, plus a loader and a sort-link helper. It runs as is on Node 24 (type stripping and import.meta.main), and against mock.shop it prints the men’s collection from most to least expensive:
// collection-sort.ts
// Storefront API 2026-10. Runs on Node 24 with no build step: node collection-sort.ts
type Sort = { sortKey: string; reverse: boolean };
// Theme sort_by values, translated for Collection.products (ProductCollectionSortKeys)
const COLLECTION_SORTS: Record<string, Sort> = {
'manual': { sortKey: 'MANUAL', reverse: false },
'best-selling': { sortKey: 'BEST_SELLING', reverse: false },
'title-ascending': { sortKey: 'TITLE', reverse: false },
'title-descending': { sortKey: 'TITLE', reverse: true },
'price-ascending': { sortKey: 'PRICE', reverse: false },
'price-descending': { sortKey: 'PRICE', reverse: true },
'created-ascending': { sortKey: 'CREATED', reverse: false },
'created-descending': { sortKey: 'CREATED', reverse: true },
};
// The same values for the root products query (ProductSortKeys). No MANUAL here.
const PRODUCTS_SORTS: Record<string, Sort> = {
'best-selling': { sortKey: 'BEST_SELLING', reverse: false },
'title-ascending': { sortKey: 'TITLE', reverse: false },
'title-descending': { sortKey: 'TITLE', reverse: true },
'price-ascending': { sortKey: 'PRICE', reverse: false },
'price-descending': { sortKey: 'PRICE', reverse: true },
'created-ascending': { sortKey: 'CREATED_AT', reverse: false },
'created-descending': { sortKey: 'CREATED_AT', reverse: true },
};
export function collectionSort(sortBy: string | null): Sort {
return COLLECTION_SORTS[sortBy ?? ''] ?? { sortKey: 'COLLECTION_DEFAULT', reverse: false };
}
export function productsSort(sortBy: string | null): Sort {
return PRODUCTS_SORTS[sortBy ?? ''] ?? { sortKey: 'BEST_SELLING', reverse: false };
}
const COLLECTION_PRODUCTS = `
query CollectionProducts(
$handle: String!
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
$after: String
) {
collection(handle: $handle) {
products(first: 12, sortKey: $sortKey, reverse: $reverse, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { handle priceRange { minVariantPrice { amount } } }
}
}
}
`;
export async function loadCollectionPage(endpoint: string, url: URL, handle: string) {
const { sortKey, reverse } = collectionSort(url.searchParams.get('sort_by'));
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: COLLECTION_PRODUCTS,
variables: { handle, sortKey, reverse, after: url.searchParams.get('cursor') },
}),
});
const { data, errors } = await res.json();
if (errors?.length) throw new Error(errors.map((e: { message: string }) => e.message).join('; '));
return data.collection?.products ?? null;
}
// A sort change starts again from page one: never carry the old cursor across.
export function sortLink(current: URL, sortBy: string): string {
const next = new URL(current);
next.searchParams.set('sort_by', sortBy);
next.searchParams.delete('cursor');
return next.pathname + next.search;
}
if (import.meta.main) {
const endpoint = 'https://mock.shop/api/2026-10/graphql.json';
const page = await loadCollectionPage(
endpoint,
new URL('https://example.com/collections/men?sort_by=price-descending'),
'men',
);
for (const p of page.nodes) console.log(p.priceRange.minVariantPrice.amount, p.handle);
console.log(sortLink(new URL('https://example.com/collections/men?sort_by=title-ascending&cursor=abc'), 'price-ascending'));
}
I sent every sort_by value through both tables to mock.shop, one query per value covering both connections, and none came back with an error. Against a real shop, swap in https://<shop>.myshopify.com/api/2026-10/graphql.json and add your X-Shopify-Storefront-Access-Token header.
Three choices in that file are mine, not Shopify’s. An unknown or missing sort_by on a collection falls back to COLLECTION_DEFAULT, which is also the API default. most-relevant is left out of both tables: Collection.products takes no search query, and on the root products query RELEVANCE only applies when you also pass query, which an all-products page does not. And the all-products page defaults to BEST_SELLING rather than the API’s ID.
What do MANUAL and COLLECTION_DEFAULT actually sort by?
The enum descriptions say only “Sort by the manual value” and “Sort by the collection-default value”. The Storefront Collection object has no field telling you which sort order the merchant picked in the admin, so a headless storefront cannot read the default and label it. Offer the default under a neutral label such as “Featured” (the name the Liquid example gives manual) and let the merchant’s admin setting decide what it means.
I could not tell MANUAL, COLLECTION_DEFAULT and BEST_SELLING apart on mock.shop. All three returned the same order in each of the five collections I tried (men, women, tops, bottoms, unisex), and I have no view of mock.shop’s sales data, so best-selling was not meaningfully tested. Check all three on a real store with a non-manual collection before relying on the difference.
PRICE is similar. The reference does not say whether it uses the lowest or highest variant price. On mock.shop the one product with a price range (35.00 to 42.00) sorted by its lowest price, which is one data point, not a rule.
Why does changing the sort break pagination?
A cursor belongs to the sort it was issued under. On mock.shop, a cursor taken from a PRICE page and sent back with sortKey: TITLE failed with invalid cursor for this connection and collection: null.
Flipping reverse is worse, because it does not fail. The same PRICE cursor sent back with reverse: true returned two products instead of the three I asked for: the cheapest two, walking past the cursor in the opposite direction. No error, just a short, wrong page that looks like the end of the list.
That is why sortLink deletes cursor whenever it sets sort_by. If you build pagination with Hydrogen, our post on cursor pagination in Hydrogen covers resetting on a sort change from the component side.
Can you sort Storefront API products by metafield or stock?
No. None of the three enums has a metafield, inventory or availability key in 2026-10. Requests such as “sold out last”, “sort by our custom rank” or “sort by rating” have to be solved somewhere else.
For sold-out items, the closest the API gets is excluding them: Collection.products takes filters, and the filtering guide lists availability among them, so available: true drops sold-out products rather than moving them. The query ran on mock.shop, but its men’s collection had no sold-out products, so I saw the filter accepted, not the exclusion. Our write-up on Storefront API product filters covers passing those filters through. For a custom rank, set the collection to manual sort and have your app write the order with the Admin API’s collectionReorderProducts mutation. It needs the write_products scope, and it returns a user error on a collection that is not manually sorted. The storefront then queries with MANUAL. I have not run that mutation, because it needs a real store. Sorting in your own code only works if you fetch the whole collection first, which breaks cursor pagination and gets slower as the catalogue grows.
On a replatform or a new headless build, I would keep the theme’s sort_by values in the URL and translate them with two separate tables, one per enum. Reset the cursor on every sort change. Anything that is not price, title, date or best-selling goes back to the merchant’s manual collection order. I would not fake a metafield sort client-side on a collection of more than one page.
Whoooop builds headless Shopify storefronts and moves themed stores onto them without breaking their indexed collection URLs. Our Shopify development page covers how we scope that work.