Skip to content

Product Recommendations API

Themes fetch “you may also like”, “frequently bought together”, and bundle offers from a same-origin JSON endpoint. It powers the recommendation sections on product and cart pages and is the JavaScript counterpart to the recommendations Liquid object — both call the same engine, so server-rendered and lazily-fetched recommendations agree.

EndpointMethodPurpose
/recommendations/productsGETFetch recommendation offers as JSON
/recommendations/products.jsonGETSame handler, .json suffix

Always build the URL from {{ routes.product_recommendations_url }} rather than hard-coding the path, so it stays correct under a locale or sub-path prefix. The default value is /recommendations/products.

Query parameters

const url = `${window.routes.product_recommendations_url}.json`
+ `?product_id=${productId}&variant_id=${variantId}&intent=cross_sell&limit=6`;
const data = await (await fetch(url, { headers: { Accept: 'application/json' } })).json();
ParameterRequiredNotes
product_idFor product_pageThe anchor product id (productId is accepted as an alias)
variant_idFor product_pageThe selected variant id (variantId alias). Required so variant-aware bundle matching is deterministic
cart_idFor cart placementsThe cart id (cartId alias)
placementNoproduct_page, cart_page, cart_drawer, or generic. Inferred when omitted
intentNoauto (default), upsell, or cross_sell
limitNoNumber of offers to return. Defaults to 4, clamped to 120

Placements

placement selects the context the engine recommends for. If you omit it, the endpoint infers one from the ids you send: a product_id and variant_idproduct_page, a cart_idcart_page, otherwise generic.

Each placement has strict id requirements, so the engine never mixes signals:

  • product_page — requires both product_id and variant_id. Sending a variant_id without its product_id is rejected.
  • cart_page / cart_drawer — require cart_id and reject product_id / variant_id.
  • generic — must not include product_id, variant_id, or cart_id.

Intents

intent biases what the engine returns:

IntentUse it for
autoLet the engine choose the best mix for the placement (default)
upsellHigher-value or “complete your purchase” add-ons
cross_sellComplementary products and “frequently bought together”

The platform does not use Shopify’s related / complementary values — pass one of auto, upsell, or cross_sell. Any other value returns a validation error (see Errors).

Response

A successful request returns 200 with Cache-Control: no-store and this shape:

{
"requestId": "8f3c…",
"currencyCode": "INR",
"offers": [
{
"key": "single_product:prod_123:var_456",
"kind": "single_product",
"source": "bought_together",
"title": "Stainless Steel Bottle",
"productId": "prod_123",
"variantId": "var_456",
"score": 0.82,
"rank": 1,
"reason": "Frequently bought together"
}
]
}

Top-level fields:

FieldMeaning
requestIdAn id for this recommendation request — log it with click/add events for attribution
currencyCodeThe store currency (for example INR); money inside offers is in paise
offersThe recommended offers array, ordered by rank

Each entry in offers describes one recommendation:

FieldMeaning
keyStable de-duplication key for the offer
kindsingle_product, product_bundle, upsell_rule, or bundle_rule
sourceWhy it was picked — e.g. bought_together, same_collection, best_selling, manual_cross_sell, bundle_rule
titleDisplay title for the offer
productId / variantIdPresent for simple product offers
score / rankRelevance score and 1-based display order
reasonShort human-readable reason, when the engine supplies one
upsellRulePresent when kind is upsell_rule — the rule, its pricing, and its items
bundleRulePresent when kind is bundle_rule — the bundle, its pricing, and its sections of choices

Empty results render nothing

When the engine has no offers for the context, it returns 200 with "offers": [] — not an error. Your section should detect the empty array and render nothing (hide the whole block) rather than show an empty “Recommended” heading. The same rule applies server-side: call one of the filters below, then guard with {% unless recs.empty %} on the result so the section disappears when there is nothing to show.

Load recommendations into a PDP section

