Blog · · 10 min
A reviews section with real structured data
The stars in a search result come from AggregateRating markup, and Google's structured data policies require the marked-up content to be visible on the page. Markup describing reviews the visitor cannot see is a manual action waiting to happen, not a shortcut. Generate the JSON-LD from the same array the component renders, show the distribution rather than only the average, sort newest first, and emit no markup at all when there are no reviews.
By uxgen
Stars in Google come from AggregateRating markup attached to a Product, and Google's structured data policies require that markup to describe content visible on the page. Markup describing reviews a visitor cannot find is not a growth tactic, it is the definition of the spammy structured markup that earns a manual action. So the rule that decides the whole implementation is this: the JSON-LD is generated from the same array the component renders, and from nothing else.
Everything below follows from that single constraint, including the branch that matters most, which is what happens when the array is empty.
Where do the stars in a search result come from?
From a Product node carrying aggregateRating, review, or both. Google decides on its own whether to draw them; markup makes a page eligible for a review snippet, it never guarantees one. Anyone who tells you the markup produces stars is describing a hope.
Two conditions are worth knowing before writing any of it. The reviewed content has to be available to a visitor on that page, not hidden behind a tab that never loads or a script that only runs for some users. And self-serving reviews, meaning reviews about an organisation placed on that organisation's own pages, are not eligible for the snippet in Google's guidelines. A Product reviewed by its buyers is the normal, eligible case; a business reviewing itself is not.
What has to be true of each field?
| Field | Where it must come from | What breaks when it is typed by hand |
|---|---|---|
ratingValue | computed from the array being rendered | an average that does not match the stars on screen |
reviewCount | length of the array with written reviews | a count the crawler cannot find on the page |
ratingCount | number of ratings, including those with no text | it silently becomes a second, contradictory total |
bestRating / worstRating | your scale, declared explicitly | a 4.8 read as if it were on a scale of 10 |
author.name | the review row | an invented name is a fabricated review |
datePublished | the row's creation date | reviews that never age and never sort |
itemReviewed | the very same product node | markup describing something other than this page |
offers.price | the same source the page prints | a price mismatch, flagged as inconsistent |
The distinction between reviewCount and ratingCount catches people out. A store where 400 people left a star rating and 90 wrote something has ratingCount: 400 and reviewCount: 90. Putting 400 into reviewCount claims 400 written reviews on a page that shows 90.
How do you generate the JSON-LD from the data?
One function, taking the product and the exact array the component will render. Not a second query, not a cached total, not a number from the admin.
export type Review = {
author: string
rating: number // on your declared scale
body: string
createdAt: string // ISO
verifiedPurchase: boolean
}
export function productJsonLd(
product: {
name: string
url: string
sku: string
image: string[]
priceCents: number
currency: string
inStock: boolean
},
/** the same array passed to the component. One array, two consumers. */
reviews: Review[],
{ maxReviews = 10, bestRating = 5, worstRating = 1 } = {},
) {
const node: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
sku: product.sku,
image: product.image,
offers: {
'@type': 'Offer',
url: product.url,
price: (product.priceCents / 100).toFixed(2),
priceCurrency: product.currency,
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
}
const written = reviews.filter((r) => r.body.trim().length > 0)
// No visible review, no rating node. This branch is the point of the file:
// there is no configuration flag that can switch it off.
if (written.length === 0) return node
const sum = written.reduce((s, r) => s + r.rating, 0)
node.aggregateRating = {
'@type': 'AggregateRating',
ratingValue: (sum / written.length).toFixed(1),
reviewCount: written.length,
bestRating,
worstRating,
}
node.review = [...written]
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
.slice(0, maxReviews)
.map((r) => ({
'@type': 'Review',
author: { '@type': 'Person', name: r.author },
datePublished: r.createdAt.slice(0, 10),
reviewBody: r.body,
reviewRating: {
'@type': 'Rating',
ratingValue: r.rating,
bestRating,
worstRating,
},
}))
return node
}
The output, for a product with two written reviews, looks like this. The values are invented fixtures for an invented coffee, printed here so the shape is visible; nobody's actual reviews are reproduced, and this block is a test fixture rather than something to paste into a page:
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Guji washed, 250 g",
"sku": "GUJI-250",
"image": ["https://example.com/guji-1.webp"],
"offers": {
"@type": "Offer",
"url": "https://example.com/guji-250",
"price": "19.00",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.0",
"reviewCount": 2,
"bestRating": 5,
"worstRating": 1
},
"review": [
{
"@type": "Review",
"author": { "@type": "Person", "name": "Marie L." },
"datePublished": "2026-08-30",
"reviewBody": "Ground finer than I expected. Good with a filter, muddy in an espresso basket.",
"reviewRating": {
"@type": "Rating",
"ratingValue": 3,
"bestRating": 5,
"worstRating": 1
}
},
{
"@type": "Review",
"author": { "@type": "Person", "name": "Tom V." },
"datePublished": "2026-08-21",
"reviewBody": "Arrived two days after ordering, roast date printed on the bag.",
"reviewRating": {
"@type": "Rating",
"ratingValue": 5,
"bestRating": 5,
"worstRating": 1
}
}
]
}
Note that the average is 4.0 and one of the two reviews is a three. That is not an accident of the example, and the next two sections are about why.

