Compiling ideas into code…
Wiring up APIs and UI…
Sit back for a moment while the site gets ready.
ShopifyFunctions

Shopify Bundleswith Functions

Why Cart Transform Functions for Custom Bundles

Cart Transform Functions are Shopify's server-side mechanism for reshaping the cart before checkout. They execute as WebAssembly on Shopify infrastructure, meaning the transformation applies universally, Online Store, Shop app, headless storefronts, POS, and B2B channels all see the same bundled cart. Unlike theme JavaScript or app blocks confined to a single storefront surface, Cart Transform operates at the platform level through the cart.transform.run extension point.

Shopify's native bundle app handles basic fixed bundles, but production bundling typically demands more, per-component pricing, conditional merge rules, mix-and-match from curated collections, tiered quantity discounts, and inventory tracking at the component level. Cart Transform Functions give you programmatic control over all of this through three operations: linesMerge, lineExpand, and lineUpdate.

One function limit. Shopify allows a maximum of one Cart Transform function per store. If you need bundles AND other cart transformations (gift wrapping, assembly services, BOGO logic), they must all coexist in the same function. Plan your architecture to route each cart line to the right operation.


The Three Cart Transform Operations

Cart Transform exposes three distinct operations. Understanding when to use each, and how they interact, is the foundation of any custom bundle implementation.

linesMerge combines multiple separate cart lines into a single bundle line. The customer adds individual items to cart, and the function merges them into one line that displays a parent variant (the "bundle" product) while the original items are still tracked for fulfillment. This is the pattern for mix-and-match bundles where customers build their own selection from a catalog.

lineExpand does the reverse, it breaks a single cart line (a bundle product) into its component items. A merchant creates one "bundle product" SKU, and the function expands it into individual components at checkout. Each component can have a fixed price so the bundle total stays correct, and inventory decrements per component rather than per phantom bundle SKU. This is the pattern for fixed bundles with predetermined contents.

lineUpdate overrides price, title, or image on a cart line without merging or expanding. This operation is restricted to Shopify Plus and development stores only. Use it for dynamic pricing adjustments on individual items, "buy 3+ of this item and the unit price drops to $X."

Operation priority. Only one Cart Transform function can be installed per store. If you need both merge and expand operations, implement them in the same function, inspect each cart line and decide which operation applies. When multiple operations target the same line, Shopify resolves collisions with a priority system: lineExpand takes priority over linesMerge and lineUpdate.


Case Study: Mix-and-Match Snack Box

A specialty food store sells a "Build Your Own Snack Box" where customers pick items from a curated snack collection. The business requirement is tiered pricing based on bundle size, the more items, the bigger the discount:

  • Pick 4 items, 15% off the combined price
  • Pick 6 items, 20% off the combined price
  • Pick 8 items, 25% off the combined price

The cart behavior works like this: the customer adds individual snack items through a bundle builder UI on the storefront. Each item gets a cart line attribute _bundle_id, set by the storefront JavaScript when the customer adds items through the builder. Items sharing the same _bundle_id are grouped by the Cart Transform Function, which validates the count against tier thresholds, calculates the discounted total, and returns a linesMerge operation that collapses them into a single "Snack Box" line in the cart.

The result: the customer sees one clean line item, "Snack Box (6 items, 20% off)", instead of six separate lines. The individual items are preserved behind the scenes for fulfillment and inventory tracking.


Scaffolding the Cart Transform Function

Start by generating the extension scaffold with the Shopify CLI:

Terminal
shopify app generate extension --template cart_transform --name snack-box-bundle

This creates the following directory structure inside your app:

extensions/snack-box-bundle/
├── src/
│   ├── run.graphql
│   └── run.js
├── input.graphql
└── shopify.extension.toml

The TOML configuration file defines the extension type and build settings:

extensions/snack-box-bundle/shopify.extension.toml
api_version = "2025-07"
type = "cart_transform"

[build]
command = "npm exec -- shopify app function build"
path = "dist/function.wasm"

