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
- Create the gift as a product variant and keep it out of your collections.
- Create an app with the Shopify CLI, add the extension (shopify app generate extension --template discount) and replace its files with the files below.
- Run the fixtures with shopify app function run --input tests/threshold-met.input.json, then deploy.
- Create an automatic discount with discountAutomaticAppCreate, discountClasses: [PRODUCT], and set {"giftVariantId": "gid://shopify/ProductVariant/1234567890", "minimumAmount": 75} in its metafield.
- 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.tomlapi_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.graphqlquery 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.
Shopify documentation
- 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.
