Free gift with purchase

A Shopify discount Function that makes one gift item free once the rest of the cart reaches a threshold. The gift must already be in the cart.

Function API
Discount
Target
cart.lines.discounts.generate.run
Discount class
PRODUCT
Plan
Any plan in a public app; Shopify Plus in a custom app
App scope
write_discounts
API version
2026-10
Last checked

In short

This discount Function makes one unit of a gift product free once the rest of the cart reaches a threshold, 75 in your store currency by default. It can’t add the gift: a discount Function only discounts lines that are already in the cart, so the theme, Storefront API or a cart transform has to add it. It uses cart.lines.discounts.generate.run, Discount Function API 2026-10.

How to use it

  1. Create the gift as a product variant and keep it out of your collections.
  2. Create an app with the Shopify CLI, add the extension (shopify app generate extension --template discount) and replace its files with the files below.
  3. Run the fixtures with shopify app function run --input tests/threshold-met.input.json, then deploy.
  4. Create an automatic discount with discountAutomaticAppCreate, discountClasses: [PRODUCT], and set {"giftVariantId": "gid://shopify/ProductVariant/1234567890", "minimumAmount": 75} in its metafield.
  5. Add the gift to the cart from the theme (for example with /cart/add.js when the threshold is reached) and remove it when the cart drops below.

Code

shopify.extension.toml
api_version = "2026-10"

[[extensions]]
name = "Free gift with purchase"
handle = "free-gift-with-purchase"
type = "function"

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

  [extensions.build]
  command = ""
  path = "dist/function.wasm"
src/cart_lines_discounts_generate_run.graphql
query CartLinesDiscountsGenerateRunInput {
  cart {
    lines {
      id
      cost {
        subtotalAmount {
          amount
        }
      }
      merchandise {
        __typename
        ... on ProductVariant {
          id
        }
      }
    }
  }
  presentmentCurrencyRate
  discount {
    discountClasses
    metafield(namespace: "$app", key: "function-configuration") {
      jsonValue
    }
  }
}
src/cart_lines_discounts_generate_run.js
// @ts-check

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

/**
 * Settings stored as JSON in the discount's metafield. A discount Function
 * can't add lines to the cart: the gift has to be added by the theme, the
 * Storefront API or a cart transform. This Function only makes it free.
 */
const DEFAULT_CONFIG = {
  giftVariantId: /** @type {string | null} */ (null),
  // Spend on other products, in the store's currency, that unlocks the gift.
  minimumAmount: 75,
  message: "Free gift",
};

/** @type {CartLinesDiscountsGenerateRunResult} */
const NO_DISCOUNT = { operations: [] };

/**
 * @param {CartLinesDiscountsGenerateRunInput} input
 * @returns {CartLinesDiscountsGenerateRunResult}
 */
