Building a Tiered Bundle Discount With Shopify Functions

A pet-gear brand I work with had a $29 per month bundle app that refused to combine with their sitewide promo code. The code had been redeemed 347 times. Every redemption silently failed in cart. The fix is not another app. It is a Shopify Discount Function and one combinesWith flag.

TL;DR: A Discount Function written in TypeScript replaces most “bundle discount” apps in under 50 lines of code, costs nothing per month, and stacks with sitewide promo codes once you flip combinesWith.productDiscounts to true on the parent automatic discount. Build a 3-tier kit (buy 3, 5, 7 units), wire it into the cart drawer, and verify the whole thing in 30 minutes.

Why this matters for your store

  • Bundle apps charge $19 to $79 per month and almost always block stacked discounts, so your sitewide email code dies in cart.
  • Shopify Scripts (Plus only) shut off June 30 2026, so any tiered-discount logic still living in the Ruby Script Editor breaks that morning.
  • A Discount Function compiles to WebAssembly, runs on Shopify’s checkout server, and stacks with code discounts when you configure it correctly. One config flip saves the AOV the bundle was supposed to lift.

What is a Shopify Discount Function

A Discount Function is a piece of TypeScript or Rust code, compiled to WebAssembly, that runs inside Shopify’s Functions runtime and decides what discount to apply to a cart. Every checkout call passes the cart object into your function. The function returns a list of discounts. Shopify applies them.

Three things make Functions different from the apps and Scripts they replaced:

  1. They run on Shopify’s infrastructure, not yours, so there is no latency tax and no third-party JavaScript to load on the storefront.
  2. They stack with code and automatic discounts when combinesWith.productDiscounts (or orderDiscounts, depending on the discount class) is true on the parent automatic discount.
  3. They are free to deploy on every plan from Basic through Plus. The Discount Functions API has no plan gate.

The migration deadline matters. Shopify Scripts deprecate June 30 2026 for Plus stores, and the broader non-Plus checkout extensibility deadline lands August 26 2026. Anything still in cart.scripts, the Script Editor, or the Additional Scripts box on either deadline stops firing. A Discount Function is the official replacement path.

The 3-tier kit pattern that lifts AOV

The pattern shipping this week:

Quantity in cart Discount applied Use case
3 of the same SKU family 10% off First-tier kit
5 15% off Mid-tier kit
7 or more 20% off Top-tier kit

The brand sells $24.95 leashes, harnesses, and collars. A 3-pack at 10% off lands cart subtotal around $67.36. The 5-pack tier crosses the brand’s $50 free shipping threshold and drives the loudest jump in cart-to-checkout conversion. The 7-pack tier is rare in volume but lifts AOV roughly 18% on the sessions it converts.

You can extend this to mixed-SKU bundles, but the cleanest first ship is single-SKU tiers because the cart logic stays simple. Mixed-SKU gets layered after the function works in production for two weeks.

The Discount Function code, end to end

Install the Shopify CLI and scaffold the extension:

# terminal: project root
shopify app generate extension \
  --type=product_discount \
  --name=tiered-kit-discount

This creates an extensions/tiered-kit-discount/ folder with run.graphql (the cart query), run.ts (the function), and shopify.extension.toml (config).

The cart query (run.graphql):

query RunInput {
  cart {
    lines {
      id
      quantity
      merchandise {
        ... on ProductVariant {
          id
          product { id }
        }
      }
    }
  }
}

The function logic (run.ts, 31 lines):

// extensions/tiered-kit-discount/src/run.ts
import type { RunInput, FunctionRunResult } from "../generated/api";

const TIERS = [
  { qty: 7, percent: 20 },
  { qty: 5, percent: 15 },
  { qty: 3, percent: 10 },
];

export function run(input: RunInput): FunctionRunResult {
  const totalQty = input.cart.lines.reduce(
    (sum, line) => sum + line.quantity, 0
  );

  const tier = TIERS.find((t) => totalQty >= t.qty);
  if (!tier) return { discounts: [] };

  return {
    discounts: [{
      message: `Kit ${tier.percent}% off`,
      targets: input.cart.lines.map((l) => ({
        productVariant: { id: l.merchandise.id }
      })),
      value: { percentage: { value: tier.percent.toString() } }
    }],
    discountApplicationStrategy: "FIRST"
  };
}

Tiers are ordered descending so the highest-qualifying tier wins automatically. The FIRST strategy means Shopify applies your function’s first matching discount and skips the rest. Keep the tier order high to low or you will silently apply 10% to a 7-unit cart.

