How to Add an Upsell Section to the Product Page in Shopify

Add an upsell or cross-sell section to a Shopify product page — from product-recommendations, hand-picked products, or an app block — with real section code.

5 min readUpdated

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.

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_list setting 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.products object is only populated when the section is requested with a product_id and intent — 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 existing product-recommendations.liquid so the API request fires correctly.
  • image_url: width: 300 and loading="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.