Product Bundles
A product bundle lets a single product be sold as a configurable kit of other products — a mix of fixed "included" items and customer-customizable groups. On the Product template, a bundle product exposes its full structure through a set of Liquid drops, so a section (for example, a custom bundle builder) can render the groups, items, pricing, and subscription options.
Bundle variables are only populated for products that are bundles. For a non-bundle product,
product.bundle_configis an empty object ({}) andproduct.product_bundle_groupsis an empty array.
Where bundles are exposed
All bundle data hangs off the product drop (Product scope). These are the entry points:
{ "product": { "bundle_config": "Bundle configuration object — empty {} for non-bundle products", "mutually_exclusive_groups <Array>": "Array of mutually-exclusive group sort_order pairs — groups that cannot be selected together", "is_enrollment": "Boolean — true when the product's bundle is an enrollment bundle", "track_inventory_on_bundle_items": "Boolean — whether bundle item inventory is tracked at the item level", "bundle_in_stock": "Boolean — whether the full bundle is in stock for the storefront warehouse", "bundle_available_quantity": "Available quantity for the full bundle (null if unlimited)", "product_bundles <Array>": "Statically bundled products — see ProductBundle below", "product_bundle_groups <Array>": "Bundle groups and their items — see ProductBundleGroup below", "subscription_plans <Array>": "Bundle-wide subscription plans — the bundle product's own plans (same shape as an item's subscription_plans). Drives the bundle-wide Subscribe & Save option; empty when the bundle has no plans." } }
Guard for bundle data before rendering:
{'%' if product and product.product_bundle_groups '%'}
{'%' for group in product.product_bundle_groups '%'}
<h3>{{ group.title | default: 'Group' }}</h3>
{'%' endfor '%'}
{'%' endif '%'}
product.product_bundle_groups[] — ProductBundleGroup
Each entry is a bundle group: either a fixed set of included items, or a customizable group the customer picks from. Items live under bundle_group_items.
{ "id": "Bundle group ID", "title": "Bundle group title", "description": "Bundle group description", "group_type": "Group type: `included` (fixed items) or `customizable` (customer picks)", "sort_order": "Sort order of the group on the storefront", "selection_type": "Completion rule for customizable groups: `exact`, `min_only`, `max_only`, or `min_and_max`", "min_selections": "Minimum number of selections required (null for `max_only` groups, which have no lower bound)", "max_selections": "Maximum number of selections allowed (null/unbounded for `min_only`; collapses to `min_selections` for `exact`)", "pricing_type": "Pricing type: `fixed_price` or `dynamic_price`", "fixed_price": "Group fixed price as string", "min_price": "Minimum group price as string (dynamic_price groups)", "max_price": "Maximum group price as string (dynamic_price groups)", "compare_at_price": "Compare-at (strikethrough) price as string", "group_cv": "Group commission value — deprecated, prefer per-country CV via country_pricing", "group_qv": "Group qualifying value — deprecated, prefer per-country QV via country_pricing", "track_quantity": "Boolean — whether to track quantity at the group level", "country_pricing <Array>": { "country_code": "ISO country code (e.g., US)", "enabled": "Boolean — whether this country entry is active", "price": "Country-specific price as string", "subscription_price": "Country-specific subscription price as string", "compare_price": "Country-specific compare-at price as string", "cv": "Country-specific commission value", "qv": "Country-specific qualifying value", "wholesale": "Country-specific wholesale price", "points": "Country-specific points value", "wholesale_cv": "Country-specific wholesale commission value", "wholesale_qv": "Country-specific wholesale qualifying value" }, "pricing_config": "Raw pricing configuration object (source for fixed_price, country_pricing, etc.)", "allow_subscriptions": "Boolean — whether items in this group can be subscribed", "force_subscriptions": "Boolean — whether subscription is required for every item in this group", "image_urls <Array>": "Array of group image URLs", "images <Array>": { "id": "Image ID", "src": "Image src url", "url": "Image url" }, "bundle_group_items <Array>": { "id": "Bundle group item ID", "quantity": "Quantity of this item in the bundle", "sort_order": "Sort order of the item within the group", "price": "Item price as string", "cv": "Item commission value", "qv": "Item qualifying value", "is_default": "Boolean — whether this item is pre-selected by default", "display_quantity": "Display quantity for the item", "max_quantity": "Maximum quantity a customer can add of this item", "allow_subscription": "Boolean — whether this item allows subscription", "force_subscription": "Boolean — whether subscription is required for this item", "image_url": "Item image URL", "available_country_codes <Array>": "Array of country ISO codes the item is available in", "country_prices": "Object mapping country ISO code to item price", "country_subscription_prices": "Object mapping country ISO code to item subscription price", "subscription_plan_id": "Subscription plan ID for the item (if any)", "subscription_plans <Array>": { "id": "Subscription plan ID", "name": "Plan name", "billing_interval": "Billing interval count", "billing_interval_unit": "Billing interval unit (e.g., month, week)", "shipping_interval": "Shipping interval count", "shipping_interval_unit": "Shipping interval unit", "trial_period": "Trial period count", "trial_period_unit": "Trial period unit", "price_adjustment_type": "Discount type: `percentage` or `fixed_amount`", "price_adjustment_amount": "Discount amount", "max_skips": "Maximum number of skips allowed", "savings_display_mode": "How savings are displayed" }, "config": "Raw item configuration object", "product_id": "ID of the product the item's variant belongs to", "product_title": "Title of the product the item's variant belongs to", "product_image_url": "Image URL of the product the item's variant belongs to", "product_bundles <Array>": "Statically bundled products for this item's product (same shape as `product.product_bundles[]`)", "variant": "Variant drop for the bundle item (same shape as `products[].variants[]`)", "in_stock": "Boolean — whether the bundle item is in stock", "available_quantity": "Available quantity (number of sets) for the bundle item, or null if untracked" }, "created_at": "ISO8601 creation timestamp (or null)", "updated_at": "ISO8601 update timestamp (or null)" }
product.product_bundles[] — ProductBundle
Each entry is a single statically-bundled product/variant included with the parent product.
{ "id": "Product bundle ID", "quantity": "Quantity of the bundled product", "product_id": "ID of the parent product that owns the bundle", "bundled_variant_id": "ID of the bundled variant", "tax_percentage": "Tax percentage for this bundled item", "display_externally": "Boolean — whether to display this item on the storefront", "title": "Bundled product title (null if the bundled variant is missing)", "image_url": "Bundled product image URL (null if the bundled variant is missing)", "bundled_product": "Product drop for the bundled product (null if the variant was discarded)", "bundled_variant": "Variant drop for the bundled variant (same shape as `products[].variants[]`)", "in_stock": "Boolean — whether the bundled variant is in stock", "available_quantity": "Available quantity (number of sets) for the bundled variant, or null if untracked" }
Building a bundle section
A bundle product is just a product that exposes product_bundle_groups. To build a custom bundle builder, you follow six steps: embed the data → render groups & items → track selections → enforce each group's rule → show a live total → submit to the cart.
The reference implementation is the global product_bundle section (app/themes/templates/global/sections/product_bundle/index.liquid). This guide distills its core flow into the minimum that works — it deliberately leaves out the advanced features (see What this guide leaves out at the end). The server always re-validates the submitted bundle (CartBundleValidator), so the client logic here is for UX, not enforcement.
1. Embed the bundle data
Render the bundle product as JSON into a <script> tag so your JavaScript can read the whole structure once. Guard it so it only renders for actual bundles:
{'%' if product.product_bundle_groups and product.product_bundle_groups.size > 0 '%'}
<script type="application/json" data-bundle-data>{{ product | json }}</script>
{'%' endif '%'}
Place the section on the product template (where
productis in scope). For a standalone landing page, add a{ "type": "product", "id": "bundle_product" }schema setting and usesection.settings.bundle_productinstead — that is what the reference section does.
2. Render the groups and items
Loop the groups (ordered by sort_order) and their items. Mark each card with the data the script needs, and distinguish customizable groups (the shopper picks) from included groups (fixed, read-only):
<div data-bundle-builder> {'%' assign groups = product.product_bundle_groups | sort: 'sort_order' '%'} {'%' for group in groups '%'} <div class="bundle-group" data-group-id="{{ group.id }}" data-max="{{ group.max_selections | default: 0 }}"> <h3>{{ group.title }}</h3> {'%' for item in group.bundle_group_items '%'} <div class="bundle-card" data-variant-id="{{ item.variant.id }}"> <span>{{ item.product_title | default: item.variant.title }}</span> <span>{{ item.price }}</span> {'%' if group.group_type == 'customizable' '%'} <button type="button" data-add>Add</button> {'%' else '%'} <span>{{ item.quantity }}× included</span> {'%' endif '%'} </div> {'%' endfor '%'} </div> {'%' endfor '%'} <div class="bundle-summary"> <strong>Total: <span data-total>0.00</span></strong> <button type="button" data-add-bundle disabled>Add Bundle to Cart</button> </div> </div>
3–5. Track selections, enforce the rule, show the total
A small script holds the selection state, enforces each group's selection_type, computes a running total, and gates the "Add Bundle to Cart" button until every group is complete. The groupComplete helper mirrors isGroupComplete in the reference section — see the selection_type field in the ProductBundleGroup reference above for the four rules.
<script> (function () { var root = document.querySelector('[data-bundle-builder]'); var data = JSON.parse(document.querySelector('[data-bundle-data]').textContent); var bundleVariantId = data.selected_or_first_available_variant.id; var selections = {}; // { groupId: { variantId: qty } } function countFor(gid) { var sel = selections[gid] || {}, n = 0; for (var v in sel) n += sel[v]; return n; } // Completion rule per group — mirrors isGroupComplete in the reference section. function groupComplete(group, count) { if (group.group_type !== 'customizable') return true; var min = group.min_selections || 0; var max = group.max_selections || Infinity; switch (group.selection_type) { case 'exact': return count === min; case 'min_only': return count >= min; case 'max_only': return true; default: return count >= min && count <= max; // min_and_max } } // Add an item, respecting the group's max_selections. root.addEventListener('click', function (e) { var btn = e.target.closest('[data-add]'); if (!btn) return; var groupEl = btn.closest('.bundle-group'); var gid = groupEl.dataset.groupId; var vid = btn.closest('.bundle-card').dataset.variantId; var max = parseInt(groupEl.dataset.max) || Infinity; selections[gid] = selections[gid] || {}; if (countFor(gid) >= max) return; selections[gid][vid] = (selections[gid][vid] || 0) + 1; refresh(); }); // Recompute total + enable checkout when every group is complete. function refresh() { var total = 0, complete = true; data.product_bundle_groups.forEach(function (group) { var sel = selections[group.id] || {}; for (var vid in sel) { var item = (group.bundle_group_items || []).find(function (i) { return i.variant && String(i.variant.id) === vid; }); if (item) total += parseFloat(item.price || 0) * sel[vid]; } if (!groupComplete(group, countFor(group.id))) complete = false; }); root.querySelector('[data-total]').textContent = total.toFixed(2); root.querySelector('[data-add-bundle]').disabled = !complete; } refresh(); // Wire the "Add Bundle to Cart" button here (step 6) — it shares // data, selections, and bundleVariantId from this same closure. })(); </script>
6. Add the bundle to the cart
On checkout, build the bundled_items payload from the customizable selections — plain included-group items are reconstituted server-side (submitting them too is rejected as a duplicate). The one exception is included groups that belong to a mutually exclusive set: those must be submitted so the server knows which branch was chosen — the Mutual exclusivity section below shows the one-line guard change for that case. Add this inside the same IIFE (it shares data, selections, and bundleVariantId):
root.querySelector('[data-add-bundle]').addEventListener('click', function () { var bundledItems = []; data.product_bundle_groups.forEach(function (group) { if (group.group_type !== 'customizable') return; // included items reconstituted server-side (see exclusivity exception below) var sel = selections[group.id] || {}; for (var vid in sel) { bundledItems.push({ variant_id: parseInt(vid), quantity: sel[vid], product_bundle_group_id: parseInt(group.id) }); } }); window.FairShareSDK.addCartItems(bundleVariantId, { quantity: 1, bundled_items: bundledItems }); });
addCartItems(bundleVariantId, options) is the integration contract. Each bundled_items entry needs variant_id, quantity, and product_bundle_group_id. The server (CartBundleValidator) validates selection counts, availability, mutual exclusivity, pricing, and subscription rules, then recomputes the price — so a tampered client payload is caught at checkout.
Mutual exclusivity
Some groups are wired so that only one of them can be active at a time — e.g. "Choose your free gift: A or B". This lives on product.mutually_exclusive_groups, a list of sets. Two things to know:
- Members are referenced by group
sort_order, notid— so map them once. - A set is either a bare array (
[0, 1]) or an object carrying a default branch ({ "ids": [0, 1], "default": 0 }).
Build the set membership up front:
// mutually_exclusive_groups members are group SORT_ORDERS, not ids. function buildExclusiveSets(data) { var sortToId = {}; data.product_bundle_groups.forEach(function (g) { sortToId[g.sort_order] = String(g.id); }); return (data.mutually_exclusive_groups || []).map(function (set) { var sortOrders = Array.isArray(set) ? set : (set.ids || []); return { ids: sortOrders.map(function (so) { return sortToId[so]; }).filter(Boolean), defaultId: (set && set.default != null) ? sortToId[set.default] : null }; }); } function siblingsOf(sets, gid) { var out = []; sets.forEach(function (set) { if (set.ids.indexOf(String(gid)) === -1) return; set.ids.forEach(function (id) { if (id !== String(gid)) out.push(id); }); }); return out; }
First, alongside selections at the top of the IIFE (page scope — these must persist across clicks, so they do not live inside the handler):
var exclusiveSets = buildExclusiveSets(data); var disabledGroups = {}; // exclusive branches the shopper isn't using
Then, inside the [data-add] click handler — picking in one branch clears its siblings and marks them disabled so the completeness check can skip them:
// inside the [data-add] click handler, before adding the item: siblingsOf(exclusiveSets, gid).forEach(function (sib) { selections[sib] = {}; // discard the losing branch's selected items disabledGroups[sib] = true; // and skip it in refresh() }); delete disabledGroups[gid]; // the branch just picked is active again
A cleared branch keeps empty selections, so refresh() must skip disabled groups — otherwise a customizable branch (e.g. min_selections: 1, selection_type: 'exact') stays incomplete and pins the button disabled forever. Update its per-group completeness check:
// in refresh(), replacing the completeness line: if (!disabledGroups[group.id] && !groupComplete(group, countFor(group.id))) complete = false;
On first load, if a set declares a default, pre-select that branch and clear the others so the shopper never starts in a two-branch conflict:
exclusiveSets.forEach(function (set) { if (!set.defaultId) return; set.ids.forEach(function (id) { if (id !== set.defaultId) { selections[id] = {}; disabledGroups[id] = true; } }); });
Checkout exception. When a set includes an included (static) branch, that branch's items must be submitted — otherwise the server can't tell which branch was chosen and rejects the cart with a 422 (mutually_exclusive_groups_selected). Adjust the step 6 guard so exclusive branches are never skipped (here exclusiveSets / siblingsOf are in scope):
data.product_bundle_groups.forEach(function (group) { var inExclusive = siblingsOf(exclusiveSets, group.id).length > 0; if (group.group_type !== 'customizable' && !inExclusive) return; // skip only plain included groups // ...build and push each entry exactly as in step 6 });
The reference section also adds the optional UX around this: a per-group "Choose this group" button, visually dimming the inactive branch, and re-rendering sibling cards. (Skipping
disabledGroupsinrefresh(), shown above, is required for a working button — not polish.) SeegetMutuallyExclusiveGroupIds/updateMutualExclusivityinindex.liquid.
Subscriptions
Bundles support two independent subscription layers. Both rely on subscription_plans (the plan list documented above) and both ride along in the same addCartItems call.
Per-item subscriptions
An item whose product has subscription_plans can be subscribed on its own. The mode is derived exactly like the reference's resolveItemSubscriptionMode:
// "hidden" | "optional" | "forced" function itemSubMode(item, group) { if (!(item.subscription_plans || []).length) return 'hidden'; if (item.force_subscription || group.force_subscriptions) return 'forced'; if (item.allow_subscription) return 'optional'; return 'hidden'; }
Render a Subscribe checkbox for optional / forced items (checked and disabled when forced), plus a plan <select> when the product has 2+ plans. Track the choice keyed by group + variant:
var itemSubs = {}; // { groupId: { variantId: { active, planId } } } // ...when the customer ticks the checkbox / picks a plan: itemSubs[gid] = itemSubs[gid] || {}; itemSubs[gid][vid] = { active: checkbox.checked, planId: parseInt(planSelect.value) };
Bundle-wide subscription
The whole bundle can be sold as a subscription using the bundle product's own plans (data.subscription_plans). Offer a one-time vs. subscribe toggle; if there are no bundle plans, omit the toggle entirely:
var bundlePlans = data.subscription_plans || []; // empty -> no bundle-wide option var bundleSub = { active: false, planId: null }; // ...on the subscribe radio / plan select: bundleSub.active = (radio.value === 'subscription' && radio.checked); bundleSub.planId = parseInt(planSelect.value) || (bundlePlans[0] && bundlePlans[0].id);
Attaching both at checkout
Extend the step-6 payload. Per-item subscriptions decorate each bundled_items entry; the bundle-wide subscription attaches to the top-level options — and note the top-level key is subscribe, not subscription:
// build each entry as a variable so you can decorate it: for (var vid in sel) { var entry = { variant_id: parseInt(vid), quantity: sel[vid], product_bundle_group_id: parseInt(group.id) }; var sub = (itemSubs[group.id] || {})[vid]; if (sub && sub.active && sub.planId) { entry.subscription = true; entry.subscription_plan_id = sub.planId; } bundledItems.push(entry); } // after building bundledItems: var options = { quantity: 1, bundled_items: bundledItems }; if (bundleSub.active && bundleSub.planId) { options.subscribe = true; // top-level uses `subscribe`, not `subscription` options.subscription_plan_id = bundleSub.planId; } window.FairShareSDK.addCartItems(bundleVariantId, options);
CartBundleValidatorconfirms eachsubscription_plan_idbelongs to the item's (or bundle's) product, so no client-side ownership check is needed. A plan'sprice_adjustment_type/price_adjustment_amountare informational for showing savings — the charged amount is always recomputed server-side.
What this guide leaves out
The reference section also handles the following. Reach for it (and the variable reference above) when you need them — each maps to fields already documented on the drops:
- Quantity steppers & per-item
max_quantity—+/−controls instead of a single Add. - Country / region pricing & availability —
country_pricing,country_prices,available_country_codes, and bundle-levelbundle_pricing_config. - Stock states —
in_stock/available_quantityfor out-of-stock and low-stock messaging. - Progress UI — a completion bar driven by each group's required count.
Full reference:
app/themes/templates/global/sections/product_bundle/index.liquid.