Product AEO: Price History and Discounts Without Confusing AI

Discounts are great for shoppers and for your conversion rate. They are less great when your schema claims one thing while your buy box shows another. Assistants will repeat whatever looks most trustworthy in the HTML, and that depends on clean Product and Offer markup. The goal here is simple. Show savings clearly to humans and keep your JSON-LD focused on the current offer only.

Think about the last time you checked a product on sale and saw three different prices on the page. You probably lost confidence for a second. Robots feel the same way. If the visible price, the microcopy, and the JSON-LD disagree, trust drops. The patterns below remove that uncertainty. You will keep one truth for machines, and a friendly, transparent story for people.


Core principles

  • Emit exactly one current price in Offer and match the buy box.
  • Show history in the UI only. Keep Product JSON-LD about the present.
  • Include priceValidUntil only when the end date is real, public, and visible.
  • Use the selected variant for price and availability to avoid mismatch.
  • Choose either one Offer or an AggregateOffer range. Do not mix them.
  • Strip separators in JSON values and align currency with the storefront.

In practice this feels calm. Your page shows a clear current price and, when relevant, a tasteful “was” price and a savings badge. Your schema says only the current price. If the sale ends on a specific date that buyers can see, you add it to the Offer. No tricks. No guesswork. Just parity.


Liquid snippet for clean price and discount UI

This snippet renders the buy box price, an optional compare price, and a savings badge. It also exposes normalized numbers for later JSON-LD output. Place in snippets/price-ui.liquid.

{% comment %}
  snippets/price-ui.liquid
  Purpose: Display price, compare-at price, and savings without schema confusion.
  Inputs:
    - product
    - selected_variant (optional)
{% endcomment %}

{% assign v = selected_variant | default: product.selected_or_first_available_variant %}
{% assign has_compare = v.compare_at_price and v.compare_at_price > v.price %}

<div class="price-box">
  <div class="price-row flex items-baseline space-x-2">
    <span class="text-2xl font-semibold" id="price-now">{{ v.price | money }}</span>
    {% if has_compare %}
      <span class="line-through text-gray-400" id="price-was">{{ v.compare_at_price | money }}</span>
      {% assign savings_amount = v.compare_at_price | minus: v.price %}
      {% assign savings_pct = savings_amount | times: 100.0 | divided_by: v.compare_at_price | round: 0 %}
      <span class="ml-2 inline-flex items-center rounded bg-[#102a43] px-2 py-0.5 text-xs text-[#7cc0ff]" aria-label="You save">
        Save {{ savings_pct }}%
      </span>
    {% endif %}
  </div>
  {% if has_compare %}
    <p class="mt-1 text-sm text-gray-400">Limited time discount. Price at checkout may vary by currency.</p>
  {% endif %}
</div>

{%- comment -%} Normalized values for schema consumers {%- endcomment -%}
{% assign price_str = v.price | money_without_currency | replace: ',', '' %}
{% assign compare_at_str = has_compare | default: false | if: has_compare, v.compare_at_price | money_without_currency | replace: ',', '' %}

<!-- Expose normalized values via data attributes for sections that render JSON-LD later -->
<div data-price-jsonld
     data-price="{{ price_str }}"
     data-compare="{{ compare_at_str }}"
     data-currency="{{ cart.currency.iso_code }}"></div>

Product and Offer JSON-LD with safe sale fields

Attach a single Offer for the selected variant. Include priceValidUntil only if the date is shown in the UI. Omit compare price from JSON-LD. Keep discounts in the visible HTML where shoppers can interpret them.

{% comment %}
  snippets/product-offer-jsonld.liquid
  Emits Product with a single Offer that matches the buy box.
  Inputs:
    - product
    - selected_variant (optional)
    - sale_end_date (optional, ISO 8601), shown in UI if present
{% endcomment %}

{% assign v = selected_variant | default: product.selected_or_first_available_variant %}
{% assign price_str = v.price | money_without_currency | replace: ',', '' %}