The type = "cart_transform" tells Shopify this function targets the cart.transform.run extension point. The build command compiles your JavaScript into WebAssembly, the same sandboxed runtime used by all Shopify Functions.


The Input Query

The input.graphql file defines what data your function receives from Shopify at runtime. For the mix-and-match merge operation, the query needs cart line details, the bundle grouping attribute, eligibility tags, and app-owned configuration:

extensions/snack-box-bundle/input.graphql
query RunInput {
  cart {
    lines {
      id
      quantity
      cost {
        amountPerQuantity {
          amount
          currencyCode
        }
      }
      merchandise {
        ... on ProductVariant {
          id
          title
          product {
            id
            title
            hasAnyTag(tags: ["bundle-eligible"])
          }
          image {
            url
          }
        }
      }
      attribute(key: "_bundle_id") {
        value
      }
    }
  }
  cartTransform {
    metafield(namespace: "$app:bundle_config", key: "config") {
      value
    }
  }
  presentmentCurrencyRate
}

The attribute(key: "_bundle_id") field reads the bundle grouping identifier set by the storefront JavaScript, this is how items are associated with a specific bundle instance. The hasAnyTag field checks whether each product is tagged bundle-eligible, preventing non-bundle items from being swept into merge operations.

The cartTransform.metafield reads app-owned configuration stored on the Cart Transform itself, tier thresholds, the parent bundle variant ID, and other settings that merchants can update without redeploying code. And presentmentCurrencyRate provides the exchange rate between the store's base currency and the customer's selected currency, which is essential when calculating fixed prices for multi-currency stores.


The Run Function: linesMerge in Action

The run.js file contains the core logic. It groups cart lines by their _bundle_id attribute, validates eligibility, determines the discount tier, and returns linesMerge operations:

extensions/snack-box-bundle/src/run.js
// @ts-check

/**
 * @typedef {import("../generated/api").RunInput} RunInput
 * @typedef {import("../generated/api").CartTransformRunResult} CartTransformRunResult
 * @typedef {import("../generated/api").LinesMergeOperation} LinesMergeOperation
 */

const TIERS = [
  { minItems: 8, discount: 0.25 },
  { minItems: 6, discount: 0.20 },
  { minItems: 4, discount: 0.15 },
];

/**
 * @param {RunInput} input
 * @returns {CartTransformRunResult}
 */
export function run(input) {
  const config = input.cartTransform?.metafield?.value
    ? JSON.parse(input.cartTransform.metafield.value)
    : null;

  if (!config) return { operations: [] };

  const bundleGroups = groupByBundleId(input.cart.lines);
  const mergeOps = [];

  for (const [bundleId, lines] of Object.entries(bundleGroups)) {
    const totalItems = lines.reduce((sum, l) => sum + l.quantity, 0);
    const tier = TIERS.find(t => totalItems >= t.minItems);

    if (!tier) continue;

    const totalPrice = lines.reduce((sum, l) => {
      const price = parseFloat(l.cost.amountPerQuantity.amount);
      return sum + (price * l.quantity);
    }, 0);

    const discountedPrice = totalPrice * (1 - tier.discount);
    const pricePerUnit = (discountedPrice / totalItems).toFixed(2);

    mergeOps.push({
      linesMerge: {
        parentVariantId: config.bundleVariantId,
        title: `Snack Box (${totalItems} items, ${tier.discount * 100}% off)`,
        cartLines: lines.map(line => ({
          cartLineId: line.id,
          quantity: line.quantity,
        })),
        price: {
          adjustment: {
            fixedPricePerUnit: {
              amount: pricePerUnit,
            },
          },
        },
        attributes: [
          { key: "_bundle_discount", value: `${tier.discount * 100}%` },
          { key: "_bundle_id", value: bundleId },
        ],
      },
    });
  }

  return { operations: mergeOps };
}

function groupByBundleId(lines) {
  const groups = {};
  for (const line of lines) {
    const bundleId = line.attribute?.value;
    if (!bundleId) continue;
    if (line.merchandise?.__typename !== "ProductVariant") continue;
    if (!line.merchandise.product.hasAnyTag) continue;

    if (!groups[bundleId]) groups[bundleId] = [];
    groups[bundleId].push(line);
  }
  return groups;
}

