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

  1. Create an app with the Shopify CLI and add the extension: shopify app generate extension --template discount, with JavaScript.
  2. Replace the generated shopify.extension.toml and src/ files with the files below.
  3. Run the fixtures with shopify app function run --input tests/qualifies.input.json.
  4. Deploy with shopify app deploy and create an automatic discount with discountAutomaticAppCreate, discountClasses: [PRODUCT].
  5. Set the discount’s $app:function-configuration metafield with buyProductIds, getProductIds and, if needed, buyQuantity, getQuantity and percentage.

Code

shopify.extension.toml
api_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.graphql
query 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.
  • 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.