A common pattern is to render an empty placeholder section server-side, then fetch and hydrate it on the client — on page load, or after the shopper changes the variant or adds to cart.

  1. Expose the route to your script. The product and variant ids are already in the section’s markup; add the endpoint:

    <script>
    window.routes = window.routes || {};
    window.routes.product_recommendations_url = "{{ routes.product_recommendations_url }}";
    </script>
  2. Fetch offers for the current product and variant. Keep limit to what the layout can show:

    async function loadRecommendations(productId, variantId, mountEl) {
    const url = `${window.routes.product_recommendations_url}.json`
    + `?product_id=${encodeURIComponent(productId)}`
    + `&variant_id=${encodeURIComponent(variantId)}`
    + `&intent=cross_sell&limit=4`;
    const res = await fetch(url, { headers: { Accept: 'application/json' } });
    if (!res.ok) return; // leave the section empty on error
    const { offers } = await res.json();
    if (!offers || offers.length === 0) {
    mountEl.hidden = true; // nothing to recommend → hide it
    return;
    }
    render(offers, mountEl);
    }
  3. Render the offers and wire add-to-cart through the Cart AJAX API. Use each offer’s variantId as the cart id:

    function render(offers, mountEl) {
    mountEl.hidden = false;
    mountEl.innerHTML = offers
    .filter((o) => o.kind === 'single_product' && o.variantId)
    .map((o) => `
    <article class="rec-card">
    <h3>${o.title ?? ''}</h3>
    <button data-add="${o.variantId}">Add</button>
    </article>`)
    .join('');
    }
  4. Re-fetch when the shopper switches variant so cross-sells stay relevant — listen for your theme’s variant-change event and call loadRecommendations again with the new variant_id.

Using it from Liquid

If you render recommendations server-side, you don’t call the endpoint at all — use the global recommendations object with one of its filters. Each takes an optional { limit, intent } options hash:

{% assign recs = recommendations | recommendations_for_product: product, product.selected_or_first_available_variant, limit: 6, intent: 'cross_sell' %}
{% unless recs.empty %}
<ul class="recommendations" data-request-id="{{ recs.request_id }}">
{% for offer in recs.offers %}
<li>{{ offer.title }}{{ offer.display_reason }}</li>
{% endfor %}
</ul>
{% endunless %}
{% assign recs = recommendations
| recommendations_for_product: product, variant, limit: 4 %}

Each offer exposes kind, source, title, display_reason, product_id, variant_id, and — for rule offers — upsell_rule and bundle_rule. Inspect the live shape in any template with {{ recommendations | json }}.

How offers are generated

The endpoint is a thin client over the platform’s recommendation engine, which blends several signals — purchase co-occurrence (bought_together, cart_together), same-collection products, best sellers, and the seller’s own bundles and upsell offers. The source on each offer tells you which signal produced it, and intent shifts the weighting toward upsells or cross-sells. You don’t configure the algorithm from the theme — you ask for a placement and an intent, and decide how to render what comes back. If the store has no qualifying data for the context, the engine simply returns an empty list.

Errors

Invalid input returns HTTP 400 with a structured body — the fields member is an array of { field, rule, message } objects:

{
"error": "Validation failed",
"fields": [
{
"field": "intent",
"rule": "oneof",
"message": "Invalid recommendation intent: \"related\". Expected one of: auto, upsell, cross_sell"
}
]
}

Common causes:

fieldWhen
intentA value other than auto, upsell, cross_sell
placementA value other than the four supported placements
productIdproduct_page without a product id, or a variant_id sent without its product_id
variantIdproduct_page without a variant id
cartIdA cart placement without a cart_id
productId|variantIdProduct/variant ids sent on a cart placement
productId|variantId|cartIdAny id sent on a generic request

A missing or empty result is not an error — that’s 200 with offers: []. Treat a 400 as a bug in your request, not a “no recommendations” state.

Next steps