What Google reads on a product page, what it requires and what it recommends, how to describe variants, the details that fail quietly, and a ten-minute check at the end.
Structured data tells a search engine what a page is without making it guess: this product, at this price, in stock, rated this well. On a product page it is a block of Schema.org JSON-LD. Google reads it for two kinds of result, which its product structured data overview calls product snippets and merchant listings. Product snippets are for pages where people cannot buy the product, such as reviews. Merchant listings are for pages where customers can buy from you. A shop’s product page is the second kind, and its rules ask for more, so those are the rules this post follows.
Correct markup makes a page eligible and nothing more. Google’s structured data guidelines say it does not guarantee a rich result even for markup that passes its test. The same guidelines set the rule behind most of what follows: the markup must be a true representation of the page, and must not describe anything a visitor cannot see.
What Joomla ships
Joomla 5.0 added a System – Schema.org plugin with a set of type plugins: Article, BlogPosting, Book, Custom, Event, JobPosting, Organization, Person and Recipe. They attach to articles and contacts, where you fill in the fields on the edit screen. There is no Product type, in a Joomla 6.1 installation or in the 6.3 and 7.0 development branches at the time of writing. The Custom type takes JSON you write yourself, but only on an article. A shop’s product pages are served by the cart, and Joomla does not know what a product is, so the cart has to write the markup. The rest of this post is the checklist to hold it to.
One page, one product
Google lists JSON-LD as its recommended format: a script block that sits apart from the visible HTML. For merchant listings it also recommends having the product markup in the page’s initial HTML, and warns that markup added by JavaScript can make shopping crawls less frequent and less reliable. That matters for the two things that change most often, price and stock.
Then there is a trap that Joomla layouts walk into. A product page rarely shows one product. Related products, recently viewed items and a listing module in a position under the component all render product cards, and a card template that marks itself up as a Product hands the crawler five candidates for one page. Google’s product snippet page is plain about it: product rich results only support pages that focus on a single product, or on variants of the same product. So the page’s own product is the only one marked up. Cards on a product page carry no product markup, and everything about the product, its ratings included, goes into that one node rather than into a second block.
The node, field by field
This is the markup a Solidshop store writes for a product without variants, with the addresses shortened:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Enamel Mug",
"url": "https://www.example.com/shop/enamel-mug",
"image": [
"https://www.example.com/files/shop/1/media/thumbs/enamel-mug_800x450.jpg",
"https://www.example.com/files/shop/1/media/thumbs/enamel-mug-detail_800x450.jpg"
],
"description": "A hard-wearing 350ml enamel mug.",
"sku": "MUG-001",
"gtin13": "5012345678900",
"brand": { "@type": "Brand", "name": "Acme" },
"offers": {
"@type": "Offer",
"url": "https://www.example.com/shop/enamel-mug",
"price": 9.5,
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"priceSpecification": {
"@type": "UnitPriceSpecification",
"priceType": "https://schema.org/StrikethroughPrice",
"price": 12,
"priceCurrency": "USD"
}
}
}
</script>
Google’s
merchant
listing page requires three things on the product: a
name, an image (it prefers photos that
clearly show the product, for example against a white background)
and an offers node. The offer requires a
price and a priceCurrency as a
three-letter ISO 4217 code. The price must be greater than zero,
which product snippets do not insist on.
The price is a number. Schema.org’s
usage
notes ask for a full stop as the decimal point, no thousands
separator, and no currency sign in the value, since
priceCurrency carries the currency. A template that
prints its formatted display price into the markup, such as
“$1,299.00” or “1.299,00 €”, breaks
all three rules at once.
Everything else in the sample is recommended rather than required:
description (strongly recommended, in Google’s
words), brand, the identifiers sku and
gtin13, and availability as a Schema.org
URL. The recommended list also has itemCondition,
aggregateRating and review, and the two
policies covered further down. Fill in what is true for your shop;
a recommended property is not an invitation to guess.
Variants: one group, one product per variant
A T-shirt in three colours and four sizes is twelve things to buy.
The simple way to mark it up is one Product with twelve offers, which
gives a crawler twelve prices and twelve stock states with nothing
to say which colour and size each belongs to. Google’s
product
variant markup fixes that with a ProductGroup whose
hasVariant lists each variant as a Product of its own,
and it makes the variants eligible to show in merchant listings.
Trimmed to two variants:
{
"@context": "https://schema.org",
"@type": "ProductGroup",
"name": "Cotton T-shirt",
"url": "https://www.example.com/shop/cotton-t-shirt",
"description": "Soft, breathable unisex cotton T-shirt.",
"productGroupID": "21",
"variesBy": [
"https://schema.org/size",
"https://schema.org/color"
],
"hasVariant": [
{
"@type": "Product",
"name": "Cotton T-shirt - S / Red",
"sku": "TEE-S-RED",
"gtin13": "5012345678917",
"image": "https://www.example.com/files/shop/1/media/thumbs/tee-red_800x450.jpg",
"size": "S",
"color": "Red",
"offers": {
"@type": "Offer",
"url": "https://www.example.com/shop/cotton-t-shirt?variant=60",
"price": 20,
"priceCurrency": "USD",
"availability": "https://schema.org/OutOfStock"
}
},
{
"@type": "Product",
"name": "Cotton T-shirt - S / Black",
"sku": "TEE-S-BLACK",
"gtin13": "5012345678924",
"image": "https://www.example.com/files/shop/1/media/thumbs/tee-black_800x450.jpg",
"size": "S",
"color": "Black",
"offers": {
"@type": "Offer",
"url": "https://www.example.com/shop/cotton-t-shirt?variant=61",
"price": 21,
"priceCurrency": "USD",
"availability": "https://schema.org/InStock"
}
}
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 5,
"reviewCount": 1,
"bestRating": 5,
"worstRating": 1
}
}
Google’s technical guidelines for variants come down to five rules:
-
Every variant has a unique ID, a
skuor agtin. Copying the parent SKU onto every variant defeats the purpose. -
The group has one too, in
productGroupID, which Google describes as the parent SKU. -
variesBynames what differs, as full Schema.org URLs. Google supports six:color,size,suggestedAge,suggestedGender,materialandpattern. Each variant then carries its own value of each. A variant’s name should be more specific than the group’s. - Shared facts sit on the group: the brand, and a rating that represents all variants.
- On a single-page product, one canonical URL for the whole group, normally the address with no variant picked, and a distinct URL for each variant through a query parameter. Opening that URL must preselect the variant, which Google spells out as showing the right image, price and availability, and letting the shopper add that variant to the cart.
One more rule follows from the last. Once a crawler knows a variant URL it will come back to it, so a link to a variant you have since deleted must still open the product page, with nothing picked, rather than break it.
The details that fail quietly
Each of these can pass a validator and still make the markup say something the page does not.
The price the shopper reads
Mark up the price the page shows. Many shops enter prices without tax and display them with it, and a template that prints the computed price while the markup prints the stored one tells a search engine a lower price than the shopper sees. That breaks the true-representation rule above, and it is invisible until someone compares the two.
A sale
The active price goes in price. The old price goes in a
priceSpecification with priceType set to
https://schema.org/StrikethroughPrice, as in the mug
sample, and Google asks you not to give the active price a
priceType at all. Only mark a sale while the old price
is higher. An “original” price below the current one is
not a sale, whatever the product form allowed.
GTINs that are GTINs
A GTIN is the number under a product’s barcode: the UPC or EAN
on the box, or an ISBN-13 on a book. Google takes them as
gtin8, gtin12, gtin13 or
gtin14, recommends the most specific property that
applies, and wants the number in digits rather than as a URL. The
barcode field in a shop often holds something else, such as a code
of your own for the warehouse, and that must stay out. The last
digit of a real GTIN is a check digit calculated from the others,
so a typo or a made-up code fails the check and is easy to filter.
Stock per variant
Each variant’s availability is its own, and it should follow the rule your checkout applies. If a variant does not track stock and checkout sells it, the markup says in stock; if checkout refuses it, the markup says out of stock. A product-wide flag applied to every variant gets one of them wrong.
A plain-text description
The description field in a Joomla product form is usually an editor, so it holds HTML. The markup should hold the text: tags removed, entities decoded, and a space kept where one paragraph ends and the next begins.
Ratings and reviews
aggregateRating and review go on the
product’s own node, and on the group when there are variants.
Product is among the types Google’s
review
snippet guidelines accept; the restriction to reviews of other
businesses applies to the local business and organisation types, not
to products. Each review needs an author whose name is a real
person’s or a team’s. On a Solidshop store the
Reviews add-on merges
the rating and the latest reviews into the product’s node.
Shipping and returns
The two recommended facts most shops leave out are the shipping
cost and the return policy. You do not have to repeat them on every
product. Google recommends one store-wide
return
policy, nested under your Organization markup, and
since
November
2025 a store-wide shipping policy in the same place, ideally on
the page that describes your shipping. A policy given on a single
product still wins for that product.
The other route needs no markup. The Shipping and returns settings in Search Console are now open to every site Google identifies as an online merchant, with or without a Merchant Center account, and what you set there takes precedence over structured data. Solidshop does not write either policy yet, so on a Solidshop store Search Console is the way to supply them today.
The worked example: a Solidshop store
Solidshop writes the product markup itself, in the page’s
initial HTML, with nothing to switch on. Today every product page
carries one Product node, and the product cards and listing modules
below it carry no product markup of their own. Custom fields set to
Include in structured data are added as product
properties, as the
custom fields guide explains,
and the store’s front page carries an
Organization node.
Version 1.7.0, the next release, rebuilds the product node along the
lines of this post, and the two samples above are its output. A
product with variants becomes a ProductGroup, each
variant with its own SKU, image, price, stock and a link that opens
the page with it picked. A barcode becomes a GTIN only when it
passes the check digit. Options named Color, Colour, Size, Material
or Pattern, and any option shown as colour swatches, are listed in
variesBy. An original price above the price is marked
as a sale, and prices are the ones the page shows, as numbers. What
you can do now: give each variant its own SKU or barcode and an
image. The
products guide has
the details.
Check your work
- Run the Rich Results Test on a product URL. The help page explains the results: an item with errors is invalid, an item with warnings is still valid. Read each warning against the recommended list above. For a site Google cannot reach, such as a staging copy, choose Code instead of URL and paste the page source.
- Paste the same URL into the Schema Markup Validator, which Google describes as checking all Schema.org markup without its own feature rules. It shows the full node.
-
Print the JSON-LD from the command line and count
the product nodes. There should be one.
curl -sL "https://www.example.com/shop/enamel-mug" | grep -o '<script type="application/ld+json"[^>]*>.*</script>' -
Compare a variant URL with the product page. Both
must print the same canonical address.
curl -sL "https://www.example.com/shop/cotton-t-shirt?variant=60" | grep -i -o '<link[^>]*rel="canonical"[^>]*>' - Open the Merchant listings report in Search Console a few days later. Google keeps a separate Product snippets report, but for pages that sell, the merchant listings report already includes its checks.
What each page is
robots.txt says where a crawler may go, the sitemap says what is there, and structured data says what each page is. Making a Joomla Store Visible to AI Assistants covers what an assistant reads on top. Solidshop writes the product markup from the free core; get it from the extensions page, or read the products guide if your store is already running.