Build and deploy:

shopify app build
shopify app deploy

The deploy uploads the WebAssembly binary to Shopify. The function is then attached to a parent automatic discount in admin (Discounts, Create automatic discount, Tiered Kit Discount). The parent discount is where you set the schedule and the combinesWith flags.

The combinesWith trap (the part nobody documents)

This is the bug that costs merchants money. By default, a new automatic discount has combinesWith.productDiscounts = false, orderDiscounts = false, and shippingDiscounts = false. Translation: your Discount Function will not stack with any code discount, including the sitewide promo your email list has been using for two years.

On the brand I shipped this for, their sitewide code had been redeemed 347 times. With the bundle app installed, every one of those redemptions silently failed in cart because the app blocked stacking. After installing the Discount Function with default combinesWith flags, the same thing happened. Code redemption rate stayed dead.

The fix is a single GraphQL mutation against the Shopify Admin Discounts API:

mutation {
  discountAutomaticAppUpdate(
    automaticAppDiscount: {
      combinesWith: {
        productDiscounts: true
        orderDiscounts: true
        shippingDiscounts: true
      }
    }
    id: "gid://shopify/DiscountAutomaticApp/REPLACE_ID"
  ) {
    userErrors { field message }
  }
}

You can run this in the Shopify GraphiQL App in admin or through the Admin API. After the flip, a customer with 5 leashes in cart and the sitewide code applied gets the 15% kit tier first and the code discount second, applied on the discounted subtotal. The combinesWith reference covers the full matrix of which discount classes can stack with which.

Storefront wiring (showing the saving in cart)

The Function applies the discount at checkout, but customers want to see the saving in the cart drawer or the cart page. Two ways to surface it.

The simplest reads cart.cart_level_discount_applications in Liquid:

{%- comment -%} sections/cart-drawer.liquid {%- endcomment -%}
{%- if cart.cart_level_discount_applications.size > 0 -%}
  {%- for application in cart.cart_level_discount_applications -%}
    <div class="cart-discount">
      {{ application.title }}: -{{ application.total_allocated_amount | money }}
    </div>
  {%- endfor -%}
{%- endif -%}

This works because Discount Functions render into cart.cart_level_discount_applications the same way Scripts and apps did. No JavaScript needed.

For an upsell hint above the cart total (“buy 2 more for 15% off”), calculate the gap inline:

{%- assign total_qty = cart.item_count -%}
{%- assign next_tier = 0 -%}
{%- if total_qty < 3 -%}
  {%- assign next_tier = 3 -%}
{%- elsif total_qty < 5 -%}
  {%- assign next_tier = 5 -%}
{%- elsif total_qty < 7 -%}
  {%- assign next_tier = 7 -%}
{%- endif -%}
{%- if next_tier > 0 -%}
  <p>Add {{ next_tier | minus: total_qty }} more to unlock the next kit discount</p>
{%- endif -%}

Rendered above the cart subtotal, the same hint moved my last bundle builder ship from 1.4 to 1.7 items per checkout over a 14-day window. The lift is small but real and compounds with every other CRO change in the cart.

To measure whether that hint actually moves AOV, drop an impression pixel through a script tag inside the same Custom Liquid section. It survives the theme editor reload and fires cleanly on every cart render.

How to verify it actually works

Three checks, total time under five minutes:

  1. Admin test. In Shopify admin, go to Discounts, your automatic discount, Test. Add 3 of the qualifying SKU. Confirm the discount line item appears and the percentage is correct.
  2. Storefront test. Open an incognito window, add 5 units to cart, proceed to checkout. The 15% line should appear in the order summary. Apply your sitewide code. Both should stack.
  3. Live order. Place a real $1 test order through Shopify’s Bogus Gateway. Confirm the order in admin shows both discount applications in the order timeline.

If any step fails, the bug is almost always one of: tier order in TIERS is wrong, combinesWith was never flipped, or the parent automatic discount is paused. The function code itself rarely breaks.

The takeaway

  • Replace any “bundle discount” app with a Discount Function once you cross $19 per month in app cost. The latency drops to zero and the storefront gets one fewer third-party script to load.
  • Order your tier array high to low so the largest qualifying tier wins under the FIRST strategy. Reverse the order and you ship 10% on a 7-unit cart.
  • Flip combinesWith.productDiscounts to true on the parent automatic discount or your sitewide code dies silently in cart. This is the most expensive default in Shopify admin.
  • Surface the next-tier gap in the cart drawer with a 4-line Liquid block. Small AOV nudge, free to ship, no JavaScript.
  • Verify in admin test mode, then incognito storefront, then a $1 Bogus Gateway order. Five-minute check, catches every bug worth catching.