export function cartLinesDiscountsGenerateRun(input) {
  // A Function may only return operations for the classes the discount was created with.
  if (!input.discount.discountClasses.includes("PRODUCT")) return NO_DISCOUNT;
  const config = { ...DEFAULT_CONFIG, ...input.discount.metafield?.jsonValue };
  if (!config.giftVariantId) return NO_DISCOUNT;

  /** @param {(typeof input.cart.lines)[number]} line */
  const isGift = (line) => line.merchandise.__typename === "ProductVariant" && line.merchandise.id === config.giftVariantId;
  const giftLine = input.cart.lines.find(isGift);
  if (!giftLine) return NO_DISCOUNT;

  // The gift itself doesn't count towards the threshold.
  const spend = input.cart.lines
    .filter((line) => !isGift(line))
    .reduce((sum, line) => sum + Number(line.cost.subtotalAmount.amount), 0);
  if (spend < config.minimumAmount * Number(input.presentmentCurrencyRate)) return NO_DISCOUNT;

  // Only one unit is free; extra units of the gift are charged normally.
  return {
    operations: [
      {
        productDiscountsAdd: {
          candidates: [
            {
              message: config.message,
              targets: [{ cartLine: { id: giftLine.id, quantity: 1 } }],
              value: { percentage: { value: "100" } },
            },
          ],
          selectionStrategy: "FIRST",
        },
      },
    ],
  };
}
Test input and expected output
tests/below-threshold.input.json
{
  "cart": {
    "lines": [
      { "id": "gid://shopify/CartLine/1", "cost": { "subtotalAmount": { "amount": "60.0" } }, "merchandise": { "__typename": "ProductVariant", "id": "gid://shopify/ProductVariant/1111111111" } },
      { "id": "gid://shopify/CartLine/2", "cost": { "subtotalAmount": { "amount": "15.0" } }, "merchandise": { "__typename": "ProductVariant", "id": "gid://shopify/ProductVariant/1234567890" } }
    ]
  },
  "presentmentCurrencyRate": "1.0",
  "discount": {
    "discountClasses": ["PRODUCT"],
    "metafield": { "jsonValue": { "giftVariantId": "gid://shopify/ProductVariant/1234567890", "minimumAmount": 75, "message": "Free tote bag" } }
  }
}
tests/below-threshold.output.json
{ "operations": [] }
tests/gift-not-in-cart.input.json
{
  "cart": {
    "lines": [
      { "id": "gid://shopify/CartLine/1", "cost": { "subtotalAmount": { "amount": "120.0" } }, "merchandise": { "__typename": "ProductVariant", "id": "gid://shopify/ProductVariant/1111111111" } }
    ]
  },
  "presentmentCurrencyRate": "1.0",
  "discount": {
    "discountClasses": ["PRODUCT"],
    "metafield": { "jsonValue": { "giftVariantId": "gid://shopify/ProductVariant/1234567890", "minimumAmount": 75, "message": "Free tote bag" } }
  }
}
tests/gift-not-in-cart.output.json
{ "operations": [] }
tests/threshold-met.input.json
{
  "cart": {
    "lines": [
      { "id": "gid://shopify/CartLine/1", "cost": { "subtotalAmount": { "amount": "80.0" } }, "merchandise": { "__typename": "ProductVariant", "id": "gid://shopify/ProductVariant/1111111111" } },
      { "id": "gid://shopify/CartLine/2", "cost": { "subtotalAmount": { "amount": "15.0" } }, "merchandise": { "__typename": "ProductVariant", "id": "gid://shopify/ProductVariant/1234567890" } }
    ]
  },
  "presentmentCurrencyRate": "1.0",
  "discount": {
    "discountClasses": ["PRODUCT"],
    "metafield": { "jsonValue": { "giftVariantId": "gid://shopify/ProductVariant/1234567890", "minimumAmount": 75, "message": "Free tote bag" } }
  }
}
tests/threshold-met.output.json
{
  "operations": [
    {
      "productDiscountsAdd": {
        "candidates": [
          {
            "message": "Free tote bag",
            "targets": [{ "cartLine": { "id": "gid://shopify/CartLine/2", "quantity": 1 } }],
            "value": { "percentage": { "value": "100" } }
          }
        ],
        "selectionStrategy": "FIRST"
      }
    }
  ]
}

Written by Lintel. MIT license: free to use in your own store and in client work.

Limits and edge cases

  • Discount Functions can’t create cart lines. Without a theme or app step that adds the gift, nothing happens.
  • Only one unit is free; more units of the gift are charged at the normal price.
  • The gift itself doesn’t count towards the threshold, so it can’t qualify itself.

Questions

Can a Shopify Function add a free product to the cart?
Not a discount Function. It can only discount existing lines. A cart transform can expand a purchased product into extra component lines, and the theme or Storefront API can add the gift as its own line.
What if the customer removes items after the gift is added?
The Function runs again and stops discounting the gift, so it’s charged. Have the theme remove it, or tell the customer, to avoid surprises.
Can I limit the gift to one per order?
Yes, this code already discounts one unit. To prevent extra units, also cap the quantity in the theme.
  • Written for Function API version 2026-10 and checked against Shopify’s reference on the date shown. Shopify releases a new version every quarter.
  • Custom apps that contain Functions only run on Shopify Plus stores; a public app with the same Function runs on any plan unless the page says otherwise.
  • Every example ships with test fixtures, and we run them before publishing. Run them again after you change the code: shopify app function run.
  • MIT license. Use the code in your own store or in client work.

Need this built, tested and maintained for your store? See Shopify Functions development.

Have something to build? Let’s talk it through.

Taking a limited number of projects at a time. Every brief is read by an engineer, not a salesperson.

Gencer KarakayaShopify Engineer

An engineer reads your brief, and we reply by email.