Shopify Product Recommendations API: Theme and Headless

The Shopify product recommendations API returns up to ten products for a given product, in one of two intents: RELATED (similar items, generated automatically) and COMPLEMENTARY (things to buy alongside, which a merchant has to set up by hand). Headless builds call productRecommendations on the Storefront API. Themes fetch /recommendations/products and render a section.

Both routes read from the same engine, and the query has three arguments and little to get wrong, so a weak recommendations block is usually a data problem. Below are both calls against Storefront API version 2026-10, what feeds the results, and the gaps that show up after a migration.

How does the Shopify product recommendations API work?

There is one engine and three ways in: the Storefront API query, the theme endpoint that returns rendered HTML, and a JSON variant of the theme endpoint. All of them take a product and an intent and hand back at most ten products.

The productRecommendations query takes productId or productHandle, plus an optional intent that defaults to RELATED. It returns [Product!], a plain list rather than a connection, so there is no pagination and no first argument. Ten is the ceiling. If you want four, slice on your side.

The ProductRecommendationIntent enum has two values. Shopify describes RELATED as “a mix of products that are similar or complementary” and COMPLEMENTARY as products “complementary to a product for which recommendations are to be fetched”. Take the overlap in that wording at face value: a related list can contain pairing items, while a complementary list holds only what someone chose as a pairing.

Where do the recommendations come from?

Related recommendations are generated for you. Shopify’s Search & Discovery help page lists three signals: products commonly bought together, similar product descriptions, and related collections. The description signal is only available to merchants with an English storefront, so a store selling mainly in French or German starts one signal down.

Complementary recommendations are not generated at all. Someone picks them, up to ten per product, in the Search & Discovery app. Until that happens, a COMPLEMENTARY query returns nothing for that product, and a “Pairs well with” block sits empty.

The app stores those choices in three standard metafields, listed in Shopify’s standard definitions:

  • shopify--discovery--product_recommendation.related_products, a list.product_reference
  • shopify--discovery--product_recommendation.complementary_products, also a list.product_reference
  • shopify--discovery--product_recommendation.related_products_display, a text field that takes only manual or ahead

ahead puts the hand-picked related products before the automatic ones. only manual drops the automatic ones for that product entirely. That is the only real control you get over ranking: Shopify’s theme docs are explicit that you cannot customise the algorithm to exclude specific products.

Querying recommendations from a headless storefront

This is the call we would put in a Hydrogen loader or any server-side fetch. It asks by handle, because handles are what a product route already has, and it wraps the query in @inContext so prices come back in the visitor’s currency (our @inContext write-up covers that directive in detail).

type Intent = 'RELATED' | 'COMPLEMENTARY';

interface RecommendedProduct {
  id: string;
  handle: string;
  title: string;
  availableForSale: boolean;
  featuredImage: { url: string; altText: string | null; width: number; height: number } | null;
  priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
}

const QUERY = `#graphql
  query ProductRecommendations(
    $handle: String!
    $intent: ProductRecommendationIntent!
    $country: CountryCode
  ) @inContext(country: $country) {
    productRecommendations(productHandle: $handle, intent: $intent) {
      id
      handle
      title
      availableForSale
      featuredImage { url altText width height }
      priceRange { minVariantPrice { amount currencyCode } }
    }
  }
`;

/**
 * Fetch up to `limit` recommendations for one product.
 * Returns an empty array when Shopify has nothing to suggest.
 */
export async function getRecommendations(
  handle: string,
  intent: Intent,
  country = 'GB',
  limit = 4,
): Promise<RecommendedProduct[]> {
  const res = await fetch(
    `https://${process.env.SHOPIFY_STORE_DOMAIN}/api/2026-10/graphql.json`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Storefront-Access-Token': process.env.SHOPIFY_STOREFRONT_TOKEN ?? '',
      },
      body: JSON.stringify({ query: QUERY, variables: { handle, intent, country } }),
    },
  );

  if (!res.ok) throw new Error(`Storefront API returned ${res.status}`);

  const json = (await res.json()) as {
    data?: { productRecommendations: RecommendedProduct[] | null };
    errors?: { message: string }[];
  };
  if (json.errors?.length) throw new Error(json.errors[0].message);

  return (json.data?.productRecommendations ?? [])
    .filter((p) => p.availableForSale)
    .slice(0, limit);
}

