Buy X get Y discount
A Shopify discount Function for buy X get Y offers: buy 2 of one product, get 1 of another free or discounted, cheapest first. Tested JavaScript.
- 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 runs a buy X get Y offer: for every 2 units of the products you list, one unit of the reward products is free, cheapest first. Quantities, percentage and product IDs live in a JSON metafield. It uses the cart.lines.discounts.generate.run target of the Discount Function API, version 2026-10, with the product class and quantity-limited cart line targets.
How to use it
- Create an app with the Shopify CLI and add the extension: shopify app generate extension --template discount, with JavaScript.
- Replace the generated shopify.extension.toml and src/ files with the files below.
- Run the fixtures with shopify app function run --input tests/qualifies.input.json.
- Deploy with shopify app deploy and create an automatic discount with discountAutomaticAppCreate, discountClasses: [PRODUCT].
- Set the discount’s $app:function-configuration metafield with buyProductIds, getProductIds and, if needed, buyQuantity, getQuantity and percentage.
Code
shopify.extension.tomlapi_version = "2026-10"
[[extensions]]
name = "Buy X get Y discount"
handle = "buy-x-get-y-discount"
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
quantity
cost {
amountPerQuantity {
amount
}
}
merchandise {
__typename
... on ProductVariant {
product {
id
}
}
}
}
}
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. The product lists are
* empty by default, so the discount does nothing until it's configured.
* Keep the two lists separate: a product in both counts only as "buy".
*/
const DEFAULT_CONFIG = {
buyProductIds: /** @type {string[]} */ ([]),
buyQuantity: 2,
getProductIds: /** @type {string[]} */ ([]),
getQuantity: 1,
// 100 makes the Y items free.
percentage: 100,
// Shown at checkout; null builds it from the numbers above ("Buy 2, get 1 free").
message: /** @type {string | null} */ (null),
};
/**
* @param {{buyQuantity: number, getQuantity: number, percentage: number}} config
*/
const defaultMessage = ({ buyQuantity, getQuantity, percentage }) =>
`Buy ${buyQuantity}, get ${getQuantity} ${percentage >= 100 ? "free" : `${percentage}% off`}`;
/** @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 };
/** @param {(typeof input.cart.lines)[number]} line */
const productId = (line) => (line.merchandise.__typename === "ProductVariant" ? line.merchandise.product.id : null);
const buyLines = input.cart.lines.filter((line) => config.buyProductIds.includes(productId(line)));
const getLines = input.cart.lines.filter(
(line) => !buyLines.includes(line) && config.getProductIds.includes(productId(line)),
);
const boughtUnits = buyLines.reduce((sum, line) => sum + line.quantity, 0);
let freeUnits = Math.floor(boughtUnits / config.buyQuantity) * config.getQuantity;
if (freeUnits === 0 || getLines.length === 0) return NO_DISCOUNT;
// Discount the cheapest Y units first, as customers expect from "get one free".
const targets = [];
const cheapestFirst = [...getLines].sort(
(a, b) => Number(a.cost.amountPerQuantity.amount) - Number(b.cost.amountPerQuantity.amount),
);
for (const line of cheapestFirst) {
if (freeUnits === 0) break;
const quantity = Math.min(line.quantity, freeUnits);
targets.push({ cartLine: { id: line.id, quantity } });
freeUnits -= quantity;
}
return {
operations: [
{
productDiscountsAdd: {
candidates: [{ message: config.message ?? defaultMessage(config), targets, value: { percentage: { value: String(config.percentage) } } }],
selectionStrategy: "FIRST",
},
},
],
};
}
Test input and expected output
tests/not-enough-bought.input.json{
"cart": {
"lines": [
{ "id": "gid://shopify/CartLine/1", "quantity": 1, "cost": { "amountPerQuantity": { "amount": "30.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/1111111111" } } },
{ "id": "gid://shopify/CartLine/2", "quantity": 1, "cost": { "amountPerQuantity": { "amount": "12.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/2222222222" } } }
]
},
"discount": {
"discountClasses": ["PRODUCT"],
"metafield": {
"jsonValue": {
"buyProductIds": ["gid://shopify/Product/1111111111"],
"getProductIds": ["gid://shopify/Product/2222222222"]
}
}
}
}
tests/not-enough-bought.output.json{ "operations": [] }
tests/qualifies.input.json{
"cart": {
"lines": [
{ "id": "gid://shopify/CartLine/1", "quantity": 2, "cost": { "amountPerQuantity": { "amount": "30.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/1111111111" } } },
{ "id": "gid://shopify/CartLine/2", "quantity": 1, "cost": { "amountPerQuantity": { "amount": "12.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/2222222222" } } }
]
},
"discount": {
"discountClasses": ["PRODUCT"],
"metafield": {
"jsonValue": {
"buyProductIds": ["gid://shopify/Product/1111111111"],
"getProductIds": ["gid://shopify/Product/2222222222"]
}
}
}
}
tests/qualifies.output.json{
"operations": [
{
"productDiscountsAdd": {
"candidates": [
{
"message": "Buy 2, get 1 free",
"targets": [{ "cartLine": { "id": "gid://shopify/CartLine/2", "quantity": 1 } }],
"value": { "percentage": { "value": "100" } }
}
],
"selectionStrategy": "FIRST"
}
}
]
}
tests/two-sets-cheapest-first.input.json{
"cart": {
"lines": [
{ "id": "gid://shopify/CartLine/1", "quantity": 5, "cost": { "amountPerQuantity": { "amount": "30.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/1111111111" } } },
{ "id": "gid://shopify/CartLine/2", "quantity": 3, "cost": { "amountPerQuantity": { "amount": "15.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/2222222222" } } },
{ "id": "gid://shopify/CartLine/3", "quantity": 1, "cost": { "amountPerQuantity": { "amount": "12.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/2222222222" } } }
]
},
"discount": {
"discountClasses": ["PRODUCT"],
"metafield": {
"jsonValue": {
"buyProductIds": ["gid://shopify/Product/1111111111"],
"getProductIds": ["gid://shopify/Product/2222222222"]
}
}
}
}
tests/two-sets-cheapest-first.output.json{
"operations": [
{
"productDiscountsAdd": {
"candidates": [
{
"message": "Buy 2, get 1 free",
"targets": [
{ "cartLine": { "id": "gid://shopify/CartLine/3", "quantity": 1 } },
{ "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
- The reward item must already be in the cart; a discount Function can’t add lines. Add it with the theme or a cart transform.
- Keep the buy and get lists separate. A product in both counts only as a bought item in this code.
- The target quantity limits the discount to the free units, so extra reward items are charged normally.
Questions
- Doesn’t Shopify have buy X get Y built in?
- It does, for simple cases. A Function helps when you need rules the admin can’t express, for example cheapest-first rewards across several products or combining it with other logic in code.
- How do I make “buy 2, get the 3rd free” on the same product?
- This code expects separate lists. For one product, change the count to group size (buyQuantity + getQuantity) and discount getQuantity units per full group.
- Can the reward be a percentage instead of free?
- Yes. Set percentage, for example 50 for half price. 100 makes the reward free.
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.
