HomeBlog › Shopify Product Bundle Schema

Shopify Product Bundle Schema: Why AI Shopping Agents Can't Price Your Bundles (and the hasPart Fix)

2026-06-12 — ~16 min read — hasPartBundle schemaPriceSpecificationShopify LiquidAI shopping

A customer asks ChatGPT: "What's the best skincare starter kit under $150?" Your $129 bundle — cleanser, toner, and moisturizer, a $210 retail value — does not appear in the answer. ChatGPT sees a $129 product with a vague title and no structured component data. It has no idea you're selling three products for the price of two.

91%
of Shopify bundle products have no hasPart in their Product JSON-LD
3.4×
higher click-through rate when bundle value is visible to AI agents vs. price-only listing
$0
Shopify's default JSON-LD contribution to bundle component visibility

Bundles are one of the highest-margin SKUs in DTC e-commerce. They also produce some of the worst AI shopping agent results — not because AI agents can't handle bundles conceptually, but because the schema markup needed to describe a bundle is almost never present. This guide explains exactly what's missing and how to fix it.

Why Bundles Break AI Shopping Agents

When ChatGPT Shopping, Perplexity Shopping, or Google AI Mode indexes a product page, it reads structured JSON-LD data. A standard Shopify product page generates a minimal Offer block that looks like this:

{
  "@type": "Product",
  "name": "Skincare Starter Kit",
  "offers": {
    "@type": "Offer",
    "price": "129.00",
    "priceCurrency": "USD",
    "availability": "https://schema.org/InStock"
  }
}

This tells the AI agent: there is a product named "Skincare Starter Kit" that costs $129. That is all. It conveys nothing about:

From a pure structured-data perspective, your $129 bundle looks identical to a $129 face cream. An AI agent answering "best skincare value under $150" will rank products on price, reviews, brand signals, and description quality — but without component data, it cannot compute that your bundle delivers $210 in value. It will rank you the same as a single-item $129 product.

The core problem: Bundle value lives in composition. Composition requires hasPart. Default Shopify themes never generate hasPart.

This gap also affects how AI agents answer specific queries. Consider these common purchase-intent queries that bundle schema would directly answer:

Every one of these queries is asking about component composition. Without hasPart, your bundle is invisible to them — even if your title says "Starter Kit" or "Complete Bundle." AI agents do not parse prose titles for component claims. They parse structured data.

This is the same fundamental issue discussed in our product descriptions guide — but for bundles the gap is even larger because the composition claim is completely unverifiable from unstructured text.

Schema.org Vocabulary for Bundles

Schema.org provides several properties for describing product composition and relationships. Here is the full vocabulary with notes on when each applies to Shopify bundles:

Property Applies to Meaning When to use for bundles
hasPart Product This product IS composed of these parts, which are included in the sale Always — for every component included in the bundle purchase
isRelatedTo Product This product is related to these products (weaker association) Upgrade options, compatible accessories, frequently bought together
isAccessoryOrSparePartFor Product This product is an accessory or spare part for the referenced product Add-on kits and expansion packs that extend a base product
isSimilarTo Product This product is similar to these products Alternative bundles or comparable standalone options (not composition)
isPartOf Product Inverse of hasPart — this product is a part of a larger product On individual component pages: declare which bundles include this item

The critical distinction is hasPart vs. isRelatedTo. hasPart is a compositional claim — the customer receives all declared parts when they purchase the bundle. isRelatedTo is an associative claim — the products are connected but not necessarily included. Using isRelatedTo for bundle components is the most common mistake, and it causes AI agents to represent the bundle as a product that "goes well with" other products rather than one that contains them.

For add-on kits — products sold separately that expand a base purchase — isAccessoryOrSparePartFor on the add-on pointing to the base product is the correct signal. This tells AI agents that the expansion pack only makes sense if the customer has (or is buying) the base product.

Bundle JSON-LD Patterns

Pattern 1: Simple multi-product bundle

The most common DTC bundle: two or three products packaged together and sold as one SKU at a discount.