The function walks through the cart in three passes. First, groupByBundleId collects all eligible cart lines that share the same _bundle_id attribute, filtering out anything that isn't a ProductVariant or isn't tagged bundle-eligible.

For each bundle group, it calculates the total item count and finds the best matching discount tier. The TIERS array is ordered from largest to smallest so the first match is always the best tier the customer qualifies for.

Finally, it computes the discounted total and distributes the price evenly across all items using fixedPricePerUnit. The linesMerge operation tells Shopify to collapse the individual lines into a single line using the parentVariantId, which should point to a dedicated "Snack Box" product variant in the store catalog. The attributes array preserves the bundle metadata through checkout for order processing and analytics.


Fixed Bundles with lineExpand

The merge operation handles mix-and-match bundles where customers build their own selection. For fixed bundles, a "Skincare Starter Kit" that always contains the same three products, the pattern reverses. The merchant creates a single bundle product, and the Cart Transform function expands it into individual components for inventory and fulfillment.

The input query for expand reads component data from a product metafield:

input.graphql (expand additions)
query RunInput {
  cart {
    lines {
      id
      quantity
      merchandise {
        ... on ProductVariant {
          id
          product {
            bundleComponents: metafield(namespace: "$app:bundles", key: "components") {
              value
            }
          }
        }
      }
    }
  }
  presentmentCurrencyRate
}

The bundleComponents metafield stores a JSON array defining each component, variant ID, quantity per bundle, and price allocation. The expand function reads this and returns a lineExpand operation:

src/run.js (expand logic)
function buildExpandOperations(lines, presentmentCurrencyRate) {
  const expandOps = [];

  for (const line of lines) {
    const variant = line.merchandise;
    if (variant.__typename !== "ProductVariant") continue;

    const componentData = variant.product.bundleComponents?.value;
    if (!componentData) continue;

    const components = JSON.parse(componentData);

    expandOps.push({
      lineExpand: {
        cartLineId: line.id,
        title: variant.product.title || "Bundle",
        expandedCartItems: components.map(comp => ({
          merchandiseId: comp.variantId,
          quantity: comp.quantity,
          price: {
            adjustment: {
              fixedPricePerUnit: {
                amount: (parseFloat(comp.price) * presentmentCurrencyRate).toFixed(2),
              },
            },
          },
        })),
      },
    });
  }

  return expandOps;
}

Each component gets a fixedPricePerUnit so the sum of component prices equals the bundle's intended price. The presentmentCurrencyRate multiplier converts these fixed prices into the customer's selected currency, without it, multi-currency stores would show incorrect totals. Inventory decrements happen per component, not per bundle SKU, which means the merchant doesn't need to manage a separate "bundle inventory" count.


Combining Merge and Expand in One Function

Since Shopify enforces a one-function-per-store limit for Cart Transform, any store that needs both mix-and-match (merge) and fixed (expand) bundles must handle both in the same function. The architecture is straightforward, build each operation type as a separate function and combine the results:

src/run.js (combined)
export function run(input) {
  const mergeOps = buildMergeOperations(input);
  const expandOps = buildExpandOperations(input.cart.lines, input.presentmentCurrencyRate);

  return {
    operations: [...mergeOps, ...expandOps],
  };
}

The buildMergeOperations function contains the grouping and tier logic from the merge implementation, while buildExpandOperations handles the metafield-based expansion. Both return arrays of operation objects, and the combined function spreads them into a single operations array.

Avoid operation collisions. If a cart line could theoretically match both a merge and an expand rule, the expand takes priority and the merge is discarded. Structure your eligibility checks (tags, metafields, attributes) so each line routes to exactly one operation type.


Bundle Pricing Strategies

The merge and expand operations are the structural mechanism, but pricing is where the business logic lives. Here are the most common patterns and how to implement each one.

