Merge cart lines into a bundle

A Shopify cart transform Function that merges component lines into one discounted bundle line when the full set is in the cart. Tested JavaScript.

Function API
Cart Transform
Target
cart.transform.run
Operation
linesMerge
Plan
Any plan in a public app; Shopify Plus in a custom app
App scope
write_cart_transforms
API version
2026-10
Last checked

In short

This cart transform Function merges separate cart lines into one bundle line when every component of a set is in the cart, with an optional percentage off and a custom title. Bundles are defined in a JSON metafield on the cart transform. It uses the linesMerge operation of the cart.transform.run target, Cart Transform API version 2026-10.

How to use it

  1. Create the bundle parent as a product variant; it’s what the customer sees after the merge.
  2. Create an app with the Shopify CLI, add the extension (shopify app generate extension --template cart_transform) and replace its files with the files below.
  3. Run the fixtures with shopify app function run --input tests/two-complete-sets.input.json, then deploy.
  4. Activate it with cartTransformCreate and pass the bundles in the $app:function-configuration metafield.

Code

shopify.extension.toml
api_version = "2026-10"

[[extensions]]
name = "Merge cart lines into a bundle"
handle = "merge-lines-into-bundle"
type = "function"

  [[extensions.targeting]]
  target = "cart.transform.run"
  input_query = "src/cart_transform_run.graphql"
  export = "cart-transform-run"

  [extensions.build]
  command = ""
  path = "dist/function.wasm"
src/cart_transform_run.graphql
query CartTransformRunInput {
  cart {
    lines {
      id
      quantity
      merchandise {
        __typename
        ... on ProductVariant {
          id
        }
      }
    }
  }
  cartTransform {
    metafield(namespace: "$app", key: "function-configuration") {
      jsonValue
    }
  }
}
src/cart_transform_run.js
// @ts-check

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

/**
 * Bundles stored as JSON in the cart transform's metafield. Each bundle has a
 * parent variant (the product customers see after the merge), its components
 * and an optional discount and title.
 * @typedef {{
 *   parentVariantId: string,
 *   components: {variantId: string, quantity: number}[],
 *   percentageDecrease?: number,
 *   title?: string,
 * }} BundleRule
 */

/**
 * @param {CartTransformRunInput} input
 * @returns {CartTransformRunResult}
 */
