Expand a bundle into its components

A Shopify cart transform Function that expands a bundle product into its component variants in the cart, using the standard component metafields.

Function API
Cart Transform
Target
cart.transform.run
Operation
lineExpand
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 turns a bundle variant into its component items in the cart and checkout, so inventory and fulfillment use the real products. Components come from the custom.component_reference and custom.component_quantities variant metafields. It uses the lineExpand operation of the cart.transform.run target, Cart Transform API version 2026-10, available on any plan in a public app.

How to use it

  1. Create variant metafield definitions custom.component_reference (list of product variants) and custom.component_quantities (list of integers).
  2. On each bundle variant, list the component variants and their quantities in the same order.
  3. 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.
  4. Run the fixtures with shopify app function run, deploy, and activate the Function with cartTransformCreate.

Code

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

[[extensions]]
name = "Expand a bundle into its components"
handle = "expand-bundle-components"
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
      merchandise {
        __typename
        ... on ProductVariant {
          componentReference: metafield(namespace: "custom", key: "component_reference") {
            jsonValue
          }
          componentQuantities: metafield(namespace: "custom", key: "component_quantities") {
            jsonValue
          }
        }
      }
    }
  }
}
src/cart_transform_run.js
// @ts-check

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

/**
 * The bundle variant lists its components in two variant metafields, the
 * definitions Shopify's bundle guide uses: custom.component_reference (list of
 * variants) and custom.component_quantities (list of integers, same order).
 * Quantities are for one bundle.
 */

/**
 * @param {CartTransformRunInput["cart"]["lines"][number]} line
 * @returns {CartTransformRunResult["operations"][number] | null}
 */
function expandOperation(line) {
  if (line.merchandise.__typename !== "ProductVariant") return null;
  const variantIds = line.merchandise.componentReference?.jsonValue;
  const quantities = line.merchandise.componentQuantities?.jsonValue;
  if (!Array.isArray(variantIds) || variantIds.length === 0) return null;
  // Mismatched lists mean a half-edited bundle; leave the line as it is
  // rather than expand it into the wrong items.
  if (!Array.isArray(quantities) || quantities.length !== variantIds.length) return null;

  // No price on the items: Shopify splits the bundle price across the
  // components by their own prices (its weight price algorithm).
  return {
    lineExpand: {
      cartLineId: line.id,
      expandedCartItems: variantIds.map((merchandiseId, index) => ({ merchandiseId, quantity: quantities[index] })),
    },
  };
}

/**
 * @param {CartTransformRunInput} input
 * @returns {CartTransformRunResult}
 */
export function cartTransformRun(input) {
  const operations = input.cart.lines.map(expandOperation).filter((operation) => operation !== null);
  return { operations };
}
Test input and expected output
tests/bundle-line.input.json
{
  "cart": {
    "lines": [
      {
        "id": "gid://shopify/CartLine/1",
        "merchandise": {
          "__typename": "ProductVariant",
          "componentReference": {
            "jsonValue": ["gid://shopify/ProductVariant/1111111111", "gid://shopify/ProductVariant/2222222222"]
          },
          "componentQuantities": { "jsonValue": [2, 1] }
        }
      },
      {
        "id": "gid://shopify/CartLine/2",
        "merchandise": { "__typename": "ProductVariant", "componentReference": null, "componentQuantities": null }
      }
    ]
  }
}
tests/bundle-line.output.json
{
  "operations": [
    {
      "lineExpand": {
        "cartLineId": "gid://shopify/CartLine/1",
        "expandedCartItems": [
          { "merchandiseId": "gid://shopify/ProductVariant/1111111111", "quantity": 2 },
          { "merchandiseId": "gid://shopify/ProductVariant/2222222222", "quantity": 1 }
        ]
      }
    }
  ]
}
tests/mismatched-lists.input.json
{
  "cart": {
    "lines": [
      {
        "id": "gid://shopify/CartLine/1",
        "merchandise": {
          "__typename": "ProductVariant",
          "componentReference": {
            "jsonValue": ["gid://shopify/ProductVariant/1111111111", "gid://shopify/ProductVariant/2222222222"]
          },
          "componentQuantities": { "jsonValue": [2] }
        }
      }
    ]
  }
}
tests/mismatched-lists.output.json
{ "operations": [] }
tests/no-bundles.input.json
{
  "cart": {
    "lines": [
      { "id": "gid://shopify/CartLine/1", "merchandise": { "__typename": "ProductVariant", "componentReference": null, "componentQuantities": null } }
    ]
  }
}
tests/no-bundles.output.json
{ "operations": [] }

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

Limits and edge cases

  • No component prices are sent, so Shopify splits the bundle price across the components in proportion to their own prices.
  • Shopify rejects cart transform operations when a selling plan (subscription, pre-order) is on the line.
  • An expanded line can have at most 150 components.
  • One cart transform Function per app per store; if two operations target the same line, lineExpand wins.

Questions

How do product bundles work with Shopify Functions?
A cart transform Function changes how cart lines are presented: lineExpand turns one bundle line into components, linesMerge turns components into one bundle line. Orders keep the component lines with a reference to the bundle parent.
Do I need Shopify Plus for bundles?
Not for lineExpand and linesMerge in a public app. Only lineUpdate (changing a line’s price, title or image) needs Shopify Plus or a development store.
Why doesn’t the cart transform activate?
cartTransformCreate needs the write_cart_transforms scope, and the store must have upgraded to Checkout Extensibility.
  • 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.