Shopify Checkout Upsell Extension: applyCartLinesChange

A Shopify checkout upsell extension is a checkout UI extension that reads the cart through shopify.lines, fetches an offer with shopify.query against the Storefront API, and adds it with shopify.applyCartLinesChange({ type: "addCartLine", merchandiseId, quantity }). It needs the api_access capability, a purchase.checkout.* target and, for the information, shipping and payment steps, a Shopify Plus store.

Shopify’s pre-purchase product offer tutorial shows that call working against the first five products in the catalogue. What it leaves out is the part that matters on a live store: letting the merchant choose the product, checking whether this checkout will accept a new line before you draw a button for it, and dealing with the error object when it will not.

Everything below is written against checkout UI extensions API 2026-07, which is what shopify.dev served as the latest reference when I checked on 19 October 2026. The @shopify/ui-extensions package tagged 2026-10 (2026.10.0) was published on 14 October, but its reference docs were still at the 2026-10-rc URL, so I would wait a week or two before pinning it.

Which target and capabilities does the extension need?

Use purchase.checkout.block.render. It is a block target, so the merchant positions it in the checkout editor rather than you choosing a fixed slot, and Shopify’s targets list notes that block extensions always render even when the section they were placed in is hidden. For an upsell that is what you want. Our earlier piece on where checkout UI extensions can render covers the static alternatives if the offer has to sit against the order summary.

api_version = "2026-07"

[[extensions]]
name = "checkout-offer"
handle = "checkout-offer"
type = "ui_extension"

[[extensions.targeting]]
module = "./src/Checkout.jsx"
target = "purchase.checkout.block.render"

[extensions.capabilities]
api_access = true

[extensions.settings]
[[extensions.settings.fields]]
key = "offer_handle"
type = "single_line_text_field"
name = "Product handle to offer"
description = "The handle of the product shown as an add-on, e.g. gift-wrap."

[[extensions.settings.fields]]
key = "heading"
type = "single_line_text_field"
name = "Heading"
[[extensions.settings.fields.validations]]
name = "max"
value = "40"

api_access is what turns on shopify.query. The capabilities guide lists the seven unauthenticated Storefront scopes it grants: product and collection publications, product and collection listings, product tags, selling plans and metaobjects. Shopify handles the access token, so there is none in your bundle. You only need network_access if the offer logic lives on your own server, and that capability needs a Partner Dashboard request and Access-Control-Allow-Origin: * on your endpoint before you can publish.

The settings block is the part the tutorial skips. The reference allows up to twenty fields per extension, typed as boolean, single_line_text_field, multi_line_text_field, number_integer, number_decimal, date, date_time or variant_reference, and every one of them is optional from the merchant’s side. Settings are read-only in the extension and undefined until the merchant fills them in, so the code has to survive an empty value on day one.

I have used a product handle rather than variant_reference. The reference lists that type but, as of this writing, does not document the shape of the value your extension receives, and a handle gives you a Storefront query you can test with curl.

How do you build a Shopify checkout upsell extension?

Here is the whole extension, in Preact, which is the default for API 2025-10 and later. The React hooks package is off the main path; the extension APIs hang off a global shopify object and the components are s- prefixed web components.

import "@shopify/ui-extensions/preact";
import { render } from "preact";
import { useEffect, useState } from "preact/hooks";

export default function () {
  render(<Extension />, document.body);
}

const OFFER_QUERY = `query Offer($handle: String!) {
  product(handle: $handle) {
    title
    variants(first: 1) {
      nodes {
        id
        availableForSale
        price { amount currencyCode }
        image { url altText }
      }
    }
  }
}`;

function Extension() {
  const { applyCartLinesChange, query, i18n } = shopify;
  const { offer_handle: handle, heading } = shopify.settings.value;
  const canAdd = shopify.instructions.value.lines.canAddCartLine;

  const [product, setProduct] = useState(null);
  const [adding, setAdding] = useState(false);
  const [failed, setFailed] = useState(false);

  useEffect(() => {
    if (!handle || !canAdd) return;
    query(OFFER_QUERY, { variables: { handle } })
      .then(({ data, errors }) => {
        if (errors) console.error(errors);
        setProduct(data?.product ?? null);
      })
      .catch((error) => console.error(error));
  }, [handle, canAdd]);

  if (!handle || !canAdd || !product) return null;

  const variant = product.variants.nodes[0];
  if (!variant?.availableForSale) return null;

  const inCart = shopify.lines.value.some(
    (line) => line.merchandise.id === variant.id
  );
  if (inCart) return null;

  async function addToCart() {
    setAdding(true);
    const result = await applyCartLinesChange({
      type: "addCartLine",
      merchandiseId: variant.id,
      quantity: 1,
    });
    setAdding(false);
    if (result.type === "error") {
      console.error(result.message);
      setFailed(true);
    }
  }

  return (
    <s-stack gap="base">
      <s-heading>{heading ?? "Add this to your order"}</s-heading>
      <s-grid gap="base" gridTemplateColumns="64px 1fr auto" alignItems="center">
        <s-image
          src={variant.image?.url}
          alt={variant.image?.altText ?? product.title}
          aspectRatio="1"
          borderRadius="large-100"
        />
        <s-stack gap="none">
          <s-text type="strong">{product.title}</s-text>
          <s-text color="subdued">
            {i18n.formatCurrency(variant.price.amount, {
              currency: variant.price.currencyCode,
            })}
          </s-text>
        </s-stack>
        <s-button
          variant="secondary"
          loading={adding}
          accessibilityLabel={`Add ${product.title} to your order`}
          onClick={addToCart}
        >
          Add
        </s-button>
      </s-grid>
      {failed && (
        <s-banner tone="critical">
          We could not add that item. Please try again.
        </s-banner>
      )}
    </s-stack>
  );
}