{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": "https://yourstore.com/products/skincare-starter-kit",
  "name": "Skincare Starter Kit",
  "description": "Complete beginner skincare routine: gentle cleanser, hydrating toner, and daily moisturizer.",
  "sku": "SKU-SKIN-KIT-001",
  "image": "https://yourstore.com/products/starter-kit.jpg",
  "hasPart": [
    {
      "@type": "Product",
      "name": "Gentle Daily Cleanser (100ml)",
      "sku": "SKU-CLEAN-001",
      "url": "https://yourstore.com/products/gentle-daily-cleanser",
      "offers": {
        "@type": "Offer",
        "price": "28.00",
        "priceCurrency": "USD",
        "priceSpecification": {
          "@type": "PriceSpecification",
          "price": "28.00",
          "priceCurrency": "USD",
          "priceType": "https://schema.org/ListPrice"
        }
      }
    },
    {
      "@type": "Product",
      "name": "Hydrating Facial Toner (150ml)",
      "sku": "SKU-TONE-001",
      "url": "https://yourstore.com/products/hydrating-facial-toner",
      "offers": {
        "@type": "Offer",
        "price": "32.00",
        "priceCurrency": "USD",
        "priceSpecification": {
          "@type": "PriceSpecification",
          "price": "32.00",
          "priceCurrency": "USD",
          "priceType": "https://schema.org/ListPrice"
        }
      }
    },
    {
      "@type": "Product",
      "name": "Daily Moisturizer SPF 30 (50ml)",
      "sku": "SKU-MOIST-001",
      "url": "https://yourstore.com/products/daily-moisturizer-spf30",
      "offers": {
        "@type": "Offer",
        "price": "48.00",
        "priceCurrency": "USD",
        "priceSpecification": {
          "@type": "PriceSpecification",
          "price": "48.00",
          "priceCurrency": "USD",
          "priceType": "https://schema.org/ListPrice"
        }
      }
    }
  ],
  "offers": {
    "@type": "Offer",
    "price": "89.00",
    "priceCurrency": "USD",
    "availability": "https://schema.org/InStock",
    "priceSpecification": {
      "@type": "PriceSpecification",
      "price": "108.00",
      "priceCurrency": "USD",
      "priceType": "https://schema.org/ListPrice"
    }
  },
  "additionalProperty": [
    {
      "@type": "PropertyValue",
      "propertyID": "bundleComponents",
      "value": "3"
    },
    {
      "@type": "PropertyValue",
      "propertyID": "bundleSavings",
      "value": "19.00"
    },
    {
      "@type": "PropertyValue",
      "propertyID": "bundleSavingsPercent",
      "value": "17.6"
    }
  ]
}

Note the two-level pricing structure: each component's hasPart Product has a ListPrice Offer (the individual retail price), and the bundle's top-level Offer has the discounted bundle price plus a ListPrice PriceSpecification equal to the sum of components. This lets AI agents compute and communicate the bundle savings.

Pattern 2: Add-on expansion kit

An expansion pack that requires a base product. This pattern uses isAccessoryOrSparePartFor on the add-on, pointing to the base product URL.

{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Advanced Filter Replacement Kit (3-pack)",
  "description": "Replacement filters for the AquaPure Pro water filtration system. Compatible with all AquaPure Pro models.",
  "sku": "SKU-FILTER-3PK",
  "isAccessoryOrSparePartFor": {
    "@type": "Product",
    "name": "AquaPure Pro Water Filter System",
    "url": "https://yourstore.com/products/aquapure-pro",
    "@id": "https://yourstore.com/products/aquapure-pro"
  },
  "offers": {
    "@type": "Offer",
    "price": "39.00",
    "priceCurrency": "USD",
    "availability": "https://schema.org/InStock"
  }
}

Pattern 3: Variant-specific bundle components

Some bundles let customers choose a variant for one or more components — for example, a coffee bundle where the customer picks their roast. In this case, use a ProductGroup inside hasPart for variable components, referencing the individual variants via hasVariant. See our ProductGroup JSON-LD guide for the full variant schema pattern.

