Part of our guide to Shopify Custom Sections: The Complete Guide.
An upsell section on the product page shows shoppers a relevant add-on or a higher-tier option before they check out. On Shopify you can build one three ways: from Shopify's built-in product recommendations, from a hand-picked list of products you choose per product, or from a dedicated upsell app's block. This guide gives real section code for the first two and explains when the app route is the right call.
This is part of the Shopify custom sections guide.
If building a .liquid section is new to you, read
How to create a custom section
first.
Recommended vs. hand-picked upsells
Two honest strategies, and they suit different stores:
- Automatic recommendations — Shopify's Product Recommendations API returns related products based on your catalog and order history. Zero curation, and it scales to every product, but you do not control which items show.
- Hand-picked upsells — you choose the exact complementary products per item
(a case for a phone, a filter for a jug). More work, but the pairing is always
intentional. A
product_listsetting or a product-reference metafield holds the picks.
Pick automatic when you have a large catalog and want zero maintenance; pick hand-picked when the right add-on is obvious and specific per product.
Option 1 — Automatic recommendations
Shopify exposes recommendations through the recommendations object, populated via
its Product Recommendations API. The reliable pattern is a section that renders the
recommendations for the current product. Create
sections/product-upsell.liquid:
<section class="upsell">
<h2 class="upsell__heading">{{ section.settings.heading }}</h2>
{% assign recs = recommendations.products %}
{% if recs.size > 0 %}
<ul class="upsell__grid">
{% for product in recs limit: section.settings.limit %}
<li class="upsell__card">
<a href="{{ product.url }}">
{% if product.featured_image %}
<img
src="{{ product.featured_image | image_url: width: 300 }}"
alt="{{ product.featured_image.alt | escape }}"
loading="lazy"
width="300"
>
{% endif %}
<span class="upsell__title">{{ product.title }}</span>
<span class="upsell__price">{{ product.price | money }}</span>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% schema %}
{
"name": "Product upsell",
"tag": "section",
"settings": [
{ "type": "text", "id": "heading", "label": "Heading", "default": "You may also like" },
{ "type": "range", "id": "limit", "label": "Products to show", "min": 2, "max": 8, "step": 1, "default": 4 }
],
"presets": [{ "name": "Product upsell" }]
}
{% endschema %}
Two details that matter:
- The
recommendations.productsobject is only populated when the section is requested with aproduct_idandintent— themes load this through the Product Recommendations API endpoint, usually via the theme's own recommendations section markup. If you are adding this to a Dawn-based theme, model it on the theme's existingproduct-recommendations.liquidso the API request fires correctly. image_url: width: 300andloading="lazy"keep the upsell images light so they do not slow the product page.
Option 2 — Hand-picked upsells
For deliberate pairings, add a product_list setting so a merchant selects the
exact products to show. Swap the recommendations loop for the chosen list:
<section class="upsell">
<h2 class="upsell__heading">{{ section.settings.heading }}</h2>
<ul class="upsell__grid">
{% for product in section.settings.products %}
<li class="upsell__card">
<a href="{{ product.url }}">
<span class="upsell__title">{{ product.title }}</span>
<span class="upsell__price">{{ product.price | money }}</span>
</a>
</li>
{% endfor %}
</ul>
</section>
{% schema %}
{
"name": "Hand-picked upsell",
"tag": "section",
"settings": [
{ "type": "text", "id": "heading", "label": "Heading", "default": "Pairs well with" },
{ "type": "product_list", "id": "products", "label": "Upsell products", "limit": 6 }
],
"presets": [{ "name": "Hand-picked upsell" }]
}
{% endschema %}
The product_list setting gives the merchant a product picker in the editor and
returns real product objects you can loop over — no metafield needed. If you would
rather store the picks per product (so they travel with the product across
templates), use a list.product_reference metafield and read
product.metafields.custom.upsells.value instead.
Option 3 — Use an upsell app's block
If you want cart-triggered offers, post-purchase upsells, or A/B-tested placements, a dedicated upsell app is the practical route — those flows need logic Liquid alone cannot run. The app provides an app block you drop into the product template from the editor's Apps group. Because it renders from the app rather than your theme files, it also survives theme updates, for the reasons covered in Shopify Theme App Extensions.
Style the grid
A simple responsive grid keeps the cards tidy on any width:
{% stylesheet %}
.upsell { padding: 2rem 1.5rem; }
.upsell__grid {
list-style: none; margin: 0; padding: 0;
display: grid; gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
}
.upsell__card a { display: block; text-decoration: none; }
.upsell__title { display: block; margin-top: 0.5rem; font-weight: 600; }
.upsell__price { display: block; opacity: 0.75; }
{% endstylesheet %}
Keep the upsell section update-safe
Options 1 and 2 write a .liquid section into your theme. That file belongs to the
current theme version, so a theme update or theme switch publishes a fresh copy of
the theme's files and your product-upsell.liquid is not carried over — the
upsell block, and any products a merchant hand-picked in it, can disappear. The full
explanation is in
Update your theme without losing customizations.
An upsell block usually earns its place on every product page, which makes it a strong candidate to render from an app so no update can remove it. That is what SectionGuard does — your section renders from the app, with the same product pickers and in-editor settings, surviving every theme update. See how it compares to file-based sections on the comparison page.