Table of Contents
- Introduction
- What We'll Build
- How Cart Upsell Widgets Work
- Setting Up the Theme App Extension
- Fetching Cart Data and Recommended Products
- Building the Liquid Template
- Styling the Upsell Widget
- Adding Products to the Cart from the Widget
- Complete File
- Tips and Common Pitfalls
- Conclusion
- References
Introduction
The cart page is one of the highest-intent moments in the entire shopping journey — the customer has already decided to buy something. It's also one of the most underused spots for merchandising. A well-placed cart upsell widget that recommends relevant products based on what's already in the cart can meaningfully lift average order value (AOV) without adding any friction to checkout.
Unlike product-page recommendations, which Shopify exposes directly through the recommendations Liquid object, there's no built-in Liquid object for "recommend something based on my cart contents." The cart template doesn't have access to product recommendation data at render time, because the cart's contents are dynamic and change without a full page reload (adding, removing, or updating quantities all happen via Ajax in modern themes).
That means a cart upsell widget has to be built client-side: read the current cart with the Cart Ajax API, ask Shopify's Product Recommendations API for related products, filter out anything already in the cart, and render the result. In this tutorial, I'll walk through building exactly that as a Theme App Extension, with full support for adding a recommended product straight from the widget.
What We'll Build
The cart upsell widget will:
-
Read the current cart contents via
/cart.jswhenever it loads or the cart changes - Fetch product recommendations from Shopify's Product Recommendations API, based on the most recently added item
- Exclude products already in the cart so we never recommend something the customer already has
- Render product cards with image, title, price, and an "Add" button
-
Add the recommended product to the cart via
/cart/add.jswithout a page reload - Automatically refresh after an item is added, so the widget stays in sync with the cart
- Be fully configurable from the theme editor (heading text, number of products, colors)
- Work on both the cart page and cart drawer, since both are just sections in Online Store 2.0 themes
How Cart Upsell Widgets Work
Before writing code, it's worth understanding the two Shopify APIs this widget leans on, and why we need both.
Product Recommendations API
Shopify exposes a public JSON endpoint at /recommendations/products.json that returns products related to a given product_id. This is the same recommendation engine that powers the recommendations Liquid object on product pages — the difference is that this JSON endpoint can be called from anywhere, including the cart page, via a simple fetch() call. It accepts an intent parameter (related or complementary) and a limit parameter for how many products to return.
The catch: it only accepts a single product_id. It has no concept of "recommend based on these five cart items." So we need to pick one product from the cart to use as the reference — in this tutorial, we'll use the most recently added item.
Cart Ajax API
To know what's in the cart (and to add new items to it), we use Shopify's Cart Ajax API:
-
GET /cart.js— returns the current cart as JSON, including every line item'sproduct_id,variant_id, andquantity -
POST /cart/add.js— adds a variant to the cart and returns the added line item as JSON
Both are same-origin endpoints on the storefront domain, so no CORS configuration or authentication is needed — a plain fetch() works.
Putting It Together
The flow for our widget looks like this:
- On load, fetch
/cart.jsto see what's in the cart - If the cart is empty, hide the widget entirely
- Take the most recently added item's
product_idas the reference product - Call
/recommendations/products.json?product_id=<id>to get related products - Filter out any recommended product that's already a line item in the cart
- Render the remaining products as cards with an "Add" button
- When "Add" is clicked, POST to
/cart/add.js, then re-run the whole flow so the widget updates itself
Setting Up the Theme App Extension
Prerequisites
Make sure you have:
- A Shopify Partner account
- A development store
- Shopify CLI installed
- An existing Shopify app (or create one with
shopify app init)
Generate the Extension
Inside your app directory, run:
shopify app generate extension --template theme_app_extension --name cart-upsell
This creates the following structure:
extensions/
cart-upsell/
blocks/
assets/
locales/
snippets/
We'll create two files:
-
blocks/cart-upsell.liquid— the app block with markup, JavaScript, and schema -
assets/cart-upsell.css— styles for the widget
Because this block targets the cart template, merchants will be able to add it from the theme editor to any section on the cart page that supports app blocks (in Dawn-based themes, that's typically main-cart-items or the cart drawer section).
Fetching Cart Data and Recommended Products
Let's build the data-fetching logic first, since it's the core of the widget.
Reading the Cart
function fetchCart() {
return fetch('/cart.js').then((res) => res.json());
}
/cart.js returns an object with an items array. Each item has product_id, variant_id, quantity, and pricing fields (in cents). We only need product_id here — first to pick a reference product, and second to build a list of products to exclude from the recommendations.
Picking a Reference Product
function getReferenceProductId(cart) {
if (!cart.items.length) return null;
return cart.items[cart.items.length - 1].product_id;
}
Shopify appends newly added variants to the end of the items array (as long as it's a genuinely new line item rather than a quantity bump on an existing one), so the last item is a reasonable proxy for "what did the customer just add." It's not perfect — but for a recommendation seed, "the most recent addition" is a solid heuristic that requires no extra state tracking.
Fetching Recommendations
function fetchRecommendations(productId, count) {
const url = `/recommendations/products.json?product_id=${productId}&limit=${count}&intent=related`;
return fetch(url).then((res) => res.json());
}
We request more products than we actually plan to display (count = limit + number of items already in cart), because some of the returned recommendations will likely already be in the cart and need to be filtered out. Requesting extra up front avoids a second round-trip if the first few results get filtered away.
Filtering and Limiting
function getUpsellProducts(cart, recommendationsData, limit) {
const excludeIds = cart.items.map((item) => item.product_id);
return (recommendationsData.products || [])
.filter((product) => product.available)
.filter((product) => !excludeIds.includes(product.id))
.slice(0, limit);
}
Two filters matter here: product.available drops out-of-stock products (recommending something the customer can't buy is worse than recommending nothing), and the excludeIds check drops anything already sitting in the cart.
Building the Liquid Template
Create the app block file at extensions/cart-upsell/blocks/cart-upsell.liquid.
The Liquid side of this widget is intentionally minimal — almost all of the logic runs client-side in JavaScript, since the cart's contents aren't known until the browser loads the page. Liquid's job here is just to render the container, pass merchant settings through as data attributes and CSS custom properties, and output the shop's money format so our JavaScript can display prices correctly.
{{ 'cart-upsell.css' | asset_url | stylesheet_tag }}
<div
id="cart-upsell"
class="cart-upsell"
data-limit="{{ block.settings.product_limit }}"
data-money-format="{{ shop.money_format | escape }}"
style="
--cart-upsell-bg: {{ block.settings.background_color }};
--cart-upsell-text: {{ block.settings.text_color }};
--cart-upsell-btn-bg: {{ block.settings.button_color }};
--cart-upsell-btn-text: {{ block.settings.button_text_color }};
"
hidden
>
<h3 class="cart-upsell__heading">{{ block.settings.heading }}</h3>
<div class="cart-upsell__list" id="cart-upsell-list"></div>
</div>
A few things worth calling out:
-
hiddenby default — the widget starts hidden and JavaScript reveals it only once it has products to show. This avoids a flash of an empty heading before the cart data loads. -
data-money-format— we passshop.money_formatthrough as a data attribute rather than hardcoding a currency symbol, so prices render correctly regardless of the store's currency and formatting conventions. - CSS custom properties — merchant-configurable colors are passed as inline custom properties, keeping all visual styling in the CSS file rather than generating Liquid-conditional CSS.
Schema
{% schema %}
{
"name": "Cart Upsell",
"target": "section",
"settings": [
{
"type": "text",
"id": "heading",
"label": "Heading",
"default": "You might also like"
},
{
"type": "range",
"id": "product_limit",
"label": "Number of products to show",
"min": 2,
"max": 6,
"step": 1,
"default": 4
},
{
"type": "color",
"id": "background_color",
"label": "Background color",
"default": "#f7f7f7"
},
{
"type": "color",
"id": "text_color",
"label": "Text color",
"default": "#333333"
},
{
"type": "color",
"id": "button_color",
"label": "Button color",
"default": "#333333"
},
{
"type": "color",
"id": "button_text_color",
"label": "Button text color",
"default": "#ffffff"
}
],
"templates": ["cart"]
}
{% endschema %}
The "templates": ["cart"] entry is what makes this block selectable from sections on the cart template in the theme editor — without it, the block would be available everywhere, which doesn't make sense for a widget that depends on cart state.
Styling the Upsell Widget
Create the stylesheet at extensions/cart-upsell/assets/cart-upsell.css.
The widget renders as a responsive grid of product cards. On wider screens it shows multiple columns; on mobile it collapses to a horizontally scrollable row, which is a common pattern for secondary merchandising so it doesn't push the actual cart contents and checkout button too far down the page.
.cart-upsell {
background-color: var(--cart-upsell-bg, #f7f7f7);
color: var(--cart-upsell-text, #333333);
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.cart-upsell__heading {
margin: 0 0 14px;
font-size: 16px;
font-weight: 700;
color: var(--cart-upsell-text, #333333);
}
.cart-upsell__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 14px;
}
.cart-upsell__card {
display: flex;
flex-direction: column;
background-color: #ffffff;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.cart-upsell__image-link {
display: block;
aspect-ratio: 1 / 1;
background-color: #f0f0f0;
}
.cart-upsell__image-link img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cart-upsell__info {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
}
.cart-upsell__title {
font-size: 13px;
font-weight: 600;
color: var(--cart-upsell-text, #333333);
text-decoration: none;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cart-upsell__title:hover {
text-decoration: underline;
}
.cart-upsell__price {
font-size: 13px;
font-weight: 700;
}
.cart-upsell__add-btn {
margin-top: auto;
padding: 8px 10px;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
background-color: var(--cart-upsell-btn-bg, #333333);
color: var(--cart-upsell-btn-text, #ffffff);
transition: opacity 0.2s ease;
}
.cart-upsell__add-btn:hover {
opacity: 0.85;
}
.cart-upsell__add-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Mobile: horizontally scrollable row instead of a grid */
@media (max-width: 600px) {
.cart-upsell__list {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: 10px;
padding-bottom: 4px;
}
.cart-upsell__card {
flex: 0 0 120px;
scroll-snap-align: start;
}
}
A couple of details worth explaining:
-
aspect-ratio: 1 / 1on the image link reserves space for the image before it loads, preventing layout shift as recommendations render asynchronously. -
-webkit-line-clamp: 2truncates long product titles to two lines instead of letting them overflow the card or push the price and button down inconsistently between cards. -
margin-top: autoon the button pins it to the bottom of the card even when titles wrap to different heights, keeping all "Add" buttons aligned in a row.
Adding Products to the Cart from the Widget
Now let's wire up the rendering and add-to-cart logic. Add this <script> block to cart-upsell.liquid, after the container <div>:
<script>
(function () {
const widget = document.getElementById('cart-upsell');
if (!widget) return;
const list = document.getElementById('cart-upsell-list');
const limit = parseInt(widget.dataset.limit, 10) || 4;
const moneyFormat = widget.dataset.moneyFormat || '${{amount}}';
function formatMoney(cents, format) {
const placeholderRegex = /\{\{\s*(\w+)\s*\}\}/;
const match = format.match(placeholderRegex);
if (!match) return '$' + (cents / 100).toFixed(2);
function withDelimiters(number, precision, thousands, decimalSep) {
const fixed = (number / 100).toFixed(precision);
const parts = fixed.split('.');
const dollars = parts[0].replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1' + thousands);
const cents = parts[1] ? decimalSep + parts[1] : '';
return dollars + cents;
}
let value;
switch (match[1]) {
case 'amount_no_decimals':
value = withDelimiters(cents, 0, ',', '.');
break;
case 'amount_with_comma_separator':
value = withDelimiters(cents, 2, '.', ',');
break;
case 'amount_no_decimals_with_comma_separator':
value = withDelimiters(cents, 0, '.', ',');
break;
default:
value = withDelimiters(cents, 2, ',', '.');
}
return format.replace(placeholderRegex, value);
}
function thumbnail(src) {
if (!src) return null;
return src + (src.includes('?') ? '&' : '?') + 'width=200';
}
function fetchCart() {
return fetch('/cart.js').then((res) => res.json());
}
function fetchRecommendations(productId, count) {
const url = `/recommendations/products.json?product_id=${productId}&limit=${count}&intent=related`;
return fetch(url).then((res) => res.json());
}
function getReferenceProductId(cart) {
if (!cart.items.length) return null;
return cart.items[cart.items.length - 1].product_id;
}
function buildCard(product) {
const variant = product.variants.find((v) => v.available) || product.variants[0];
const priceCents = Math.round(parseFloat(variant.price) * 100);
const card = document.createElement('div');
card.className = 'cart-upsell__card';
const imageLink = document.createElement('a');
imageLink.href = product.url;
imageLink.className = 'cart-upsell__image-link';
const thumbSrc = product.images && product.images[0] ? thumbnail(product.images[0].src) : null;
if (thumbSrc) {
const img = document.createElement('img');
img.src = thumbSrc;
img.alt = product.title;
img.loading = 'lazy';
img.width = 140;
img.height = 140;
imageLink.appendChild(img);
}
const info = document.createElement('div');
info.className = 'cart-upsell__info';
const title = document.createElement('a');
title.href = product.url;
title.className = 'cart-upsell__title';
title.textContent = product.title;
const price = document.createElement('div');
price.className = 'cart-upsell__price';
price.textContent = formatMoney(priceCents, moneyFormat);
const button = document.createElement('button');
button.type = 'button';
button.className = 'cart-upsell__add-btn';
button.textContent = 'Add';
button.disabled = !variant.available;
button.addEventListener('click', function () {
addToCart(variant.id, button);
});
info.appendChild(title);
info.appendChild(price);
info.appendChild(button);
card.appendChild(imageLink);
card.appendChild(info);
return card;
}
function renderProducts(products) {
list.innerHTML = '';
if (!products.length) {
widget.hidden = true;
return;
}
products.forEach(function (product) {
list.appendChild(buildCard(product));
});
widget.hidden = false;
}
function addToCart(variantId, button) {
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Adding...';
fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: [{ id: variantId, quantity: 1 }] }),
})
.then(function (response) {
if (!response.ok) throw new Error('Add to cart failed');
return response.json();
})
.then(function () {
button.textContent = 'Added!';
document.dispatchEvent(new CustomEvent('cart:updated', { bubbles: true }));
init();
})
.catch(function (error) {
console.error('Cart upsell error:', error);
button.disabled = false;
button.textContent = originalText;
});
}
function init() {
fetchCart().then(function (cart) {
const referenceId = getReferenceProductId(cart);
if (!referenceId) {
widget.hidden = true;
return;
}
const excludeIds = cart.items.map(function (item) {
return item.product_id;
});
const fetchCount = limit + excludeIds.length;
fetchRecommendations(referenceId, fetchCount).then(function (data) {
const products = (data.products || [])
.filter(function (product) {
return product.available;
})
.filter(function (product) {
return excludeIds.indexOf(product.id) === -1;
})
.slice(0, limit);
renderProducts(products);
});
});
}
document.addEventListener('cart:updated', init);
document.addEventListener('cart:refresh', init);
window.CartUpsell = { refresh: init };
init();
})();
</script>
Walking through the key parts:
formatMoney() — a small, self-contained implementation of Shopify's standard money formatting logic. It reads the {{amount}}-style placeholder from shop.money_format and applies the right decimal and thousands separators. We need our own copy because recommendation data isn't run through Liquid's money filter — it arrives as raw JSON — and not every theme exposes Shopify.formatMoney globally.
thumbnail() — appends a width query parameter to the Shopify CDN image URL. Shopify's CDN resizes images on the fly based on this parameter, so we avoid downloading full-resolution product images just to show a 140px thumbnail.
addToCart() — posts the selected variant to /cart/add.js, then dispatches a cart:updated custom event (the same convention used by many themes, including Dawn, to refresh cart drawers and cart icon counts) and calls init() again so the widget re-fetches the cart and recommendations — the product that was just added disappears from the list automatically because it's now in excludeIds.
Event listeners on cart:updated / cart:refresh — these let the widget refresh itself when the cart changes from outside the widget too, e.g. when the customer updates a quantity or removes a line item elsewhere on the cart page. Since not every theme dispatches these events consistently, we also expose window.CartUpsell.refresh() as an escape hatch — see the Tips section below.
Complete File
Here's the complete cart-upsell.liquid file — everything in one place so you can copy it directly into your project:
extensions/cart-upsell/blocks/cart-upsell.liquid
{{ 'cart-upsell.css' | asset_url | stylesheet_tag }}
<div
id="cart-upsell"
class="cart-upsell"
data-limit="{{ block.settings.product_limit }}"
data-money-format="{{ shop.money_format | escape }}"
style="
--cart-upsell-bg: {{ block.settings.background_color }};
--cart-upsell-text: {{ block.settings.text_color }};
--cart-upsell-btn-bg: {{ block.settings.button_color }};
--cart-upsell-btn-text: {{ block.settings.button_text_color }};
"
hidden
>
<h3 class="cart-upsell__heading">{{ block.settings.heading }}</h3>
<div class="cart-upsell__list" id="cart-upsell-list"></div>
</div>
<script>
(function () {
const widget = document.getElementById('cart-upsell');
if (!widget) return;
const list = document.getElementById('cart-upsell-list');
const limit = parseInt(widget.dataset.limit, 10) || 4;
const moneyFormat = widget.dataset.moneyFormat || '${{amount}}';
function formatMoney(cents, format) {
const placeholderRegex = /\{\{\s*(\w+)\s*\}\}/;
const match = format.match(placeholderRegex);
if (!match) return '$' + (cents / 100).toFixed(2);
function withDelimiters(number, precision, thousands, decimalSep) {
const fixed = (number / 100).toFixed(precision);
const parts = fixed.split('.');
const dollars = parts[0].replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1' + thousands);
const cents = parts[1] ? decimalSep + parts[1] : '';
return dollars + cents;
}
let value;
switch (match[1]) {
case 'amount_no_decimals':
value = withDelimiters(cents, 0, ',', '.');
break;
case 'amount_with_comma_separator':
value = withDelimiters(cents, 2, '.', ',');
break;
case 'amount_no_decimals_with_comma_separator':
value = withDelimiters(cents, 0, '.', ',');
break;
default:
value = withDelimiters(cents, 2, ',', '.');
}
return format.replace(placeholderRegex, value);
}
function thumbnail(src) {
if (!src) return null;
return src + (src.includes('?') ? '&' : '?') + 'width=200';
}
function fetchCart() {
return fetch('/cart.js').then((res) => res.json());
}
function fetchRecommendations(productId, count) {
const url = `/recommendations/products.json?product_id=${productId}&limit=${count}&intent=related`;
return fetch(url).then((res) => res.json());
}
function getReferenceProductId(cart) {
if (!cart.items.length) return null;
return cart.items[cart.items.length - 1].product_id;
}
function buildCard(product) {
const variant = product.variants.find((v) => v.available) || product.variants[0];
const priceCents = Math.round(parseFloat(variant.price) * 100);
const card = document.createElement('div');
card.className = 'cart-upsell__card';
const imageLink = document.createElement('a');
imageLink.href = product.url;
imageLink.className = 'cart-upsell__image-link';
const thumbSrc = product.images && product.images[0] ? thumbnail(product.images[0].src) : null;
if (thumbSrc) {
const img = document.createElement('img');
img.src = thumbSrc;
img.alt = product.title;
img.loading = 'lazy';
img.width = 140;
img.height = 140;
imageLink.appendChild(img);
}
const info = document.createElement('div');
info.className = 'cart-upsell__info';
const title = document.createElement('a');
title.href = product.url;
title.className = 'cart-upsell__title';
title.textContent = product.title;
const price = document.createElement('div');
price.className = 'cart-upsell__price';
price.textContent = formatMoney(priceCents, moneyFormat);
const button = document.createElement('button');
button.type = 'button';
button.className = 'cart-upsell__add-btn';
button.textContent = 'Add';
button.disabled = !variant.available;
button.addEventListener('click', function () {
addToCart(variant.id, button);
});
info.appendChild(title);
info.appendChild(price);
info.appendChild(button);
card.appendChild(imageLink);
card.appendChild(info);
return card;
}
function renderProducts(products) {
list.innerHTML = '';
if (!products.length) {
widget.hidden = true;
return;
}
products.forEach(function (product) {
list.appendChild(buildCard(product));
});
widget.hidden = false;
}
function addToCart(variantId, button) {
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Adding...';
fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: [{ id: variantId, quantity: 1 }] }),
})
.then(function (response) {
if (!response.ok) throw new Error('Add to cart failed');
return response.json();
})
.then(function () {
button.textContent = 'Added!';
document.dispatchEvent(new CustomEvent('cart:updated', { bubbles: true }));
init();
})
.catch(function (error) {
console.error('Cart upsell error:', error);
button.disabled = false;
button.textContent = originalText;
});
}
function init() {
fetchCart().then(function (cart) {
const referenceId = getReferenceProductId(cart);
if (!referenceId) {
widget.hidden = true;
return;
}
const excludeIds = cart.items.map(function (item) {
return item.product_id;
});
const fetchCount = limit + excludeIds.length;
fetchRecommendations(referenceId, fetchCount).then(function (data) {
const products = (data.products || [])
.filter(function (product) {
return product.available;
})
.filter(function (product) {
return excludeIds.indexOf(product.id) === -1;
})
.slice(0, limit);
renderProducts(products);
});
});
}
document.addEventListener('cart:updated', init);
document.addEventListener('cart:refresh', init);
window.CartUpsell = { refresh: init };
init();
})();
</script>
{% schema %}
{
"name": "Cart Upsell",
"target": "section",
"settings": [
{
"type": "text",
"id": "heading",
"label": "Heading",
"default": "You might also like"
},
{
"type": "range",
"id": "product_limit",
"label": "Number of products to show",
"min": 2,
"max": 6,
"step": 1,
"default": 4
},
{
"type": "color",
"id": "background_color",
"label": "Background color",
"default": "#f7f7f7"
},
{
"type": "color",
"id": "text_color",
"label": "Text color",
"default": "#333333"
},
{
"type": "color",
"id": "button_color",
"label": "Button color",
"default": "#333333"
},
{
"type": "color",
"id": "button_text_color",
"label": "Button text color",
"default": "#ffffff"
}
],
"templates": ["cart"]
}
{% endschema %}
extensions/cart-upsell/assets/cart-upsell.css
.cart-upsell {
background-color: var(--cart-upsell-bg, #f7f7f7);
color: var(--cart-upsell-text, #333333);
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.cart-upsell__heading {
margin: 0 0 14px;
font-size: 16px;
font-weight: 700;
color: var(--cart-upsell-text, #333333);
}
.cart-upsell__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 14px;
}
.cart-upsell__card {
display: flex;
flex-direction: column;
background-color: #ffffff;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.cart-upsell__image-link {
display: block;
aspect-ratio: 1 / 1;
background-color: #f0f0f0;
}
.cart-upsell__image-link img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cart-upsell__info {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
}
.cart-upsell__title {
font-size: 13px;
font-weight: 600;
color: var(--cart-upsell-text, #333333);
text-decoration: none;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cart-upsell__title:hover {
text-decoration: underline;
}
.cart-upsell__price {
font-size: 13px;
font-weight: 700;
}
.cart-upsell__add-btn {
margin-top: auto;
padding: 8px 10px;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
background-color: var(--cart-upsell-btn-bg, #333333);
color: var(--cart-upsell-btn-text, #ffffff);
transition: opacity 0.2s ease;
}
.cart-upsell__add-btn:hover {
opacity: 0.85;
}
.cart-upsell__add-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 600px) {
.cart-upsell__list {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: 10px;
padding-bottom: 4px;
}
.cart-upsell__card {
flex: 0 0 120px;
scroll-snap-align: start;
}
}
Tips and Common Pitfalls
Recommendations API Prices Are Not in Cents
This one trips up a lot of people the first time: /cart.js returns prices in cents (e.g., 1999 for $19.99), but /recommendations/products.json returns variant prices as decimal strings (e.g., "19.99"), matching the format of the classic /products.json endpoint. Mixing these up will make your prices look 100x too small or force awkward manual conversions. Our buildCard() function explicitly converts with Math.round(parseFloat(variant.price) * 100) before formatting — don't skip that step.
Empty Recommendations Are Normal
If a store hasn't been live long enough to generate purchase-pattern data, or if a product doesn't have manually curated recommendations set up in the Shopify admin, the recommendations endpoint can return an empty products array. Always design for this — our widget hides itself with widget.hidden = true rather than showing an empty "You might also like" heading with nothing under it.
intent=complementary Needs Configuration
We used intent=related, which works out of the box using Shopify's automatic recommendation engine. The intent=complementary option (frequently-bought-together style suggestions) relies on recommendation data that's typically configured through the Search & Discovery app or generated from historical order data. If you switch to complementary and get empty results on a fresh dev store, that's expected — it needs real sales data or manual configuration to populate.
Not All Themes Dispatch the Same Cart Events
We listen for cart:updated and cart:refresh to know when to refresh the widget, which covers Dawn and many Dawn-based themes. But cart event naming isn't standardized across all Shopify themes — some use ajaxCart:updated, others don't dispatch any custom event at all when quantities change inline. If your widget isn't refreshing after a quantity change elsewhere on the cart page, inspect the theme's cart scripts for its actual event name, or add a listener for it. The window.CartUpsell.refresh() escape hatch we exposed is there specifically so you can wire up theme-specific events without modifying the extension code.
Debounce If You Add Quantity-Watching
If you extend this widget to also refresh when quantity <input> fields change on the cart page (rather than only after full cart mutations), debounce the fetch calls. Quantity steppers fire input events on every click, and calling /cart.js plus the recommendations endpoint on every keystroke or click is wasteful and can cause the widget to flicker as responses race each other.
Cart Drawer vs. Cart Page Placement
Because this is a Theme App Extension app block, the same file works in both the cart page and a cart drawer, provided the theme's drawer markup is built from sections that accept app blocks (true for Dawn and most modern Online Store 2.0 themes). Test in both contexts — drawers are narrower, so the mobile horizontal-scroll CSS we wrote often ends up being the primary layout even on desktop drawers, not just phone screens.
Conclusion
We've built a cart upsell widget that reads live cart data, asks Shopify's Product Recommendations API for relevant products, filters out what's already in the cart, and lets customers add a recommendation without leaving the cart page. The whole thing runs on two public, unauthenticated Shopify endpoints — no backend, no app proxy, no extra API credentials — which makes it a lightweight addition to any Theme App Extension.
The trickiest parts are the ones that aren't obvious from the API docs alone: the cents-vs-decimal price mismatch between /cart.js and the recommendations endpoint, the inconsistency in cart event names across themes, and designing gracefully for the case where there's simply nothing to recommend. Handle those three things well, and the rest of the implementation is straightforward fetch-and-render.
Top comments (0)