export function cartTransformRun(input) {
  /** @type {BundleRule[]} */
  const bundles = input.cartTransform?.metafield?.jsonValue?.bundles ?? [];

  // Units still free to merge, per line; a line used by one bundle isn't
  // counted again for the next.
  const available = new Map(input.cart.lines.map((line) => [line.id, line.quantity]));
  /** @param {string} variantId */
  const lineFor = (variantId) =>
    input.cart.lines.find(
      (line) => line.merchandise.__typename === "ProductVariant" && line.merchandise.id === variantId && (available.get(line.id) ?? 0) > 0,
    );

  const operations = [];
  for (const bundle of bundles) {
    // A variant listed twice would be merged from the same line twice; put the total in one component instead.
    const variantIds = bundle.components.map((component) => component.variantId);
    if (new Set(variantIds).size !== variantIds.length) continue;
    const lines = bundle.components.map((component) => lineFor(component.variantId));
    if (lines.some((line) => line === undefined)) continue;
    // How many complete bundles the cart holds.
    const sets = Math.min(
      ...bundle.components.map((component, i) => Math.floor((available.get(lines[i]?.id ?? "") ?? 0) / component.quantity)),
    );
    if (sets < 1) continue;

    const cartLines = bundle.components.map((component, i) => {
      const cartLineId = lines[i]?.id ?? "";
      const quantity = component.quantity * sets;
      available.set(cartLineId, (available.get(cartLineId) ?? 0) - quantity);
      return { cartLineId, quantity };
    });
    operations.push({
      linesMerge: {
        cartLines,
        parentVariantId: bundle.parentVariantId,
        ...(bundle.title ? { title: bundle.title } : {}),
        ...(bundle.percentageDecrease ? { price: { percentageDecrease: { value: String(bundle.percentageDecrease) } } } : {}),
      },
    });
  }

  return { operations };
}
Test input and expected output
tests/incomplete-set.input.json
{
  "cart": {
    "lines": [
      { "id": "gid://shopify/CartLine/1", "quantity": 1, "merchandise": { "__typename": "ProductVariant", "id": "gid://shopify/ProductVariant/1111111111" } },
      { "id": "gid://shopify/CartLine/2", "quantity": 1, "merchandise": { "__typename": "ProductVariant", "id": "gid://shopify/ProductVariant/2222222222" } }
    ]
  },
  "cartTransform": {
    "metafield": {
      "jsonValue": {
        "bundles": [
          {
            "parentVariantId": "gid://shopify/ProductVariant/9999999999",
            "title": "Morning Routine Set",
            "percentageDecrease": 10,
            "components": [
              { "variantId": "gid://shopify/ProductVariant/1111111111", "quantity": 1 },
              { "variantId": "gid://shopify/ProductVariant/2222222222", "quantity": 2 }
            ]
          }
        ]
      }
    }
  }
}
tests/incomplete-set.output.json
{ "operations": [] }
tests/not-configured.input.json
{
  "cart": {
    "lines": [
      { "id": "gid://shopify/CartLine/1", "quantity": 1, "merchandise": { "__typename": "ProductVariant", "id": "gid://shopify/ProductVariant/1111111111" } }
    ]
  },
  "cartTransform": { "metafield": null }
}
tests/not-configured.output.json
{ "operations": [] }
tests/two-complete-sets.input.json
{
  "cart": {
    "lines": [
      { "id": "gid://shopify/CartLine/1", "quantity": 3, "merchandise": { "__typename": "ProductVariant", "id": "gid://shopify/ProductVariant/1111111111" } },
      { "id": "gid://shopify/CartLine/2", "quantity": 4, "merchandise": { "__typename": "ProductVariant", "id": "gid://shopify/ProductVariant/2222222222" } }
    ]
  },
  "cartTransform": {
    "metafield": {
      "jsonValue": {
        "bundles": [
          {
            "parentVariantId": "gid://shopify/ProductVariant/9999999999",
            "title": "Morning Routine Set",
            "percentageDecrease": 10,
            "components": [
              { "variantId": "gid://shopify/ProductVariant/1111111111", "quantity": 1 },
              { "variantId": "gid://shopify/ProductVariant/2222222222", "quantity": 2 }
            ]
          }
        ]
      }
    }
  }
}
tests/two-complete-sets.output.json
{
  "operations": [
    {
      "linesMerge": {
        "cartLines": [
          { "cartLineId": "gid://shopify/CartLine/1", "quantity": 2 },
          { "cartLineId": "gid://shopify/CartLine/2", "quantity": 4 }
        ],
        "parentVariantId": "gid://shopify/ProductVariant/9999999999",
        "title": "Morning Routine Set",
        "price": { "percentageDecrease": { "value": "10" } }
      }
    }
  ]
}

Written by Lintel. MIT license: free to use in your own store and in client work.

Limits and edge cases

  • Several complete sets merge into one line with a higher quantity; leftover units stay as normal lines.
  • The bundle price is the sum of the component prices minus the percentage; with linesMerge the adjustment is based on that sum.
  • Shopify rejects the merge when a line has a selling plan, or when a line has fewer units than the merge asks for.
  • A line used by one bundle isn’t counted again for the next one in the list.
  • Each component must be a different variant, and a set is counted from one cart line per variant; units of the same variant split across lines (for example with different line item properties) aren’t added together.

Questions

What’s the difference between linesMerge and lineExpand?
linesMerge combines lines the customer added separately into one bundle line. lineExpand does the opposite: the customer adds one bundle product and checkout shows its components.
Does the order still contain the component products?
Yes. Shopify’s bundle docs say order lines return the bundle components with a reference to the bundle parent.
Can I change the bundle image?
Yes. linesMerge accepts an image, but the URL must be on Shopify’s CDN, otherwise the operation fails with invalid_image_url.
  • 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.