This feature is available on the Pro plan only. The public metafields are written and kept in sync automatically whenever you save a bundle in the app.
Overview
On the Pro plan, Panori Bundles writes the bundle configuration into a public metafield namespace (panda_bundles) on every mother variant. This lets you build a fully custom bundle picker directly in your theme’s Liquid — no Theme App Extension block required.
Each mother variant gets a metafield with the full bundle configuration for that variant.
| Scope | Namespace | Key | Type | Liquid accessor |
|---|
| Product | panda_bundles | config | json | product.metafields.panda_bundles.config |
| Mother Variant | panda_bundles | bundle_config | json | variant.metafields.panda_bundles.bundle_config |
| Mother Variant | panda_bundles | child_variants | list.variant_reference | variant.metafields.panda_bundles.child_variants |
child_variants is a list.variant_reference — Shopify resolves each entry to a full variant object in Liquid, giving you live access to .price, .title, .image, .available, and all other variant properties.
Data structure
{
"bundleId": "a1b2c3d4-...",
"steps": [
{
"id": "step-uuid",
"label": "Choose your base",
"maxVariants": 1,
"isGift": false,
"childVariants": [
{
"variantId": "gid://shopify/ProductVariant/111",
"productId": "gid://shopify/Product/222",
"price": "29.99",
"available": true,
"imageUrl": "https://cdn.shopify.com/..."
}
]
}
],
"discount": {
"type": "percentage",
"value": 10
}
}
discount.type can be "percentage", "fixed", or "custom". For "custom", the price lives directly on the Shopify variant — no calculation needed.
Building the UI in Liquid
Use bundle_config for step structure and child_variants for live variant data. Combine them by matching variantId from the config against the resolved variant objects.
{% for variant in product.variants %}
{% assign bundle_configs = variant.metafields.panda_bundles.bundle_config.value %}
{% assign child_variants = variant.metafields.panda_bundles.child_variants.value %}
{% if bundle_configs != blank %}
{% assign bundle = bundle_configs[0] %}
<div data-bundle-variant="{{ variant.id }}">
{% for step in bundle.steps %}
<div data-step-id="{{ step.id }}">
<p><strong>{{ step.label }}</strong></p>
{% for cv in step.childVariants %}
{%- comment -%}
Look up the live variant object from child_variants by matching the GID.
This gives us live price, title, image and availableForSale.
{%- endcomment -%}
{% assign live_variant = nil %}
{% for v in child_variants %}
{% if v.id == cv.variantId %}
{% assign live_variant = v %}
{% break %}
{% endif %}
{% endfor %}
{% if live_variant.available %}
<label>
<input
type="checkbox"
data-step-id="{{ step.id }}"
data-variant-id="{{ cv.variantId }}"
data-max="{{ step.maxVariants | default: 99 }}"
/>
{% if live_variant.image %}
{{ live_variant.image | image_url: width: 96 | image_tag: loading: 'lazy', width: 48, height: 48 }}
{% endif %}
<span>{{ live_variant.title }}</span>
<span>{{ live_variant.price | money }}</span>
</label>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
child_variants is a list.variant_reference — Shopify resolves it to actual variant objects at render time. Prices, availability, and images are always live. You do not need to manage staleness.
Submitting the Bundle to Cart
When the customer confirms their selection, you must POST to /cart/add.js with the selected variants plus a _bundle_config_json property on the first line item and a shared _bundle_id across all items.
Collect selections
Gather the selected variantId per step from your UI state.
Build the payload
Construct the _bundle_config_json from the bundle metafield and the customer’s selections.const bundleInstanceId = crypto.randomUUID();
// bundle = variant.metafields.panda_bundles.bundle_config (from a data attribute or API)
// getSelections(stepId) returns [{ variantId: "gid://...", quantity: 1 }]
const payload = JSON.stringify({
bundleId: bundle.bundleId,
motherVariantId: "gid://shopify/ProductVariant/MOTHER_ID",
steps: bundle.steps.map((step) => ({
stepId: step.id,
selectedVariants: getSelections(step.id),
})),
});
POST to /cart/add.js
Add each selected child variant as a separate line item. Attach _bundle_config_json to the first item only, and _bundle_id to all items.// getSelections returns [{ variantId, quantity }] — quantity can be > 1 for the same variant
const allSelections = bundle.steps.flatMap((step) => getSelections(step.id));
const items = allSelections.map(({ variantId, quantity }, idx) => ({
id: numericId(variantId), // strip the GID prefix
quantity,
properties: {
_bundle_id: bundleInstanceId,
...(idx === 0 ? { _bundle_config_json: payload } : {}),
},
}));
await fetch("/cart/add.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
});
function numericId(gid) {
return gid.split("/").at(-1);
}
Server-side Validation
Do not include discount, isGift, maxVariants, or allowedVariantIds in the _bundle_config_json payload. These fields are ignored — Panori Bundles reads them exclusively from the server-side metafield.
When the cart is processed, the Panori Bundles cart transform function validates every bundle instance against the saved configuration:
| Check | What is validated |
|---|
| Bundle exists | bundleId must match a known bundle in the shop config |
| Mother variant | motherVariantId must belong to that bundle |
| Step exists | Every stepId must exist on the server-side mother variant |
| Allowed variants | Each selectedVariantId must be in that step’s childVariants list |
| Max selections | Sum of all quantity values in a step must not exceed maxVariants |
| Quantity | Each cart line’s quantity must exactly match the declared quantity in selectedVariants |
If any check fails, the bundle is not merged and the items remain as separate, full-price lines in the cart.