# 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.

Page: https://lintelagency.com/resources/shopify-functions/merge-lines-into-bundle

## 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.

## Facts

- **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:** 2 November 2026

## 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

```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

```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

```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

```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

```json
{ "operations": [] }
```

### tests/not-configured.input.json

```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

```json
{ "operations": [] }
```

### tests/two-complete-sets.input.json

```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

```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" } }
      }
    }
  ]
}
```

## 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.

## Shopify documentation

- [Cart Transform Function API](https://shopify.dev/docs/api/functions/latest/cart-transform)

## Related

- [Bundles with Cart Transform, and what needs Plus](https://lintelagency.com/journal/shopify-bundles-cart-transform)
- [Expand a bundle into its components](https://lintelagency.com/resources/shopify-functions/expand-bundle-into-components)
- [Configuring Functions with metafields, so rules change without deploys](https://lintelagency.com/journal/configuring-functions-with-metafields)

License: MIT. Free to use in your own store and in client work.