Why show the distribution instead of only the average?
Because the average destroys the information the buyer wants. Ten reviews averaging 4.0 can be ten fours, or it can be six fives and four twos. The first is a product that is consistently fine. The second is a product that either delights or fails, and the buyer would very much like to know which one they are about to receive.
The distribution also does something the average cannot: it makes the sample size visible without stating it. A row of five bars where four are empty tells the whole story in a glance.
export function Distribution({
reviews,
bestRating = 5,
}: {
reviews: Review[]
bestRating?: number
}) {
if (reviews.length === 0) return null
const buckets = Array.from({ length: bestRating }, (_, i) => {
const stars = bestRating - i // 5 first, 1 last
const n = reviews.filter((r) => Math.round(r.rating) === stars).length
return { stars, n, share: n / reviews.length }
})
return (
<table className="distribution">
<caption>How the {reviews.length} ratings break down</caption>
<tbody>
{buckets.map((b) => (
<tr key={b.stars}>
<th scope="row">{b.stars} stars</th>
<td>
{/* The bar is the illustration. The count is the message,
which is why it is text and not a title attribute. */}
<span
className="bar"
style={{ inlineSize: `${(b.share * 100).toFixed(1)}%` }}
aria-hidden="true"
/>
</td>
<td className="count">{b.n}</td>
</tr>
))}
</tbody>
</table>
)
}
A table, not a stack of divs, because that is what it is: a header per row, a count per row, readable in order by anything that reads the page linearly. The bar is aria-hidden for the same reason the countdown digits are elsewhere: it repeats a number that is already there in words.
What should the default sort be?
Newest first. Not highest rated.
A store that sorts by rating by default is showing the buyer a curated wall, and buyers who scroll know it. Newest first answers the question they are actually holding, which is whether the product is still good now, after the supplier changed, after the recipe changed, after the version shipped. It also degrades honestly: a review section whose top entry is fourteen months old tells the buyer something true about the store.
Keep the alternative sorts available, including lowest first. A store that lets you sort by worst is making a statement about what it expects you to find.
Why keep the negative review?
Because it is what makes the other nine believable. A page of unbroken fives is the exact pattern a sceptical reader has learned to distrust, and the three-star review in the example above does more for the five-star one below it than any badge could.
There is a second reason, less discussed. A specific negative review pre-qualifies the buyer. Muddy in an espresso basket loses you the espresso customer who would have asked for a refund, and it wins the filter customer who now believes everything else on the page. That is a good trade in both directions.
The related question of when you are entitled to claim two products are bought together is a similar sort of factual claim about your own data, and it is covered in frequently bought together, done right.
And when there are no reviews?
The component renders nothing and emits no markup. Not an empty five-star row, not Be the first to review this product, not a AggregateRating with reviewCount: 0, which is both meaningless and an invitation to fill it with something invented.
That is not a gap to be patched later with placeholder stars. It is a different problem with a different set of answers, and the four proofs that work with zero customers are in social proof when you have no customers yet. The seals people reach for in the same moment are sorted in trust badges on a product page, and most of them fail for the same reason a fabricated review does: nothing behind them can be checked.
FAQ
How do I get star ratings to show in Google search results?
Publish Product structured data carrying aggregateRating and review, generated from reviews that are genuinely visible on the same page. Markup makes the page eligible for a review snippet; Google decides independently whether to draw one, and no markup guarantees stars. Reviews an organisation writes about itself on its own pages are not eligible under Google's guidelines.
Can I add AggregateRating markup without showing the reviews?
No. Google's structured data policies require the marked-up content to be visible to visitors on the page, and markup describing content that is not there falls under spammy structured markup, which is handled by a manual action rather than a ranking adjustment. Generate the markup from the same array the component renders and the situation cannot arise.
What is the difference between reviewCount and ratingCount?
reviewCount is the number of reviews with written text; ratingCount is the number of ratings, including those left with no words. A store with 400 star ratings and 90 written reviews should publish both, correctly. Putting the larger figure into reviewCount claims written reviews that are not on the page.
How should a reviews section be sorted by default?
Newest first. Sorting by highest rating turns the section into a curated wall and readers recognise it. Newest first answers the buyer's real question, which is whether the product is still good today, and it degrades honestly, since a top review that is a year old is itself a true fact about the store. Keep lowest-rating as an available sort.
Building it
uxgen.ai is an MCP server: it hands Claude 168 commerce components and installs them in the store as HTML the merchant owns, with nothing to uninstall and no commission per order, at $19, $29 or $59 a month. Where a rule can be enforced by the shape of a component rather than by documentation, it is, which is why the markup builder above has no flag to emit a rating without reviews. The public specification lives at github.com/kinerette/uxgen-commerce-kit, under MIT.