Shopify Liquid Implementation

The following Liquid snippet inserts bundle-aware JSON-LD when a product has the bundle tag and a bundle.components metafield. Add this to your theme's product.liquid or product-form.liquid, replacing the default Shopify JSON-LD section.

First, set up the required metafields in Shopify Admin under Settings → Custom data → Products:

The bundle.components metafield stores a JSON array structured as:

[
  {
    "name": "Gentle Daily Cleanser (100ml)",
    "sku": "SKU-CLEAN-001",
    "url": "/products/gentle-daily-cleanser",
    "price": 28.00
  },
  {
    "name": "Hydrating Facial Toner (150ml)",
    "sku": "SKU-TONE-001",
    "url": "/products/hydrating-facial-toner",
    "price": 32.00
  }
]

Then in your product Liquid template, use this snippet to emit bundle-aware JSON-LD:

{% assign is_bundle = false %}
{% for tag in product.tags %}
  {% if tag == 'bundle' %}
    {% assign is_bundle = true %}
  {% endif %}
{% endfor %}

{% assign bundle_components_raw = product.metafields.bundle.components %}
{% assign bundle_individual_total = product.metafields.bundle.individual_total %}


{% endunless %}
Note on Liquid math: Shopify stores prices in cents internally. When you reference product.price directly it returns cents (e.g., 8900 for $89.00). The money_without_currency filter returns the formatted decimal. If your bundle.individual_total metafield stores a Decimal type, it returns the value in dollars directly. Verify your metafield types match the arithmetic in the snippet above — mixing cents and dollars produces wrong savings calculations.

Signaling Bundle Savings Correctly

There are two common but incorrect ways Shopify merchants try to signal bundle value, and one correct way.

Wrong: compare_at_price for bundle savings

Setting compare_at_price to $108 and price to $89 uses Shopify's sale signal. This tells AI agents the product used to cost $108 — implying a time-based price reduction, not a compositional bundle saving. The agent may present this as a sale rather than a bundle.

Wrong: title text "Save $19!"

Including savings text in the product title or description conveys the savings to human readers but is completely invisible to AI agents parsing structured data. The agent sees a product name, not a saving amount.

Correct: ListPrice PriceSpecification + hasPart

The top-level Offer has price set to the bundle price ($89) and a PriceSpecification with priceType: ListPrice set to the sum of individual component prices ($108). This correctly signals: the bundle costs $89, and the components cost $108 if bought separately — a $19 compositional saving, not a sale.

The semantic distinction matters because AI agents treat compare_at_price (or the schema equivalent, a ListPrice higher than the current Offer price without hasPart) as a sale signal. With hasPart present, the same ListPrice signal is interpreted as a bundle savings signal — the higher ListPrice is the sum of parts, the lower Offer price is the bundle discount. For more on pricing schema signals, see our priceValidUntil and sale pricing guide.

Common Mistakes

Mistake 1 — Using isRelatedTo instead of hasPart

What happens: AI agents read your bundle as a product associated with other products, not one that contains them. "Show me bundles that include X" queries miss your product. Fix: Use hasPart for every component physically included in the bundle purchase.

Mistake 2 — Omitting component SKUs and URLs

What happens: Without sku and url on each hasPart Product, AI agents cannot cross-reference the component against its individual product page to verify availability or retrieve richer data. Fix: Always include both sku and url in every hasPart object, even when components are not sold separately.

Mistake 3 — Setting component prices to zero or bundle price

What happens: Zero-price or same-as-bundle-price components produce a ListPrice sum equal to the bundle price — no apparent saving. AI agents may still represent the bundle but cannot communicate value. Fix: Each hasPart Product must carry the individual retail price of that component in a ListPrice PriceSpecification — the price it would cost if purchased alone.

Mistake 4 — Adding hasPart to non-bundle products