Percentage off bundle, the approach used in the snack box case study. Sum individual prices, apply the tier discount, and distribute evenly across merged items using fixedPricePerUnit.

Fixed bundle price, ignore individual item prices entirely. The bundle costs a flat rate regardless of which items the customer selects:

Fixed price calculation
const FIXED_BUNDLE_PRICE = 39.99;
const pricePerUnit = (FIXED_BUNDLE_PRICE / totalItems).toFixed(2);

Tiered pricing table, different discount levels based on bundle size. The snack box example uses this model:

Items Discount Example (4 × $10 items)
4 15% off $34.00
6 20% off $48.00
8 25% off $60.00

BOGO / Buy X Get Y free, merge all qualifying items and set the cheapest item's allocated price to zero. The total discount equals the cheapest item's price, and the remaining items share the rest of the cost. This requires sorting the items by price before allocating per-unit amounts.


Building, Deploying, and Activating

With the function written, the deployment workflow is three commands followed by a GraphQL activation:

Terminal
shopify app function typegen --path extensions/snack-box-bundle

shopify app function build --path extensions/snack-box-bundle

shopify app deploy

The typegen command generates TypeScript type definitions from your input.graphql query, which powers the JSDoc type annotations in the run function. The build command compiles your JavaScript into WebAssembly. And deploy pushes the compiled function to Shopify.

After deploying, activate the function by running the cartTransformCreate mutation in a GraphQL client (like the Shopify Admin API GraphiQL explorer):

Activation mutation
mutation {
  cartTransformCreate(functionId: "YOUR_FUNCTION_ID") {
    cartTransform { id }
    userErrors { field message }
  }
}

To find your function ID, query the shopifyFunctions endpoint:

Query function ID
query {
  shopifyFunctions(first: 25, apiType: "cart_transform") {
    nodes {
      id
      title
      app { title }
    }
  }
}

Use functionId, not functionHandle. The handle can be unreliable across environments. Always query shopifyFunctions to get the stable ID before activating.


Testing Your Function Locally

Before deploying, test your function locally using the Shopify CLI's function runner. Create a JSON file that mirrors the shape of your input.graphql response:

extensions/snack-box-bundle/test.input.json
{
  "cart": {
    "lines": [
      {
        "id": "gid://shopify/CartLine/1",
        "quantity": 2,
        "cost": {
          "amountPerQuantity": { "amount": "8.00", "currencyCode": "USD" }
        },
        "merchandise": {
          "__typename": "ProductVariant",
          "id": "gid://shopify/ProductVariant/111",
          "title": "Trail Mix",
          "product": {
            "id": "gid://shopify/Product/1",
            "title": "Trail Mix",
            "hasAnyTag": true
          },
          "image": { "url": "https://cdn.shopify.com/trail-mix.jpg" }
        },
        "attribute": { "value": "bundle_abc123" }
      },
      {
        "id": "gid://shopify/CartLine/2",
        "quantity": 2,
        "cost": {
          "amountPerQuantity": { "amount": "6.00", "currencyCode": "USD" }
        },
        "merchandise": {
          "__typename": "ProductVariant",
          "id": "gid://shopify/ProductVariant/222",
          "title": "Dried Mango",
          "product": {
            "id": "gid://shopify/Product/2",
            "title": "Dried Mango",
            "hasAnyTag": true
          },
          "image": { "url": "https://cdn.shopify.com/dried-mango.jpg" }
        },
        "attribute": { "value": "bundle_abc123" }
      }
    ]
  },
  "cartTransform": {
    "metafield": {
      "value": "{\"bundleVariantId\": \"gid://shopify/ProductVariant/999\"}"
    }
  },
  "presentmentCurrencyRate": 1.0
}

Run the function locally and pipe the test input:

Terminal
shopify app function run --path extensions/snack-box-bundle

The expected output for this test case is a single linesMerge operation. The two cart lines (2 × $8.00 Trail Mix + 2 × $6.00 Dried Mango = 4 items totaling $28.00) qualify for the 4-item tier at 15% off, producing a merged line at $5.95 per unit ($23.80 total).


