Shopify cart upsell
without an app.
Most cart upsell apps charge $15–30 per month to show one extra product next to the checkout button. The logic is simple: read a metafield on next to the checkout button. The logic is simple: read a metafield on the cart item, render the recommended product, and handle the add with a single fetch call. This fix does exactly that with a Liquid snippet, no app, no monthly fee, and no third-party script.
Preview the cart recommendation.
Use the preview to see how the metafield-based upsell sits above the checkout action. The free version shows one recommendation from the first eligible cart item.
Before you start
This fix uses a Product reference metafield to link each product to its recommended upsell. You need to create that metafield definition once before installing the snippet. It takes about two minutes.
- 01Create the metafield definition.
In Shopify Admin, go to Settings → Custom data → Products. Click Add definition. Set the name to anything you like (e.g. “Upsell product”), the namespace to
custom, the key toupsell_product, and the content type to Product reference (under the “Reference” category). Save. - 02Set the upsell product on each product.
Open any product in Shopify Admin. Scroll down to the Metafields section and find the Upsell product field. Click it and select the product you want to recommend when this product is in the cart. Save.
Repeat for every product you want to trigger an upsell. Products without this field set will not show a recommendation.
The code
One file. No configuration needed beyond the metafield setup above.
{% comment %}
Jelonyx · Cart Upsell : no app required
Compatible with Dawn 12+, Horizon, and most Online Store 2.0 themes
Source: jelonyx.com/shopify/no-app/cart-upsell
{% endcomment %}
{%- comment -%}
Prerequisite: a Product reference metafield must exist at custom.upsell_product.
Go to Settings → Custom data → Products and add it before rendering this snippet.
Then open any product in Admin, scroll to Metafields, and set the Upsell product
field to the product you want to recommend when that item is in the cart.
{%- endcomment -%}
{%- assign jlx_upsell_product = nil -%}
{%- for item in cart.items -%}
{%- if item.product.metafields.custom.upsell_product != blank -%}
{%- assign jlx_upsell_product = item.product.metafields.custom.upsell_product.value -%}
{%- break -%}
{%- endif -%}
{%- endfor -%}
{%- if jlx_upsell_product != nil -%}
{%- assign jlx_already_in_cart = false -%}
{%- for item in cart.items -%}
{%- if item.product.id == jlx_upsell_product.id -%}
{%- assign jlx_already_in_cart = true -%}
{%- break -%}
{%- endif -%}
{%- endfor -%}
{%- unless jlx_already_in_cart -%}
{%- assign jlx_upsell_variant = jlx_upsell_product.first_available_variant -%}
{%- if jlx_upsell_variant != nil -%}
<div class="jlx-upsell" id="jlx-upsell">
<p class="jlx-upsell__label">You might also like</p>
<div class="jlx-upsell__card">
{%- if jlx_upsell_product.featured_image -%}
<img
class="jlx-upsell__img"
src="{{ jlx_upsell_product.featured_image | image_url: width: 160 }}"
alt="{{ jlx_upsell_product.featured_image.alt | escape }}"
width="80"
height="80"
loading="lazy"
>
{%- endif -%}
<div class="jlx-upsell__info">
<div class="jlx-upsell__title">{{ jlx_upsell_product.title }}</div>
<div class="jlx-upsell__price">{{ jlx_upsell_variant.price | money }}</div>
</div>
<button
class="jlx-upsell__btn"
data-jlx-variant="{{ jlx_upsell_variant.id }}"
type="button"
aria-label="Add {{ jlx_upsell_product.title }} to cart"
>Add</button>
</div>
</div>
<style>
.jlx-upsell {
margin: 16px 0;
padding: 14px 16px;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: var(--inputs-radius, 4px);
font-family: inherit;
}
.jlx-upsell__label {
margin: 0 0 10px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-foreground, #1a1a1a);
opacity: 0.45;
}
.jlx-upsell__card {
display: flex;
align-items: center;
gap: 12px;
}
.jlx-upsell__img {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: calc(var(--inputs-radius, 4px) - 2px);
flex-shrink: 0;
background: var(--color-background-2, #f4f4f4);
}
.jlx-upsell__info {
flex: 1;
min-width: 0;
}
.jlx-upsell__title {
font-size: 13px;
font-weight: 500;
color: var(--color-foreground, #1a1a1a);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jlx-upsell__price {
margin-top: 2px;
font-size: 13px;
color: var(--color-foreground, #1a1a1a);
opacity: 0.65;
}
.jlx-upsell__btn {
flex-shrink: 0;
padding: 8px 14px;
background: var(--color-button, #1a1a1a);
color: var(--color-button-text, #fff);
border: 1px solid transparent;
border-radius: var(--buttons-radius, 4px);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s ease;
}
.jlx-upsell__btn:hover:not(:disabled) { opacity: 0.8; }
.jlx-upsell__btn:disabled {
opacity: 0.5;
cursor: default;
}
.jlx-upsell__btn--done {
background: transparent;
color: var(--color-foreground, #1a1a1a);
border-color: var(--color-border, rgba(0, 0, 0, 0.15));
}
</style>
<script>
(function () {
'use strict';
var wrap = document.getElementById('jlx-upsell');
if (!wrap) return;
var btn = wrap.querySelector('[data-jlx-variant]');
if (!btn) return;
btn.addEventListener('click', function () {
var variantId = parseInt(btn.getAttribute('data-jlx-variant'), 10);
btn.disabled = true;
btn.textContent = 'Adding…';
fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ items: [{ id: variantId, quantity: 1 }] })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.status) {
btn.textContent = 'Unavailable';
btn.disabled = false;
return;
}
btn.textContent = 'Added ✓';
btn.classList.add('jlx-upsell__btn--done');
// Update the cart item count in the header
fetch('/cart.js', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (cart) {
document.querySelectorAll('[data-cart-count]').forEach(function (el) {
el.textContent = cart.item_count;
});
})
.catch(function () {});
// Fade out and remove the widget, then reload if on the cart page
setTimeout(function () {
wrap.style.transition = 'opacity 0.3s ease';
wrap.style.opacity = '0';
setTimeout(function () {
wrap.remove();
if (window.location.pathname === '/cart') window.location.reload();
}, 300);
}, 1200);
})
.catch(function () {
btn.textContent = 'Error';
btn.disabled = false;
});
});
})();
</script>
{%- endif -%}
{%- endunless -%}
{%- endif -%}How to install
- 01Open your theme code editor.
In Shopify Admin, go to Online Store → Themes. On your active theme, click the three-dot menu and select Edit code.
- 02Create a new snippet.
Under the Snippets folder, click Add a new snippet. Name it
jlx-cart-upselland click Done. - 03Paste the code and save.
Delete any placeholder content in the new file and paste the entire code block above. Save the file.
- 04Add the render tag to the cart drawer.
In the code editor, open
sections/cart-drawer.liquid. Search fordrawer__footer. Add the following tag at the top of that div, before the subtotal and checkout rows:{% render 'jlx-cart-upsell' %}To also show it on the cart page, open
sections/main-cart-footer.liquidand add the same render tag before the subtotal row. Save both files. - 05Test in the cart.
Add a product that has the
upsell_productmetafield set to your cart. Open the cart drawer. The upsell widget should appear above the subtotal. Click Add and confirm the recommended item appears in the cart and the widget fades out.Add the upsell product itself to the cart directly and recheck drawer. The widget should be hidden because the recommended product is already in the cart.
How it works
On every cart render, the snippet loops through cart.items and checks each item for a custom.upsell_product metafield. It stops at the first match. If no item has the metafield set, nothing is rendered.
Once a recommended product is found, the snippet checks whether it is already in the cart by comparing product IDs. If it is, the widget is suppressed. There is no point recommending something the customer already has. If the recommended product has no available variant (sold out), the widget is also suppressed.
The Add button calls POST /cart/add.js (Shopify's AJAX Cart API) with the first available variant ID. On success, it updates the cart count displayed in the header by fetching /cart.js and reading item_count. The widget then fades out and is removed from the DOM. If the customer is on the /cart page rather than the drawer, the page reloads after the fade to show the updated cart.
The Liquid runs server-side on every cart render, so there is no client-side product fetch and no flash of wrong content. The JavaScript only handles the add interaction.
Customisation
Change the label. The widget defaults to “You might also like”. Edit the text inside the jlx-upsell__label <p> element in the Liquid.
Show a compare-at price. To display the original price alongside the sale price, replace the price div with:
<div class="jlx-upsell__price">
{{ jlx_upsell_variant.price | money }}
{%- if jlx_upsell_variant.compare_at_price > jlx_upsell_variant.price -%}
<s style="opacity:0.4;margin-left:6px;">{{ jlx_upsell_variant.compare_at_price | money }}</s>
{%- endif -%}
</div>Use a different metafield key. If you named your metafield key something other than upsell_product, update the two references to item.product.metafields.custom.upsell_product in the Liquid to match your key.
Show the upsell from the most expensive cart item. The snippet picks the first matching cart item. To prioritise the most expensive item instead, sort cart.items by price before the loop: {%- assign sorted_items = cart.items | sort: 'final_price' | reverse -%} then iterate sorted_items in place of cart.items.
Compatibility
Tested against Dawn 12+ and Horizon. The widget reads CSS custom properties like --color-foreground, --color-border, --color-button, --color-button-text, --inputs-radius, and --buttons-radius. Colours and border radii inherit from your theme without any CSS edits.
The product.metafields.custom.upsell_product.value pattern requires that the metafield type is set to Product reference (not a plain text field). If the metafield type is wrong, Liquid will return nil and nothing will render. Check the type in Settings → Custom data → Products.
The cart count update targets elements with a data-cart-count attribute. Dawn's header cart icon uses a different selector. If the count selector. If the count does not update after adding, it is cosmetic. The cart itself is correct. The count will sync on the next full page load.
Limitations
- One recommendation per cart: the snippet finds the first cart item with an upsell metafield set and stops. If multiple items have upsell products defined, only the first match is shown. This is intentional. Showing multiple upsell widgets at once increases visual noise without improving conversion.
- Single-variant upsells only: the snippet adds the product's first available variant directly. If the recommended product has multiple variants (sizes, colours), the customer cannot choose one. The cheapest or first available variant is added. For multi-variant products, a more complex implementation with a variant selector is needed.
- Cart drawer does not re-render fully: after adding the upsell, the widget fades out rather than triggering a full drawer refresh. The cart item list will not update in real time in the drawer. The customer needs to close and reopen it (or refresh) to see the new item. On the cart page, the page reloads automatically.
- Metafield must be set per product: there is no automated way to bulk-assign upsell products. Each product requires manual setup in Shopify Admin. For stores with large catalogues, this is time-consuming without a bulk edit tool.
- Headless storefronts: this is a Liquid snippet and does not apply to Hydrogen or other headless setups.
Need this installed?
If you would rather not edit theme code, we can install and style the cart upsell for your store. We will match it to your theme, set up metafields for your top products, and test it against your live cart drawer.