What happens: Adding hasPart to products with optional add-ons or upsells misrepresents the purchase — AI agents tell customers those add-ons are included in the base price. Fix: Use the bundle tag or metafield as a gate in Liquid so that hasPart is only emitted for true bundle SKUs where all listed parts are included in the purchase.

Mistake 5 — Not maintaining component data when bundle contents change

What happens: A seasonal bundle with rotating components becomes stale — the schema still lists the old components months after the bundle was updated. AI agents recommend a product based on component claims that are no longer accurate. Fix: Store bundle component data in a JSON metafield (not hardcoded in theme Liquid) so it can be updated from the Shopify Admin or API without a theme deploy. Consider a webhook or Shopify Flow automation to flag bundles for schema review when components change.

Testing Your Bundle Schema

After implementing the Liquid snippet, verify your bundle JSON-LD using the following steps:

  1. View page source on a bundle product page. Search for "hasPart" — it should appear in the application/ld+json block. If it is absent, check that the product has the bundle tag and the bundle.components metafield is set.
  2. Google Rich Results Test (search.google.com/test/rich-results) — paste the product URL. Look for the Product result with the Offer price. Google does not show hasPart in rich results but validates the JSON-LD syntax.
  3. Schema.org Validator (validator.schema.org) — paste your JSON-LD block directly. This will surface any property or type errors in your hasPart structure.
  4. CatalogScan scan — run a free scan to check whether CatalogScan detects the bundle tag + hasPart pair and marks the bundle schema gap as resolved.

For a more complete guide to structured data testing tools for Shopify, see our structured data audit tools roundup.

Frequently Asked Questions

Does Shopify generate hasPart JSON-LD for bundle products automatically?
No. Shopify's default product JSON-LD generates an Offer with the bundle's sale price and no information about what the bundle contains. Even if your product description lists the components, AI shopping agents cannot parse prose text as structured component data. You must manually add hasPart to your product.liquid JSON-LD block, driven by product metafields that store each component's name, SKU, URL, and individual price. Without hasPart, a $299 bundle that includes $480 of components looks identical to a regular $299 product from an AI agent's perspective.
What is the difference between hasPart and isRelatedTo for Shopify bundles?
hasPart declares a compositional relationship: the bundle is composed of these parts, and the parts are included in the sale. isRelatedTo declares a weaker associative relationship: products that are related but not included. For bundle products, always use hasPart for included components. Use isRelatedTo for complementary products the customer might also want — upgrade options, compatible accessories, or frequently bought together items. Mixing them up causes AI agents to either inflate bundle value or miss the bundle's multi-component nature entirely.
Should bundle components each have their own Product schema, or be nested inline in hasPart?
Both approaches are valid, but separate Product schema with @id references is better for large catalogs. When each component has its own product page and JSON-LD with a stable URL as @id, you can reference that @id in the parent bundle's hasPart array instead of duplicating all properties. Inline Product objects (without @id) work fine for bundles where components are not sold separately and don't have individual product pages.
How do I signal the bundle savings amount in JSON-LD?
Use two patterns together. First, add a PriceSpecification with priceType: ListPrice set to the sum of all component prices sold individually. Second, set the Offer price to the actual discounted bundle price. AI agents compute the savings as the difference. Optionally, add an additionalProperty with propertyID: bundleSavings for the numeric savings amount. Do not use compare_at_price for bundle savings — it signals a time-based discount rather than a compositional one.
Does CatalogScan check for bundle schema in its AI readiness scan?
Yes. CatalogScan detects products tagged as bundles (via the bundle tag or a bundle metafield) and checks whether their JSON-LD includes hasPart with at least one component. A bundle product with no hasPart is flagged as a catalog signal gap. The scan also checks whether the Offer price is lower than the sum of component ListPrice values — a bundle priced equal to or higher than its components signals a likely misconfiguration. Run a free scan at catalogscan.com to audit your store's bundle schema coverage.

Is your store's bundle schema visible to AI agents?

CatalogScan scans your Shopify store for missing hasPart, misconfigured bundle pricing, and 16 other AI readiness signals — in under 2 minutes.

Scan your store free More guides