Two details in there are deliberate. The return type is nullable, so the ?? [] saves a crash on a product Shopify has no opinion about. And the availableForSale filter is ours: the theme docs say out-of-stock items are left out of theme recommendations, but the Storefront API reference does not promise the same, so the code checks rather than assumes.

Call it twice on a product page, once per intent, and render the complementary block only when it has results. Recommendations are a below-the-fold concern, so they should never hold up the main product query. In Hydrogen that means returning the promise from the loader and letting the page stream it in.

Showing recommendations in a Liquid theme

Themes do not query GraphQL. They request a section from the product recommendations endpoint and swap the returned HTML into the page. The endpoint takes product_id (required), limit (1 to 10, default 10), intent (related or complementary, default related) and, for the HTML version, section_id. A missing product_id or a bad intent gets a 422. A product not published to the Online Store channel gets a 404.

Inside the section, the recommendations object exposes performed?, products, products_count and intent. On the first page render performed? is false, which is why the section is fetched again by script. Shopify’s related products guide does it with an IntersectionObserver, so the request only fires when the visitor scrolls near the block:

<div
  class="product-recommendations"
  data-url="{{ routes.product_recommendations_url }}?section_id={{ section.id }}&product_id={{ product.id }}&limit=4&intent=complementary"
>
  {%- if recommendations.performed? and recommendations.products_count > 0 -%}
    <h2>Pairs well with</h2>
    <ul>
      {%- for item in recommendations.products -%}
        <li><a href="{{ item.url }}">{{ item.title }}</a> {{ item.price | money }}</li>
      {%- endfor -%}
    </ul>
  {%- endif -%}
</div>

Keep {{ item.url }} as it comes back. The endpoint appends tracking parameters (pr_prod_strat, pr_rec_pid, pr_seq and friends) that feed Shopify’s recommendation reports, and rebuilding the link from the handle throws them away. If you already lean on fetched sections elsewhere, the pattern is the same one in our Section Rendering API guide.

Why are my recommendations empty or poor?

Usually one of four reasons.

The product is new, or the store is. The purchase-history signal needs orders, and a fresh catalogue has none.

The store was migrated. Shopify’s theme docs state that orders imported from other platforms do not influence recommendations. You can bring ten years of history across for customers and reporting, as in our guide to importing historical orders, and the recommendation engine will still treat the store as brand new.

The storefront is not in English. Without the description signal, the engine has only purchase history and collections to go on.

Or the products you expected are being filtered out. The theme docs list the exclusions: products that are out of stock, priced at zero, gift cards, and items already in the visitor’s cart.

The first three have one fix: seed the metafields yourself. For a migration we would export the old platform’s upsell and cross-sell links (WooCommerce and Magento both keep them per product), map them to Shopify product IDs, and write them with metafieldsSet on the Admin API, which accepts up to 25 metafields per call and applies them atomically:

mutation SetComplementary($metafields: [MetafieldsSetInput!]!) {
  metafieldsSet(metafields: $metafields) {
    metafields { key value }
    userErrors { field message code }
  }
}
{
  "metafields": [
    {
      "ownerId": "gid://shopify/Product/1001",
      "namespace": "shopify--discovery--product_recommendation",
      "key": "complementary_products",
      "type": "list.product_reference",
      "value": "[\"gid://shopify/Product/2002\",\"gid://shopify/Product/2003\"]"
    }
  ]
}

Shopify’s help page offers bulk editing through these metafields as the alternative to clicking through the app, so they are the same data, and merchandisers can carry on in the Search & Discovery interface once the import is done.

What we would build

On a theme, keep the stock recommendations section, switch it to lazy loading if it is not already, and add a second instance with intent=complementary that renders nothing when empty. On Hydrogen or another headless front end, call productRecommendations from the server, cap the list yourself, filter on availableForSale, and stream it.

On a migrated store or a non-English one, do not trust the automatic results for the first few months. Seed complementary_products from the old platform’s cross-sells and set related_products_display to ahead on your best sellers, so the hand-picked list shows first while the engine collects real orders.

Skip all of this if the catalogue is a few dozen products. A hand-curated “You might also like” metafield on each one is less code and gives the merchant full control.

We build and migrate Shopify stores, themes and Hydrogen storefronts, and carrying merchandising data like this across a replatform is part of that work. Our Shopify development page has the details.

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