Storefront JavaScript for Bundle UX

The Cart Transform function handles the server side, grouping, pricing, and merging. But the customer-facing bundle experience requires storefront JavaScript that creates the builder UI, manages cart attributes, and provides real-time feedback.

The core pieces of the storefront implementation are: a bundle builder UI (product grid with "Add to Box" buttons), a unique _bundle_id generated per bundle session, Cart API calls that attach the _bundle_id as a line item property, a progress indicator ("3 of 4 items selected"), and a real-time price preview showing the estimated discount.

The key function for adding items to a bundle via the Cart API:

bundle-builder.js
async function addBundleItem(variantId, bundleId) {
  await fetch('/cart/add.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      items: [{
        id: variantId,
        quantity: 1,
        properties: { _bundle_id: bundleId }
      }]
    })
  });
}

The properties object maps directly to cart line attributes. When the Cart Transform function runs, it reads attribute(key: "_bundle_id") from the input query and groups lines accordingly. The bundleId should be a unique identifier generated when the customer starts building a bundle, a UUID or timestamp-based string works well. This ensures that if the customer builds two separate bundles in the same session, the items stay in their respective groups.

After the merge operation executes, the cart displays a single "Snack Box" line item. Most themes render the merged line's title and image (set by the linesMerge operation), with the individual components accessible through the line item's child structure. If you need custom display of the bundle contents, a cart drawer snippet can iterate the components and render them as a nested list under the parent line.


Edge Cases and Production Concerns

Cart Transform Functions run in production on every cart load, getting the edge cases right is the difference between a polished bundle experience and support tickets.

  • Selling plans, Shopify rejects lineExpand, linesMerge, and lineUpdate operations on cart lines with a selling plan attached. If a bundle item has a subscription, it won't be merged or expanded. Your function should check for selling plans and skip those lines gracefully.
  • Quantity changes, When a customer changes the quantity of a merged bundle line, all component quantities scale proportionally. A quantity-2 purchase of a 4-item box means 8 items total. Test that pricing tiers update correctly when this happens.
  • One function limit, Structure your function to route cart lines to different operation types (bundles, gift wrap, assembly fees) using tags, metafields, or attribute prefixes. This avoids rewriting the function when a new transformation requirement appears.
  • Price rounding, When splitting a fixed price across N items, rounding can cause a 1-cent discrepancy. If a $39.99 bundle splits into 4 items at $10.00 each, the total is $40.00. Assign the remainder to the first item: $9.99 + $10.00 + $10.00 + $10.00 = $39.99.
  • Multi-currency, Always multiply prices by presentmentCurrencyRate when using fixedPricePerUnit. Without this, customers shopping in a non-default currency see prices in the store's base currency.
  • POS compatibility, For full POS support with expand operations, set ProductVariant.requiresComponents = true on the parent bundle variant. This tells POS to expect component data from the Cart Transform function.
  • Cart API vs Checkout API, Merge operations affect what the customer sees in cart and checkout. Some headless implementations fetch cart data before transformation occurs and need to handle the un-merged state, showing individual items with a "bundle" badge, for example.

Key Takeaways

  • Cart Transform Functions run server-side and apply across all sales channels, Online Store, Shop app, headless, POS, and B2B.
  • Use linesMerge for mix-and-match bundles where customers select individual items, and lineExpand for fixed bundles where a single product breaks into components.
  • Only one Cart Transform function is allowed per store, architect it to handle all transformation types from the start.
  • Store bundle configuration in app-owned metafields so merchants can update tiers and pricing without redeploying code.
  • Always multiply fixed prices by presentmentCurrencyRate for multi-currency accuracy.
  • Skip cart lines with selling plans, Cart Transform operations are rejected on subscription lines.
  • Test locally with shopify app function run using realistic input fixtures before deploying to a live store.

If your store needs custom product bundles with mix-and-match logic, tiered pricing, or multi-channel support, I can help you architect and build it. Let's talk about what your commerce model actually needs.

Speculation Rules in Shopify