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:
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:
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:
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:
[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:
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:
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 editorapi_access = true, required forapplyCartLinesChangeto modify cart contentsvariant_referencesetting, lets the merchant pick the gift variant from a product selector in the checkout editor, no need to paste GIDsnumber_integersetting, 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:
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:
- Read settings from checkout editor,
useSettings()returns thegift_variant_idandthreshold_amountthat the merchant configured. Thevariant_referencesetting type returns the full variant GID - Identify the gift line, We scan the cart for a line matching the gift variant ID to check whether the gift is already present
- Calculate qualifying subtotal, We sum
cost.totalAmountfor all lines except the gift. The amounts fromuseCartLines()are in the buyer's presentment currency - 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 - 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
- Error handling,
applyCartLinesChangereturns 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.
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:
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:
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:
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:
// @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:
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:
query {
shopifyFunctions(first: 25, apiType: "discount") {
nodes {
app { title }
apiType
title
id
}
}
}
Then create an automatic discount using the function ID:
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:
{
"cart": {
"lines": [
{
"id": "gid://shopify/CartLine/1",
"merchandise": {
"__typename": "ProductVariant",
"product": { "isFreeGift": false }
}
},
{
"id": "gid://shopify/CartLine/2",
"merchandise": {
"__typename": "ProductVariant",
"product": { "isFreeGift": true }
}
}
]
}
}
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:
For the Checkout Extension, use the dev server for live testing:
# 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
lineExpandmultiplies 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.