Migrating off Shopify Scripts before the June 30 2026 cutoff? My 5 real Scripts I migrated to Functions walks the Ruby-in / TypeScript-out conversion pattern for tiered shipping, free gifts, B2B tags, BXGY, and payment hiding. For everything else breaking on August 26, the non-Plus checkout extensibility audit covers the full map. If your Discount Function also needs to honor agent-initiated codes from Shopify’s agentic storefronts, the May 30, 2026 UCP cutover changes how the Discounts API surfaces those redemptions through ChatGPT and Perplexity.

Need help shipping this on your store? Book a free 30-minute call. I will look at your current bundle setup, identify what is blocking stacking, and scope the Function rebuild end to end.

Frequently Asked Questions

What is a Shopify Discount Function and why use one over a bundle app?

A Discount Function is a piece of TypeScript or Rust code that compiles to WebAssembly, runs inside Shopify's checkout pipeline, and decides what discount to apply to a cart. Bundle apps charge $19 to $79 per month, run as third-party scripts that block stacking with code discounts, and add latency to cart and checkout. A Discount Function runs on Shopify's own infrastructure, has zero monthly cost, and stacks with automatic and code discounts when you configure combinesWith correctly. For a tiered-kit pattern (buy 3 / 5 / 7), the function is around 30 lines of code and ships once. The replacement math works above $19 per month.

Do Shopify Discount Functions work on Basic and Shopify plans, or only Plus?

Discount Functions work on every Shopify plan, including Basic, Shopify, Advanced, Shopify Plus, and Hydrogen. There is no plan gate on the Discount Functions API specifically. The plan gate that matters is on Cart Transform Functions and some Checkout UI Extensions, which are Plus-only. For the bundle-discount use case, Discount Functions cover Basic through Plus with the same code. The Shopify CLI scaffolds the extension the same way regardless of plan.

Why does my Discount Function not stack with my promo code?

By default, a new automatic discount in Shopify admin has combinesWith.productDiscounts, orderDiscounts, and shippingDiscounts all set to false. Your Discount Function will not stack with any code discount until you flip those flags on the parent automatic discount. The fix is a single discountAutomaticAppUpdate GraphQL mutation that sets the relevant combinesWith fields to true. Without that flip, a customer using your sitewide promo code in cart silently loses the bundle discount. This is the most expensive bug merchants ship on Function deploys and almost nobody documents it on the storefront-developer side.

When do Shopify Scripts shut off and how does that affect bundle discounts?

Shopify Scripts (the Ruby Script Editor on Plus stores) shut off June 30 2026, with edits already frozen since April 15 2026. Any tiered-discount logic still living in line_item.script_discount, cart.scripts, or shipping_rate.script_discount stops firing that morning. The broader non-Plus checkout extensibility deadline lands August 26 2026 and removes Additional Scripts, Order Status page scripts, and the Thank You page script box. Discount Functions are the official replacement path for bundle, BOGO, and tiered-quantity logic across both deadlines. The migration takes one to two weeks of dev time per Script.

How do I show the bundle discount in the cart drawer before checkout?

Discount Functions render into cart.cart_level_discount_applications in Liquid, the same way Scripts and bundle apps did. Loop through the applications array in your cart-drawer section and output application.title and application.total_allocated_amount filtered through the money filter. No JavaScript is required. For the upsell nudge (buy 2 more to unlock the next tier), read cart.item_count, compare against your tier thresholds, and conditionally render a 'Add N more to unlock' line above the cart subtotal. A 4-line Liquid block above the cart total is enough to surface the AOV-lift logic.

Can a Discount Function combine with a free shipping threshold?

Yes. The Discount Function applies as a product or order discount on the parent automatic discount, and the free shipping threshold is a separate shipping rate condition evaluated on the post-discount subtotal. To make the combination explicit, set combinesWith.shippingDiscounts to true on the automatic discount. The free shipping rate then evaluates against the discounted total. If your free shipping threshold is $50 and the customer hits $48 after the bundle discount, they are below the free shipping line. Adjust the threshold or the tier percentages so the combined math still rewards the bundle behavior you want.

Book Strategy Call