Shopify Builds>Nudestix>Cart 'Add Ons' cross-sell driven by the Product Recommendations API

Cart 'Add Ons' Cross-Sell Driven by the Product Recommendations API

The cart drawer's "Add Ons" row asks Shopify's recommendations endpoint for products that complement the first paid line in the cart, falls back to related products, filters out what the shopper already has, and survives the drawer being thrown away and re-rendered on every cart change.

Shopify's Product Recommendations API takes one product id, so a cart drawer has to decide which line speaks for the cart. Nudestix's "Add Ons" row anchors on the first paid, non-gift line, asks the endpoint for complementary products, falls back to related, drops anything already in the cart, and hides itself when nothing survives — while a promise cache keyed by anchor outlives every drawer the cart re-renders.

The Problem

Shopify's Product Recommendations API is per product. recommendations/products.json wants a product_id, and it answers for that one product: with intent=related, products the algorithm associates with it; with intent=complementary, the products a merchandiser has paired with it in the Search & Discovery app. There is no per-cart call. A drawer that wants to recommend against a cart has to pick one line to ask about, and live with the answer being about that line.

Nudestix's drawer had sidestepped the question by not asking it. The "Add Ons" row was three products chosen in theme settings — the same three for every shopper, whatever was in the cart, and stale the moment one sold out or was retired. The brief was a row that changed with the cart and stayed in the merchandiser's hands without living in theme settings.

The Constraint

The endpoint's answer doesn't know what's in the cart. It can return the product the shopper just added, and it can return products that exist only to be given away — gift-with-purchase lines, samples, the shipping-protection SKU — which the drawer must never sell.

The drawer itself is disposable. On every mutation — a quantity change, an add, a remove — the theme's cart module posts to the cart, asks the Section Rendering API for the drawer section, and replaces .drawer__inner wholesale. Anything rendered into the drawer by script is gone with it. A recommendation row that refetched on every rebuild would fire a request per keystroke of a quantity field.

And the anchor has to be chosen with care. A cart holding only a free gift has no paid intent to recommend against, and a paid line that happens to be a sample isn't one either.

What We Built

The Liquid half lives in cart-drawer.liquid. It walks cart.items once, doing two things: it accumulates every product id in the cart into a comma-separated list, and it picks the anchor — the first line whose final_line_price is above zero and whose product isn't a shipping-protection, gift-with-purchase or sample line. If no line qualifies, nothing renders. If one does, the snippet emits a [data-cart-cross-sell] container carrying data-product-id for the anchor, data-cart-product-ids for the whole cart and data-limit="4", with an "Add Ons" heading and an empty [data-cart-cross-sell-items] slot.

The script half is loadCartCrossSells() on the theme's CartItems element in cart.js. It runs when the element is constructed and again from bindEvent() after every section refresh, so a freshly rendered container is always picked up. For each container it reads the three attributes, marks the element with dataset.loadedFor so the same container is never processed twice for the same anchor, and calls fetchCartRecommendations(anchor, limit, 'complementary').

SERVER · LIQUID, EVERY DRAWER RENDERBROWSER · cart.js cart.items first paid, non-gift line = anchor [data-cart-cross-sell] data-product-id data-cart-product-ids · data-limit Section Rendering API .drawer__inner replaced wholesale loadCartCrossSells() on construct · after every refresh recommendations/products.json intent=complementary empty → intent=related Filter, then render ≤ 4 cards drop cart ids and gifting tags · else hide row Add → /cart/add.js then refreshCartSections() Same anchor? same anchor · same promise new anchor · new entry cartRecommendationCache Map · product:limit:intent writes container reads attributes fetches for anchor products[] binds .cart-cross-sell-atc re-renders drawer fresh container, anchor recomputed memoised promise
The drawer is disposable and the recommendation set is not. Liquid recomputes the anchor on every render and writes it onto a fresh container; the script keeps each lookup as a promise in a cache keyed by product, limit and intent, so a rebuilt drawer whose anchor has not changed renders from the promise the cache already holds.