{% capture images_json %}
  {% for img in product.images %}
    {{ img | image_url: width: 1200 | absolute_url | json }}{% unless forloop.last %},{% endunless %}
  {% endfor %}
{% endcapture %}

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": "{{ product.url | absolute_url }}#product",
  "url": "{{ product.url | absolute_url }}",
  "name": {{ product.title | json }},
  "description": {{ product.description | strip_html | strip_newlines | truncate: 500 | json }},
  "image": [{{ images_json | strip }}],
  {% if v.sku != blank %}"sku": {{ v.sku | json }},{% endif %}
  {% if product.metafields.global.gtin != blank %}"gtin13": {{ product.metafields.global.gtin | json }},{% endif %}
  {% if product.metafields.global.mpn != blank %}"mpn": {{ product.metafields.global.mpn | json }},{% endif %}
  "brand": { "@type": "Brand", "name": {{ shop.name | json }} },
  "offers": {
    "@type": "Offer",
    "url": "{{ product.url | absolute_url }}",
    "priceCurrency": "{{ cart.currency.iso_code }}",
    "price": "{{ price_str }}",
    {% if sale_end_date %}"priceValidUntil": "{{ sale_end_date }}",{% endif %}
    "availability": "{% if v.available %}https://schema.org/InStock{% else %}https://schema.org/OutOfStock{% endif %}",
    "itemCondition": "https://schema.org/NewCondition"
  }
}
</script>

AggregateOffer for variant ranges

If your title area shows a price range across variants, use AggregateOffer with lowPrice and highPrice. Do not also emit a per variant Offer in the same block. Keep parity with what the shopper sees.

{% comment %}
  snippets/product-aggregate-offer.liquid
  Use when the UI shows a range like $39 to $59.
{% endcomment %}

{% assign prices = product.variants | map: "price" | sort %}
{% assign low = prices.first | money_without_currency | replace: ',', '' %}
{% assign high = prices.last  | money_without_currency | replace: ',', '' %}

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": "{{ product.url | absolute_url }}#product",
  "name": {{ product.title | json }},
  "url": "{{ product.url | absolute_url }}",
  "offers": {
    "@type": "AggregateOffer",
    "priceCurrency": "{{ cart.currency.iso_code }}",
    "lowPrice": "{{ low }}",
    "highPrice": "{{ high }}",
    "offerCount": "{{ product.variants.size }}"
  }
}
</script>

Safe patterns for price history

You can give shoppers honest context about past prices without polluting Product schema. Pick one pattern that fits your brand voice and keep JSON-LD about the current Offer only.

PatternHow it worksProsConsSchema impactBest for
Was Now with savingsShow current price, strike through compare price, and a percent badge.Very scannable. Shows value quickly.Needs a clear compare price policy to avoid confusion.None. JSON-LD remains current price only.Most PDPs during sales.
Mini chart tooltipSparkline of last 90 days with a tooltip for key points. HTML only.Explains volatility and builds trust.Extra UI work and accessibility considerations.None. Keep chart data out of Product schema.Products with frequent promotions.
FAQ answerShort Q and A like “Do prices change often” with your policy.Reusable and easy to localize.Less persuasive for deal hunters who want numbers.Optional FAQPage schema, separate from Product.Brands with strict policy language.
Dedicated price history pageStandalone page with policy, methodology, and a chart.Highly transparent and linkable.More content to maintain over time.Use WebPage or Dataset. Do not attach to Product JSON-LD.High consideration products.

FAQ snippet about sales policy

This reusable FAQ helps assistants quote your rules without guessing. Keep it factual and durable.

{% comment %}
  snippets/price-faq.liquid
  Reusable answer about pricing and sales policy.
{% endcomment %}
<details class="mt-4">
  <summary class="cursor-pointer">Do prices change during promotions</summary>
  <div class="mt-2 text-gray-300">
    Yes. Sale prices apply for a limited time and may vary by currency. The price shown in the buy box reflects the current offer and is the price that applies at checkout.
  </div>
</details>

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "Do prices change during promotions",
      "acceptedAnswer": { "@type": "Answer", "text": "Yes. Sale prices apply for a limited time and may vary by currency. The price shown in the buy box reflects the current offer and is the price that applies at checkout." }
    }
  ]
}
</script>

Do's and dont's for schema

  • Do emit one Offer with the current price and match the visible UI.
  • Do include priceValidUntil only when the end date is visible to shoppers.
  • Do not emit past prices or multiple historical Offers in Product JSON-LD.
  • Do not output both AggregateOffer and per variant Offers in the same block.
  • Do localize currency codes correctly for multi currency storefronts.
  • Do QA discounted products and products with no discount on each deploy.

QA checklist before rollout

  • View Source and confirm a single Offer with the current price.
  • Toggle variants and confirm schema parity with the selected variant.
  • Enable a sale with an end date and verify priceValidUntil appears only when shown in the UI.
  • Test a product with no discount and ensure no sale hints in JSON-LD.
  • Switch currencies and confirm UI and JSON-LD use the same code.
  • Test a zero stock variant and confirm availability changes to OutOfStock.