merchandiseId must be a variant GID, gid://shopify/ProductVariant/..., which is why the query asks for variants(first: 1).nodes.id rather than the product’s own id. The Cart Lines API reference also accepts attributes, sellingPlanId and a parent of { lineId } or { merchandiseId } on an addCartLine change, so the same call can attach the add-on to a specific line or sell it on a subscription.

The duplicate check compares against line.merchandise.id, not line.id. A cart line id has the form gid://shopify/CartLine/... and the reference says plainly that it is not stable and can change after any cart operation. Read it fresh immediately before a removeCartLine or updateCartLine, and never store it.

applyCartLinesChange takes one change per call. If the offer is a two-item bundle, that is two awaited calls, and you decide what happens when the second fails.

availableForSale on the variant avoids rendering a button that is going to fail. The tutorial lets the add fail and then shows a banner, which is fine for a demo and poor on a live checkout.

What should the extension do when the checkout refuses the change?

There are two refusals to handle, and they arrive differently.

The first is a checkout that does not allow new lines at all. The Cart Instructions API exposes shopify.instructions.value.lines.canAddCartLine, and the reference gives draft orders as a case where it is false. Shopify’s guidance is to hide the feature when the flag is off rather than render a disabled control the buyer cannot explain, which is what the early return null does above. The flags can change between checkout steps, so an extension that lives on more than one step should subscribe rather than read once.

The second is a call that was permitted and still failed. applyCartLinesChange resolves, never throws, to either { type: "success" } or { type: "error", message }. The reference is specific about that message: it is for debugging, it is not localised, and it should not be shown to the buyer. Log it and render your own copy. The same page warns that canAddCartLine being true does not guarantee success during accelerated checkout, meaning Apple Pay and Google Pay, so the error branch stays even after you have checked the instruction.

A third limit produces no error object at all. The checkout UI extensions overview states that once an extension is rate limited it cannot make further changes during the buyer’s session. An effect without a dependency array, or a button that fires applyCartLinesChange on every render, can spend that budget before the buyer has read the page. The loading state on s-button disables it while a call is in flight, which covers the double click.

How do you test an upsell in checkout locally?

shopify app dev serves the extension against your development store and opens a dev console. The tutorial names one trap: if the console prints ShopifyCLI:AdminAPI requires the property token to be set, pass a cart explicitly with shopify app dev --checkout-cart-url cart/{variant_id}:{quantity}. Restart app dev after any edit to shopify.extension.toml, because settings and capabilities are read at start-up.

To see the merchant’s view, open the checkout editor on the dev store, place the block and fill in the two settings. Until you do, shopify.settings.value.offer_handle is undefined and the extension renders nothing. That is correct, and it means a blank preview is not necessarily a bug.

The Storefront query above validates against the 2026-07 schema. If you would rather Shopify chose the offer than the merchant, swap product(handle:) for productRecommendations(productId:) keyed on the first cart line’s product. The Storefront side of that is in our product recommendations API post, and it runs under the same api_access scopes.

When is an extension the wrong tool for this?

An extension can add a line at the buyer’s request. It cannot change a price, bundle lines together or discount the add-on. If the offer is “add gift wrap for half price”, the extension adds the line and a cart transform or discount Function does the pricing, because Functions run on Shopify’s servers and cannot be skipped by a buyer who drives the Storefront API directly. The same split applies to anything you would rather not trust to the browser.

If the store is not on Plus, the information, shipping and payment steps are closed to you. The Thank you page is open to every plan, but applyCartLinesChange is only available on purchase.checkout targets, so an upsell there is a link back to the store rather than an in-place add.

My recommendation: ship the merchant-configured single-product version first, with the instruction check and the availableForSale guard, and measure attach rate for a fortnight before adding recommendation logic or a bundle. Most of the lift comes from the offer being relevant and the button working every time. If the brief needs a price change or a rule that must hold, start with the Function and let the extension stay dumb.

Whoooop builds checkout UI extensions, Shopify Functions and the apps that carry them, for stores on Plus and for merchants deciding whether the upgrade is worth it for this kind of work. If you have a checkout customisation that checkout.liquid used to handle and are not sure which surface it belongs on now, our Shopify development team can map it out.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch