Compiling ideas into code…
Wiring up APIs and UI…
Sit back for a moment while the site gets ready.
ShopifyCommerce

Advanced ShopifyDiscount Solutions

When Native Discounts Aren't Enough

Shopify's built-in discount engine handles the basics well, percentage off, fixed amount, buy-X-get-Y, and free shipping. But when a merchant says "I want to add a free gift to the cart when the customer spends over $60, and it needs to work across all our currencies, and if they apply a coupon that drops the total below $60, the gift should go away", native discounts can't touch that.

The problem isn't just one missing feature. It's that each requirement sits in a different layer of the checkout pipeline. Adding a product to the cart requires cart modification. Making it free requires a discount engine. Handling currency-aware thresholds adds yet another layer. No single native tool covers all three.

This is exactly where Shopify Functions and Checkout UI Extensions come in. Functions run as WebAssembly modules directly inside the checkout pipeline with sub-millisecond overhead. Checkout UI Extensions let you add interactive React components into the checkout flow, including the ability to programmatically add or remove cart lines. Together, they form the modern toolkit for building custom commerce logic that goes far beyond native discounts.

In this article, we'll build a real-world "free gift with purchase" experience from scratch using a Checkout UI Extension and a Discount Function working together. Along the way, you'll learn why the seemingly obvious Cart Transform approach won't work, how to scaffold and deploy each component, and how to handle the edge cases that trip up most implementations.


The End of Shopify Scripts, And the Gap It Creates

If you've been in the Shopify ecosystem for a while, you might be thinking: "Didn't Shopify Scripts handle this?" You're right, they did. Shopify Scripts allowed merchants (on Plus plans) to write Ruby-based logic that could modify cart contents, apply discounts, and alter shipping rates, all server-side and fully automatic.

But Shopify is sunsetting Scripts. The replacement is Shopify Functions, purpose-built, sandboxed WebAssembly modules that run inside Shopify's infrastructure. Functions are faster, more scalable, and available to all app developers, not just Plus merchants.

Here's the catch: Functions cannot add or remove cart lines. Cart Transform Functions can expand, merge, or update existing lines (designed for bundles), and Discount Functions can apply price reductions. But neither can inject a new product into the cart the way Scripts could. This is arguably the biggest limitation in the Functions model today, and it's the reason we need a Checkout UI Extension to fill the gap.

The modern Shopify architecture for promotions: Checkout UI Extensions handle what Scripts did for cart modification (adding/removing items), while Discount Functions handle what Scripts did for pricing. This two-component pattern replaces the single Script Tags approach.


Why Cart Transform Won't Work for Promotions

Before we get to the solution, it's worth understanding why the seemingly natural choice, a Cart Transform Function, doesn't work for "gift with purchase" promotions. This is a common misconception that can waste hours of development time.

Cart Transform's lineExpand operation decomposes a cart line into bundle components. The critical detail: every component's quantity is multiplied by the parent line's quantity.

Consider a customer with 4 units of a product in their cart. If you expand that line to include a gift component with quantity 1:

lineExpand result
Parent Line (qty 4) → Component 1 (qty 1 × 4 = 4)
                     → Gift       (qty 1 × 4 = 4)

Result: 4 products + 4 gifts (not 1 gift)

You cannot set a fractional quantity like 0.25 to get exactly 1 gift. Cart Transform also cannot add new cart lines, it can only expand, merge, or update existing ones. The lineExpand operation is designed for bundles (buy 1 pack, get N components), not for promotions (spend $60+, get 1 free gift).

Bottom line: Cart Transform is the wrong tool for free-gift-with-purchase. The correct approach is a Checkout UI Extension that adds the gift as a separate, independent cart line.


The Case Study: Free Gift With $60+ Purchase

Here's the exact business requirement we're solving:

  • When a customer's cart subtotal reaches $60 or more, a free gift product (e.g., a branded tote bag worth $15) should appear in the cart
  • The store operates in multiple currencies (USD, EUR, GBP, CAD), so the $60 threshold must be currency-aware
  • The gift should display its original price with a visible discount, so the customer sees the value they're getting
  • If the customer removes items and the subtotal drops below $60, the gift should disappear

This pattern, "gift with purchase", is one of the most requested promotional mechanics in e-commerce. It drives higher average order values and gives customers a tangible incentive to add more to their cart. But implementing it properly on Shopify requires an Extension and a Function working together.

Here's what the finished result looks like in a real Shopify checkout:

Shopify checkout showing a free coffee scoop gift with 100% discount applied, the gift line shows the original price crossed out and marked FREE
The gift product appears in checkout with its original price crossed out and a "FREE GIFT" discount applied. The customer sees exactly what they're saving.

Live demo: See this working on a real Shopify store, Reima Benson Plus demo store (password: shopify). Add enough items to cross the $60 threshold, then proceed to checkout to see the free gift banner.


Solution Architecture

Here's how the two components divide responsibilities:

Checkout UI Extension, handles cart modification. It renders a React component in the checkout flow that monitors the cart subtotal. When the subtotal crosses the threshold, it presents a banner inviting the customer to add their free gift. If the subtotal drops below the threshold, it automatically removes the gift. The extension uses Shopify's applyCartLinesChange API to add and remove the gift variant as a real cart line.

Discount Function, handles pricing. It scans the cart for any product tagged free-gift and applies a 100% product discount. The Function doesn't need to know about thresholds, it trusts that if the gift is in the cart, it should be free. The Extension is the gatekeeper; the Function is the price adjuster.

Why split it this way? Because Shopify Functions cannot add or remove cart lines. The Extension fills the gap that Shopify Scripts used to cover, dynamic cart modification, while the Function handles what Functions do best: deterministic, fast pricing logic executed on Shopify's edge infrastructure.

Separation of concerns. The Checkout Extension manages what's in the cart. The Discount Function manages what it costs. Neither knows about the other's implementation, they communicate through the cart state itself (specifically, the free-gift product tag).


Prerequisites

Before we start building, make sure you have the following set up:

  • A Shopify Partner account with a development store
  • Node.js 22+ installed (required for Shopify CLI and JavaScript Functions)
  • Shopify CLI installed globally
  • An existing Shopify app (or we'll create one below)

If you don't have Shopify CLI installed yet:

brew tap shopify/shopify
brew install shopify-cli

# Or via npm
npm install -g @shopify/cli@latest
npm install -g @shopify/cli@latest
npm install -g @shopify/cli@latest

# Or via pnpm
pnpm install -g @shopify/cli@latest

If you need a new app, scaffold one with:

Terminal
shopify app init

The CLI will walk you through selecting your Partner organization and app template. Choose Remix as the framework, it's Shopify's recommended app template and comes pre-configured with the access scopes we'll need.

Once your app is ready, make sure your shopify.app.toml includes the required access scopes:

shopify.app.toml
[access_scopes]
scopes = "write_products,write_discounts"

Step 1: Build the Checkout UI Extension

The Checkout UI Extension is responsible for adding and removing the gift product from the cart. It renders a React component inside the checkout flow, monitors the cart subtotal, and uses Shopify's applyCartLinesChange API to modify cart lines in real time.

Scaffold the extension:

Terminal
shopify app generate extension --template checkout_ui --name free-gift-checkout

When prompted, select React as the language. The CLI creates an extension directory with a starter template:

extensions/free-gift-checkout/
├── src/
│   └── Checkout.jsx
├── locales/
│   └── en.default.json
└── shopify.extension.toml

Configure the extension:

Open shopify.extension.toml and configure it with the settings the merchant will use to set up the gift in the checkout editor:

shopify.extension.toml
api_version = "2025-07"

[[extensions]]
type = "ui_extension"
name = "free-gift-checkout"
handle = "free-gift-checkout"

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

[extensions.capabilities]
api_access = true

[[extensions.settings.fields]]
key = "gift_variant_id"
type = "variant_reference"
name = "Gift Product Variant"
description = "The variant to add as a free gift"

[[extensions.settings.fields]]
key = "threshold_amount"
type = "number_integer"
name = "Minimum cart subtotal"
description = "Cart subtotal required to qualify for the free gift (in shop currency)"

Key configuration points:

  • purchase.checkout.block.render, renders wherever the merchant places the block in the checkout editor
  • api_access = true, required for applyCartLinesChange to modify cart contents
  • variant_reference setting, lets the merchant pick the gift variant from a product selector in the checkout editor, no need to paste GIDs
  • number_integer setting, the threshold amount the merchant can adjust without touching code

Write the extension component:

This is the core of the solution. The component reads the cart state, calculates whether the subtotal meets the threshold, and adds or removes the gift accordingly:

src/Checkout.jsx
import { useEffect, useState } from "react";
import {
  reactExtension,
  useCartLines,
  useSettings,
  useApplyCartLinesChange,
  Banner,
  Button,
  Text,
  BlockStack,
} from "@shopify/ui-extensions-react/checkout";

export default reactExtension(
  "purchase.checkout.block.render",
  () => <FreeGift />
);

function FreeGift() {
  const cartLines = useCartLines();
  const {
    gift_variant_id: giftVariantId,
    threshold_amount: thresholdAmount,
  } = useSettings();
  const applyCartLinesChange = useApplyCartLinesChange();
  const [adding, setAdding] = useState(false);
  const [removing, setRemoving] = useState(false);

  if (!giftVariantId) return null;

  const giftLine = cartLines.find(
    (line) => line.merchandise.id === giftVariantId
  );
  const hasGift = Boolean(giftLine);

  const subtotal = cartLines
    .filter((line) => line.merchandise.id !== giftVariantId)
    .reduce(
      (sum, line) =>
        sum + Number(line.cost.totalAmount.amount),
      0
    );

  const threshold = Number(thresholdAmount || 60);
  const qualifies = subtotal >= threshold;

  useEffect(() => {
    if (hasGift && !qualifies && !removing) {
      setRemoving(true);
      applyCartLinesChange({
        type: "removeCartLine",
        id: giftLine.id,
        quantity: giftLine.quantity,
      }).finally(() => setRemoving(false));
    }
  }, [hasGift, qualifies]);

  if (qualifies && !hasGift) {
    return (
      <Banner title="You've unlocked a free gift!">
        <BlockStack spacing="tight">
          <Text>
            Your order qualifies for a complimentary gift.
          </Text>
          <Button
            loading={adding}
            onPress={async () => {
              setAdding(true);
              const result = await applyCartLinesChange({
                type: "addCartLine",
                merchandiseId: giftVariantId,
                quantity: 1,
              });
              setAdding(false);
              if (result.type === "error") {
                console.error("Failed to add gift:", result.message);
              }
            }}
          >
            Add Free Gift to Order
          </Button>
        </BlockStack>
      </Banner>
    );
  }

  if (hasGift && qualifies) {
    return (
      <Banner title="Free gift included!" status="success">
        <Text>
          A complimentary gift has been added to your order.
        </Text>
      </Banner>
    );
  }

  return null;
}

Let's walk through the key parts:

  1. Read settings from checkout editor, useSettings() returns the gift_variant_id and threshold_amount that the merchant configured. The variant_reference setting type returns the full variant GID
  2. Identify the gift line, We scan the cart for a line matching the gift variant ID to check whether the gift is already present
  3. Calculate qualifying subtotal, We sum cost.totalAmount for all lines except the gift. The amounts from useCartLines() are in the buyer's presentment currency
  4. Auto-remove via useEffect, If the gift is in the cart but the customer no longer qualifies, we automatically remove it. No button needed for removal, it just disappears
  5. Button-based add, When the customer qualifies, a banner appears with an "Add Free Gift" button. The customer opts in, which is better UX than silently adding items to their order
  6. Error handling, applyCartLinesChange returns a result object. We log errors without breaking the checkout flow

Enable the extension in checkout:

After deploying your app (shopify app deploy), go to Settings → Checkout in your Shopify admin. Open the checkout editor and drag the "free-gift-checkout" block to your desired position. Configure the gift variant and threshold amount directly in the editor.

Placement matters. Position the gift banner where it'll be most visible, typically near the order summary or just above the payment section. The merchant controls placement entirely through the checkout editor; no code changes needed.

Shopify checkout editor showing the Free Gift at Checkout extension block settings, gift variant selector, threshold amount, and live preview of the gift banner in checkout
The checkout editor with the Free Gift extension block. The merchant configures the gift variant and threshold on the left; the live checkout preview shows the gift banner on the right.

Step 2: Build the Discount Function

The Checkout Extension handles adding and removing the gift. Now we need the Discount Function to make it free. This Function is intentionally simple, it finds any product tagged free-gift and applies a 100% discount. No threshold check, no configuration reading. The Extension is the gatekeeper; the Function just prices it at zero.

Scaffold the extension:

Terminal
shopify app generate extension --template discount --name gift-discount

When prompted, select JavaScript. The CLI generates:

extensions/gift-discount/
├── src/
│   ├── run.graphql
│   └── run.js
├── schema.graphql
└── shopify.extension.toml

Configure the extension:

shopify.extension.toml
api_version = "2025-07"

[[extensions]]
name = "gift-discount"
handle = "gift-discount"
type = "function"

  [[extensions.targeting]]
  target = "cart.lines.discounts.generate.run"
  input_query = "src/run.graphql"
  export = "run"

Define the input query:

The Discount Function only needs to know which cart lines are gifts. We use the hasAnyTag field to check for the free-gift tag:

src/run.graphql
query RunInput {
  cart {
    lines {
      id
      merchandise {
        __typename
        ... on ProductVariant {
          product {
            isFreeGift: hasAnyTag(tags: ["free-gift"])
          }
        }
      }
    }
  }
}

Tag your gift product. In the Shopify admin, add the tag free-gift to the product you're using as the gift. This is how the Discount Function identifies which lines to discount, it's decoupled from the Extension's variant ID setting, so they stay independent.

Write the Function logic:

The logic is minimal, find gift-tagged lines, apply 100% off:

src/run.js
// @ts-check

/**
 * @typedef {import("../generated/api").RunInput} RunInput
 * @typedef {import("../generated/api").CartLinesDiscountsGenerateRunResult} CartLinesDiscountsGenerateRunResult
 */

const EMPTY = { operations: [] };

/**
 * @param {RunInput} input
 * @returns {CartLinesDiscountsGenerateRunResult}
 */
export function run(input) {
  const giftLines = input.cart.lines.filter((line) => {
    if (line.merchandise.__typename !== "ProductVariant") return false;
    return line.merchandise.product.isFreeGift;
  });

  if (giftLines.length === 0) return EMPTY;

  return {
    operations: [
      {
        addCartLinesDiscount: {
          candidates: [
            {
              targets: giftLines.map((line) => ({
                cartLine: { id: line.id },
              })),
              value: { percentage: { value: "100" } },
              message: "FREE Gift With Purchase",
            },
          ],
          selectionStrategy: "FIRST",
        },
      },
    ],
  };
}

Notice how simple this is compared to a full threshold-checking implementation. The Extension handles all the eligibility logic. The Function just says: "Is there a gift in the cart? Make it free."

Generate types and build:

Terminal
shopify app function typegen --path extensions/gift-discount
shopify app function build --path extensions/gift-discount

Create the automatic discount:

After deploying (shopify app deploy), query for the Discount Function's ID:

GraphQL Admin API, Get Function ID
query {
  shopifyFunctions(first: 25, apiType: "discount") {
    nodes {
      app { title }
      apiType
      title
      id
    }
  }
}

Then create an automatic discount using the function ID:

GraphQL Admin API
mutation {
  discountAutomaticAppCreate(
    automaticAppDiscount: {
      title: "Free Gift With Purchase"
      functionId: "YOUR_DISCOUNT_FUNCTION_ID"
      discountClasses: [PRODUCT]
      startsAt: "2025-01-01T00:00:00"
    }
  ) {
    automaticAppDiscount {
      discountId
    }
    userErrors {
      field
      message
    }
  }
}

Why functionId instead of functionHandle? While the Shopify docs reference functionHandle, it can be unreliable in practice. Using functionId, retrieved via the shopifyFunctions query, is the more dependable approach.


Multi-Currency Considerations

Multi-currency adds complexity to the threshold check. The useCartLines() hook in the Checkout Extension returns amounts in the buyer's presentment currency. If your merchant sets the threshold to "60" (thinking USD), a European customer shopping in EUR will have their subtotal compared against €60, not the USD equivalent.

For single-currency stores, this is straightforward: the threshold matches the store currency. For multi-currency stores, you have several options:

Option 1: Accept presentment-currency thresholds

The simplest approach. The merchant sets "60" and it means 60 units of whatever currency the buyer is using. A customer in EUR needs €60, one in GBP needs £60. This is often acceptable because most merchants round their thresholds to simple numbers anyway.

Option 2: Per-currency thresholds via app metafield

Store a currency map in an app-data metafield, something like { "USD": 60, "EUR": 55, "GBP": 48, "CAD": 80 }. The extension reads the buyer's currency via useCurrency() and looks up the correct threshold. This gives the merchant full control over per-currency pricing.

Option 3: Backend conversion

The app's backend fetches live exchange rates and serves the converted threshold via a session token-authenticated endpoint. The extension calls this endpoint on load. This is the most accurate but adds network latency and backend complexity.

For this demo, we use Option 1, the threshold setting from the checkout editor applies in whatever currency the buyer is using. For production multi-currency stores, Option 2 (per-currency metafield) offers the best balance of accuracy and simplicity.

What about presentmentCurrencyRate? If you've worked with Cart Transform Functions, you may know they have access to presentmentCurrencyRate, a handy exchange-rate field. Unfortunately, Checkout UI Extensions do not have this field. That's why multi-currency handling requires one of the alternative approaches above.


Edge Cases You Need to Handle

Building the "happy path" is straightforward. Production readiness requires handling the edges:

1. Customer manually removes the gift

If the customer removes the gift line from checkout while still qualifying, the extension's next render cycle will show the "Add Free Gift" banner again. This is intentional, the customer chose to remove it, so we give them the option to re-add rather than forcing it back in.

2. Gift product out of stock

If the gift variant is out of stock, applyCartLinesChange will return an error. Handle this gracefully, don't show the gift banner if the product can't be added. You can check inventory via the Storefront API or simply catch the error and display an "unavailable" message.

3. Multiple gift-tagged products

The Discount Function applies 100% off to all lines tagged free-gift. If multiple gift products end up tagged, they'll all be discounted. The Extension only adds the variant configured in its settings, so you have single-add control. But be mindful of the tag scope if other apps or manual processes add that tag.

4. Cart page vs. checkout

The Checkout Extension only renders during checkout, not on the cart page. If you want the gift to appear on the cart page too, you'll need theme-level JavaScript that watches the cart subtotal and uses the Cart AJAX API to add/remove the gift variant. The Discount Function will still make it free regardless of where the gift was added from.

5. Discount codes that reduce the subtotal

The Extension checks the pre-discount subtotal (the sum of cart line prices before any discounts are applied). If a customer applies a 30% coupon that drops a $70 cart to $49, the Extension may still show the gift because it sees $70. For most merchants, this is acceptable, they want to reward the customer for adding $60+ worth of products. If post-discount enforcement is critical, coordinate with your discount function or use a backend check.


Testing Your Setup

The Discount Function can be tested locally before deploying. Create a sample input file and run it against the function:

extensions/gift-discount/test-input.json
{
  "cart": {
    "lines": [
      {
        "id": "gid://shopify/CartLine/1",
        "merchandise": {
          "__typename": "ProductVariant",
          "product": { "isFreeGift": false }
        }
      },
      {
        "id": "gid://shopify/CartLine/2",
        "merchandise": {
          "__typename": "ProductVariant",
          "product": { "isFreeGift": true }
        }
      }
    ]
  }
}
Terminal
shopify app function run \
  --path extensions/gift-discount \
  --input extensions/gift-discount/test-input.json \
  --export run

Expected output: a productDiscountsAdd operation targeting the gift cart line with a 100% percentage discount. Here's what the real execution logs look like in the Partner Dashboard:

Discount Function input log showing two cart lines, the main product with isFreeGift false and the gift product with isFreeGift true
Function input: two cart lines, one tagged as a free gift
Discount Function output showing productDiscountsAdd operation with 100% percentage discount targeting the gift cart line
Function output: 100% discount applied to the gift line

For the Checkout Extension, use the dev server for live testing:

Terminal
# Start the app in development mode
shopify app dev

This starts a tunnel, installs the draft app on your dev store, and hot-reloads your extensions on every save. Navigate to your dev store's checkout with a cart subtotal above the threshold and verify that the gift banner appears. Add items, remove items, and cross the threshold in both directions to test the add/remove lifecycle. Open the Function execution logs in your Partner Dashboard to debug the Discount Function.


Key Takeaways

Building a "free gift with purchase" experience on modern Shopify requires understanding where each tool fits, and where it doesn't:

  • Shopify Scripts are going away. The server-side cart modification they provided is no longer available. Checkout UI Extensions fill this gap for cart line management
  • Cart Transform is for bundles, not promotions. Its lineExpand multiplies quantities per parent unit, making it unsuitable for "add exactly 1 gift" scenarios
  • Checkout Extension + Discount Function is the correct pattern, one manages cart contents, the other manages pricing. They communicate through the cart state itself
  • Functions are pure. They can't access the network, filesystem, or current time. All data must come through the input query
  • Keep Functions simple. Push eligibility logic to the Extension and let the Function do one thing well: apply the discount
  • Multi-currency requires forethought. Unlike Cart Transform Functions which have presentmentCurrencyRate, Checkout Extensions work in the buyer's currency. Plan your threshold strategy accordingly

This same pattern, Extension for cart modification, Function for pricing, extends to tiered gifts, loyalty rewards, conditional free shipping, and any promotional mechanic that requires both adding items and adjusting prices.


If your store needs a discount experience that goes beyond what Shopify offers out of the box, multi-currency promotions, conditional gifts, or custom stacking logic, I can help you architect and build it. Let's talk about what your commerce model actually needs.

Alpine.js Shopify Theme Development