fetchCartRecommendations builds the request with URLSearchParamsproduct_id, limit and intent — and prefixes it with window.Shopify.routes.root, so it holds under a market or locale path. The call is GET {root}recommendations/products.json?product_id=<anchor>&limit=4&intent=complementary. A non-OK response is treated as { products: [] } rather than an error. If the complementary call returns an empty array, the same call is made with intent=related. Each promise is memoised in window.cartRecommendationCache, a Map keyed product:limit:intent; a promise that rejects is deleted from the map so a later attempt can retry.

What comes back is filtered against the id set built from data-cart-product-ids and against the gifting tags the storefront already uses, cut to the limit, and rendered as up to four cards: image, title as a link, price through Shopify.formatMoney, and an add button carrying the first available variant's id in data-var. bindCartRecommendationButtons attaches the click handler once per button, marking each with dataset.bound. A click posts { id, quantity: 1 } to /cart/add.js and then calls refreshCartSections() — the same method every other drawer mutation in this module uses — which fetches /cart?sections=… for the drawer and the header bubble and swaps the HTML in.

That swap destroys the row. But the new drawer HTML carries a new container, Liquid has already recomputed the anchor, bindEvent() calls loadCartCrossSells() again, and if the anchor is unchanged the container renders from the promise the cache already holds. If nothing survives filtering, or the fetch rejects, the container is hidden outright — hidden = true on the wrapper, heading included.

Why This Way

intent=complementary is the reason to use Shopify's endpoint rather than a curated theme setting. Complementary products are configured per product in the Search & Discovery app, which is where the rest of the catalog's relationships already live, so the merchandiser curates cross-sells in one place and the drawer reads them. intent=related is the degradation path: a product nobody has curated still gets a row, from the algorithm.

Caching by anchor is what makes a client-rendered row viable inside a drawer that is rebuilt on every change. The cache lives on window, not on the container, so it outlives every drawer the Section Rendering API throws away: the row that comes back after a quantity edit is a new element reading a promise that was resolved before the edit. What is keyed is the question — product, limit and intent — rather than the drawer that asked it.

Reusing refreshCartSections() for the add keeps one cart pipeline. A recommendation card adds exactly the way a free-sample button or a gift-wrap toggle adds in the same module, and the drawer, the bubble and the gift-quantity enforcement all update through the path they already share.

What we accepted: the row is script-rendered. It isn't in the drawer's server HTML, and it appears a beat after the drawer does. One product speaks for the whole cart, so a mixed cart is represented by whichever paid line comes first — and because Liquid recomputes the anchor on every render, adding a recommended product can change which product anchors the next set. And a product that nobody curated in Search & Discovery gets algorithmic related products with no signal to anyone that the fallback fired.

Why Not an App

Cart cross-sell apps are a real category, and the better ones do things this row doesn't: rules across the whole cart rather than one anchor, bundle-aware suggestions, testing, reporting. They typically price per order and bring their own cart-mutation layer, which then has to be kept in step with the theme's.

The need here was narrower. Nudestix already had the theme's cart module, its own section-refresh path and a first-party endpoint that answers "what goes with this?" The row is built on that endpoint and the refresh path the drawer already used, with no vendor script on the page. The trade is that the drawer owns the failure states an app would have owned — empty results, a rejected fetch, a sold-out suggestion — and this page lists what it does with each.

Implementation Notes

  • Anchor selection requires item.final_line_price > 0 before any exclusion is checked, so a cart of only free gifts never anchors a recommendation set — and because the container is emitted only when an anchor exists, such a cart shows no "Add Ons" row at all.
  • data-cart-product-ids carries every product id in the cart, including lines that could never be the anchor, so the filter compares recommendations against the whole cart and not just against paid lines.
  • The request is exactly recommendations/products.json?product_id=<anchor>&limit=4&intent=complementary, prefixed with window.Shopify.routes.root; the fallback repeats it with intent=related and nothing else changed.
  • window.cartRecommendationCache is a Map of promises, not of results, keyed product:limit:intent. Two containers asking for the same anchor read the same promise, and a rejected promise is removed from the map so a later render can try again.
  • dataset.loadedFor stores the anchor id on the container. loadCartCrossSells() runs on element construction and from bindEvent() after every section refresh, and the guard makes the second run over an unchanged container a no-op.
  • The add posts /cart/add.js with { id, quantity: 1 } and then refreshCartSections(), which requests /cart?sections=cart-drawer,cart-icon-bubble and replaces .drawer__inner — the path CartDrawerItems in cart-drawer.js defines for every drawer mutation.
  • Each card's button is bound once, marked dataset.bound="true", so a re-bind pass after a refresh can't attach a second handler to a button that survived.

Edge Cases

  • intent=complementary returning an empty array falls through to intent=related before anything renders, so an uncurated anchor product gets an algorithmic row rather than an empty one.
  • Both intents returning nothing hides the whole container, heading included, rather than leaving "Add Ons" above an empty slot.
  • A rejected fetch hides the container the same way, and deletes the cache entry so a later render can try again.
  • A non-OK HTTP response is read as an empty product list, not thrown, which is what lets the complementary call fall through to related instead of failing the row.
  • Products already in the cart are removed by comparing ids against the set Liquid wrote, before the limit is applied, so a filtered-out product doesn't consume one of the four slots.
  • A sold-out recommendation renders from its first variant with the button disabled and labeled "Soldout", so the shopper sees the product but can't post an add that would be refused.
  • The add button disables itself and shows its spinner the moment it's clicked, and is re-enabled only if the post fails, so a double-tap can't queue two adds of the same product.

Platform Primitives Used

  • Product Recommendations APIrecommendations/products.json with product_id, limit and intent; complementary first, related as the fallback.
  • Cart AJAX API — a card's add posts /cart/add.js; the drawer's own mutations post through the same module.
  • Section Rendering API/cart?sections=… re-renders the drawer and the header bubble after every add, which is the event the row has to survive.

Where It Runs

The cart, in both of its renderings: cart-drawer.liquid emits the container in the drawer, and the cart page's own section emits the same container with the same three attributes. There is no template list because the trigger is the cart — a paid, non-gift line — and the script runs wherever the CartItems element is constructed or refreshed.

What This Demonstrates

  • Product recommendations and cross-sell — the primary capability: a recommendations-API rail with cart-aware filtering and an intent fallback, as the computed counterpart to a curated product-list rail.
  • In-cart upsell and add-ons — the conversion job: a suggestion the shopper can add without leaving the drawer, through the drawer's own refresh path.

How We Know

Three theme files read for the feature — the drawer snippet and the cart page's section, which each pick the anchor and emit the container, and the cart script that fetches, filters, renders and binds, roughly 220 lines of new code between them — plus two entries in the client's task register recording the mini-cart overhaul this row was part of. The register establishes that the row was requested work; the code establishes what it does. The exclusion of gift and sample lines from the anchor is a reading of the Liquid, and the tags it checks are the ones the storefront's gifting logic uses elsewhere in the same drawer.

Related Builds

The Buy-vs-Build Question

A cross-sell app buys logic across the whole cart and reporting on what it did. This row buys neither; it asks a first-party endpoint one question about one product and renders the answer through the drawer the theme already had. Whether one anchor is enough is the actual decision, and it's the one we set out in Product recommendations: buy or build?

Provenance & Evidence

  • Client: Nudestix — color cosmetics, nudestix.com
  • Surface: Cart — the drawer and the cart page
  • Templates served: none listed; the row follows the drawer wherever a page mounts it
  • Complexity: Medium — roughly 220 lines across the snippet and the script
  • Attribution: Deploi-authored. The anchor selection, the container, loadCartCrossSells() and the methods it calls are new code we wrote. They live inside the theme's cart.js, which descends from Dawn's cart script on a Dawn 6.0.2 base; the surrounding CartItems class and its section-refresh path are Dawn's with local changes, and we don't claim them.
  • Status: Live, verified 2026-09-07
  • Evidence: Three theme files, two client task-register entries
  • Confidence: Strong — code plus client records
  • Primary capability: Product recommendations and cross-sell

Ready for a Cart That Suggests Something Relevant?

If your drawer shows every shopper the same three products, the fix is one endpoint you already pay for and a row that knows how to survive its own drawer. Contact us today and we'll wire complementary recommendations into the cart you have.

More builds