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
- Create the bundle parent as a product variant; it’s what the customer sees after the merge.
- 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.
- Run the fixtures with shopify app function run --input tests/two-complete-sets.input.json, then deploy.
- Activate it with cartTransformCreate and pass the bundles in the $app:function-configuration metafield.
Code
shopify.extension.tomlapi_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.graphqlquery 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.
Shopify documentation
- 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.
