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.
| Endpoint | Method | Purpose |
|---|---|---|
/recommendations/products | GET | Fetch recommendation offers as JSON |
/recommendations/products.json | GET | Same 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();| Parameter | Required | Notes |
|---|---|---|
product_id | For product_page | The anchor product id (productId is accepted as an alias) |
variant_id | For product_page | The selected variant id (variantId alias). Required so variant-aware bundle matching is deterministic |
cart_id | For cart placements | The cart id (cartId alias) |
placement | No | product_page, cart_page, cart_drawer, or generic. Inferred when omitted |
intent | No | auto (default), upsell, or cross_sell |
limit | No | Number of offers to return. Defaults to 4, clamped to 1–20 |
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_id
→ product_page, a cart_id → cart_page, otherwise generic.
Each placement has strict id requirements, so the engine never mixes signals:
product_page— requires bothproduct_idandvariant_id. Sending avariant_idwithout itsproduct_idis rejected.cart_page/cart_drawer— requirecart_idand rejectproduct_id/variant_id.generic— must not includeproduct_id,variant_id, orcart_id.
Intents
intent biases what the engine returns:
| Intent | Use it for |
|---|---|
auto | Let the engine choose the best mix for the placement (default) |
upsell | Higher-value or “complete your purchase” add-ons |
cross_sell | Complementary 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:
| Field | Meaning |
|---|---|
requestId | An id for this recommendation request — log it with click/add events for attribution |
currencyCode | The store currency (for example INR); money inside offers is in paise |
offers | The recommended offers array, ordered by rank |
Each entry in offers describes one recommendation:
| Field | Meaning |
|---|---|
key | Stable de-duplication key for the offer |
kind | single_product, product_bundle, upsell_rule, or bundle_rule |
source | Why it was picked — e.g. bought_together, same_collection, best_selling, manual_cross_sell, bundle_rule |
title | Display title for the offer |
productId / variantId | Present for simple product offers |
score / rank | Relevance score and 1-based display order |
reason | Short human-readable reason, when the engine supplies one |
upsellRule | Present when kind is upsell_rule — the rule, its pricing, and its items |
bundleRule | Present 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.
-
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> -
Fetch offers for the current product and variant. Keep
limitto 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 errorconst { offers } = await res.json();if (!offers || offers.length === 0) {mountEl.hidden = true; // nothing to recommend → hide itreturn;}render(offers, mountEl);} -
Render the offers and wire add-to-cart through the Cart AJAX API. Use each offer’s
variantIdas the cartid: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('');} -
Re-fetch when the shopper switches variant so cross-sells stay relevant — listen for your theme’s variant-change event and call
loadRecommendationsagain with the newvariant_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 %}{% assign recs = recommendations | recommendations_for_cart: cart, intent: 'cross_sell' %}{% assign drawer = recommendations | recommendations_for_cart_drawer: cart %}{% assign recs = recommendations | recommendations_generic: limit: 8 %}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:
field | When |
|---|---|
intent | A value other than auto, upsell, cross_sell |
placement | A value other than the four supported placements |
productId | product_page without a product id, or a variant_id sent without its product_id |
variantId | product_page without a variant id |
cartId | A cart placement without a cart_id |
productId|variantId | Product/variant ids sent on a cart placement |
productId|variantId|cartId | Any 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
- The
recommendationsandrecommended_offerLiquid objects — the server-side equivalent of this endpoint, with money already formatted. - Cart AJAX API — add a recommended variant to the cart, and render sections inline.
- Bundles and offers — configure the
upsell and bundle rules that surface as
upsell_ruleandbundle_ruleoffers. - Predictive Search API for type-ahead search suggestions from your theme.