Shopify Scripts are deprecated. If you have volume discount logic running in a Script today — or you are building it fresh — Shopify Functions are the correct foundation. Functions run in a sandboxed WebAssembly runtime, return deterministically in under 5ms, and are deployed like any other app extension. They do not inject JavaScript into the checkout; they evaluate server-side before the checkout page renders, which means no render-blocking, no client-side race conditions, and guaranteed compatibility with future Shopify checkout updates. This tutorial walks you through building a tiered volume discount from scratch: buy 10+, save 10%; buy 25+, save 15%. All tier configuration is stored in metafields so your merchants can adjust thresholds without touching code or triggering a redeploy.
What you'll build
A Shopify Function extension of type product_discounts that reads total cart line quantities, evaluates them against configurable tiers stored in a JSON metafield on the discount record, and emits percentage discount targets on all matching cart lines. The tier configuration is merchant-editable through a simple App Bridge UI (not covered in this tutorial, but the metafield schema is defined in Step 7). By the end, a merchant can change from "buy 10, save 10%" to "buy 5, save 8%" without any engineering involvement.
Before you start
- A Shopify Plus development store (Functions require Plus or a Partner dev store for custom Function deployment)
- Shopify CLI 3.50 or later: run npm install -g @shopify/cli@latest to upgrade
- Node.js 18+ and an existing Shopify Partner app (create one at partners.shopify.com if needed — the app is the container for your Function extension)
- TypeScript familiarity (this tutorial uses @shopify/shopify-function in TypeScript; Rust is also supported and produces faster WASM output)
Scaffold a new Function extension
Inside your existing Shopify app directory, run the extension generator. When prompted for language, choose TypeScript. The CLI will create the extension directory with a boilerplate Function implementation, a GraphQL input query, and a shopify.extension.toml configuration file.
shopify app generate extension --type product_discounts --name volume-discount
# Select: TypeScript
# This creates extensions/volume-discount/ with:
# src/run.ts — the Function entry point
# input.graphql — your input query fragment
# shopify.extension.toml
# package.jsonExpected outcome: You now have an extensions/volume-discount/ directory with a TypeScript Function scaffold. Run `npm install` in the extension directory to install @shopify/shopify-function and the generated type packages.
Define the Function input query
The input query in input.graphql defines exactly which fields Shopify passes to your Function. Request only what you need — unused fields add query cost without benefit. For volume discounts, we need: cart line IDs (to target the discount), line quantities (to sum total cart quantity), and the discount metafield (where tier config lives). The discountNode in the input query is the discount record that references your Function — the metafield you query here is attached to that specific discount, not to the Function globally.
query RunInput {
cart {
lines {
id
quantity
merchandise {
... on ProductVariant {
id
product {
id
}
}
}
}
}
discountNode {
metafield(namespace: "volume_discount", key: "tiers") {
value
}
}
}Expected outcome: After saving this file, run `shopify app function typegen` to regenerate the TypeScript input types from this query. This produces a generated/api.ts file with fully typed RunInput and FunctionRunResult interfaces.
Implement the run function
The exported run function is the WASM entry point. It receives a typed RunInput object and must return a FunctionRunResult. The logic is straightforward: parse the tier config from the metafield (falling back to hardcoded defaults if the metafield is absent or malformed), sum quantities across all cart lines, find the highest matching tier, and emit a percentage discount targeting all lines. Notice that tiers are sorted descending so we match the highest qualifying tier first.
import type { RunInput, FunctionRunResult } from "../generated/api";
type Tier = { minQty: number; discountPercent: number };
const DEFAULT_TIERS: Tier[] = [
{ minQty: 25, discountPercent: 15 },
{ minQty: 10, discountPercent: 10 },
];
export function run(input: RunInput): FunctionRunResult {
// Parse tier config from metafield, fall back to defaults
let tiers: Tier[] = DEFAULT_TIERS;
const metafieldValue = input.discountNode?.metafield?.value;
if (metafieldValue) {
try {
const parsed = JSON.parse(metafieldValue) as Tier[];
// Sort descending so we match the highest qualifying tier first
tiers = parsed.sort((a, b) => b.minQty - a.minQty);
} catch {
// Malformed metafield JSON — use defaults silently
}
}
// Sum total quantity across all cart lines
const totalQty = input.cart.lines.reduce(
(sum, line) => sum + line.quantity,
0
);
// Find the highest-qualifying tier
const matchedTier = tiers.find((t) => totalQty >= t.minQty);
if (!matchedTier) {
// No tier matches — return no discounts
return { discounts: [], discountApplicationStrategy: "FIRST" };
}
return {
discounts: [
{
targets: input.cart.lines.map((line) => ({
cartLine: { id: line.id },
})),
value: {
percentage: { value: String(matchedTier.discountPercent) },
},
message: `Volume discount: ${matchedTier.discountPercent}% off`,
},
],
discountApplicationStrategy: "FIRST",
};
}Expected outcome: TypeScript compilation should pass with no errors. Run `npx tsc --noEmit` from the extension directory to verify. The generated types from Step 2 ensure type safety on RunInput and FunctionRunResult.
Build and deploy the Function
Functions are compiled to WASM locally and uploaded to Shopify's infrastructure as part of shopify app deploy. The CLI handles the TypeScript-to-WASM compilation via Javy, then uploads the compiled module. After deploy, Shopify registers the Function on your app and returns a Function ID — you will need this ID in the next step.
# From your app root:
npm run build # Compiles TS → WASM inside the extension
shopify app deploy # Uploads all extensions including the Function
# CLI output will include:
# ✓ volume-discount Function deployed
# Function ID: 01HXYZ... ← save this
# To verify in Admin: Apps → [your app] → ExtensionsExpected outcome: The CLI prints a Function ID. Your Function is now registered on the development store and available to be referenced by a discount record.
Create a discount referencing your Function
Functions do not activate on their own. You create a discount record that points to your Function. The discount record is what Launchpad, Flow automations, or manual merchant actions will enable and disable. You can create it through the Shopify Admin (Discounts → Create discount → App discount → select your Function) or via the Admin GraphQL API:
mutation CreateVolumeDiscount($functionId: String!) {
discountAutomaticAppCreate(
automaticAppDiscount: {
title: "Volume Discount"
functionId: $functionId
startsAt: "2026-01-01T00:00:00Z"
}
) {
automaticAppDiscount {
discountId
title
status
}
userErrors {
field
message
}
}
}Expected outcome: A discount record is created in Active status. It will now be evaluated on every checkout on the store. Confirm by navigating to Discounts in the Admin and finding the Volume Discount entry.
Test in checkout
Open your development store's storefront in a browser. Add a product to the cart. Test three scenarios: quantity 5 (should show no discount), quantity 10 (should show 10% discount in the order summary with the message "Volume discount: 10% off"), and quantity 25 (should show 15% discount). Use the CLI log flag to watch Function executions in real time while you test.
# Stream Function execution logs during checkout testing:
shopify app function run --log
# Each checkout evaluation prints:
# --- Invocation ---
# Input: { cart: { lines: [{ id: '...', quantity: 10 }] }, discountNode: {...} }
# Output: { discounts: [{ message: 'Volume discount: 10% off', ... }], ... }
# Execution time: 1.4ms
# Status: successExpected outcome: Correct discount tiers appear in checkout. Execution time in logs stays under 5ms. The discount line in the cart summary shows the tier message you set.
Register a metafield definition for merchant-configurable tiers
The Function already reads discountNode.metafield(namespace: "volume_discount", key: "tiers"). Now register the metafield definition so Shopify knows the schema, and merchants (or your App Bridge UI) can write valid tier data to it. The definition also enables Admin validation of the JSON before it reaches the Function.
# Register a metafield definition on DiscountAutomaticApp:
mutation CreateMetafieldDefinition {
metafieldDefinitionCreate(
definition: {
name: "Volume Discount Tiers"
namespace: "volume_discount"
key: "tiers"
type: "json"
ownerType: DISCOUNT
access: { admin: MERCHANT_READ_WRITE }
}
) {
createdDefinition { id name }
userErrors { field message }
}
}
# Example metafield value to write to the discount:
# [
# { "minQty": 10, "discountPercent": 10 },
# { "minQty": 25, "discountPercent": 15 },
# { "minQty": 50, "discountPercent": 20 }
# ]Expected outcome: The metafield definition is created. You can now write tier data to the discount's metafield via the Admin API or your App Bridge merchant UI. Changes take effect on the next cart evaluation — no redeploy required.
Monitor Function executions in production
Shopify retains Function execution logs for 72 hours, accessible via the Partner Dashboard or the Admin GraphQL API. For production monitoring of whether the volume discount is firing correctly, set up a webhook on orders/create and inspect the discount_applications array on each order payload. Orders with a volume discount will have a discount_applications entry with your Function's discount title. Track the discount application rate and average discount percent to confirm tier configuration is producing intended outcomes.
# Query Function run logs via CLI (development):
shopify app function run --log --tail
# Query execution logs via Admin API (production):
# GET /admin/api/2025-07/graphql.json
# query {
# shop {
# functions(first: 5) {
# nodes {
# id
# title
# logs(first: 20) {
# nodes { status executionTime input output createdAt }
# }
# }
# }
# }
# }Expected outcome: You can observe per-execution timing (should stay well under 5ms P99) and verify the Function output matches expected tier logic across a variety of order sizes.
What's next
You have built a working, production-deployable volume discount Function with merchant-configurable tiers and live execution monitoring. From here, the natural extensions are: (1) scoping the volume discount to specific collections or product tags rather than the entire cart, which requires adding product tag or collection membership data to your input.graphql; (2) exploring delivery customization Functions — the same scaffold, input query, and deploy cycle applies, but the output schema targets shipping rates rather than discounts; and (3) payment customization Functions for hiding payment methods conditionally.
Consider also integrating the volume discount into a Shopify Flow automation: a Flow trigger on orders/created can check if the volume discount fired (via the discount_applications field) and tag the order or customer for downstream CRM segmentation. This pattern — Function fires the discount at checkout, Flow tags the customer post-order — is a clean way to build loyalty tiers without external tooling.
The complete Shopify Functions API reference — input schemas, output types, all available Function types, and language-specific SDK documentation — is at shopify.dev/docs/api/functions. The API changelog there tracks breaking changes across API versions — subscribe to it if you are maintaining Functions in production.
Common mistakes and how to avoid them
Not regenerating types after changing input.graphql. Every time you modify the input query, you must re-run shopify app function typegen to regenerate the TypeScript types. If you skip this step, your RunInput type will be stale and your IDE will lie to you about which fields are available. This causes runtime null-reference errors that are confusing to debug because they look like Shopify is not returning data you expected.
Forgetting to create a discount record after deploying. A deployed Function has no effect on any checkout until a discount record references it and is set to Active. This is the most common "my Function isn't doing anything" issue new developers encounter. Verify in the Admin that the discount exists, is Active, and references the correct Function ID.
Using string comparison instead of numeric comparison for quantities. If your tier config comes from a JSON metafield and you parse it without type validation, minQty values can arrive as strings rather than numbers. The comparison totalQty >= '10' will behave unexpectedly in JavaScript. Always coerce tier values to numbers immediately after JSON parse: Number(tier.minQty), or validate the schema with a library like Zod before using the parsed values.
