Review Snippet Schema Framework
Use conditional-logic schema generation to keep Review Snippet markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralReview Snippet documentationUse when the page shows a review, or an aggregate rating, of one specific item such as a product, book, recipe, course or local business, not a category or a list of items.
Key Implementation Documentation Highlights
- Review required properties: author, reviewRating (with ratingValue), itemReviewed (with name). Recommended: datePublished, reviewRating.bestRating, reviewRating.worstRating.
- AggregateRating required properties: itemReviewed (with name), ratingValue, ratingCount or reviewCount. Recommended: bestRating, worstRating.
- itemReviewed must be a Book, Course, CreativeWorkSeason, CreativeWorkSeries, Episode, Event, Game, HowTo, LocalBusiness, MediaObject, Movie, MusicPlaylist, MusicRecording, Organization, Product, Recipe or SoftwareApplication (or a subtype). Omit it only when the Review or AggregateRating is nested in its item through review or aggregateRating.
- ratingValue is a number, fraction or percentage (4, 60%, 6 / 10) with a dot decimal separator (4.4, not 4,4), within worstRating to bestRating (1 and 5 when omitted).
- author.name must be a valid person or organization name under 100 characters, not promotional text such as "50% off until Saturday".
- Self-serving reviews: a LocalBusiness or any Organization reviewed on a site it controls, directly or through an embedded third-party widget, is not eligible for review stars.
- With several individual reviews, also include an aggregate rating. Don't aggregate reviews from other websites, and don't mark up fake or undisclosed incentivized reviews. The review content must be immediately visible on the page.
- Google expects content parity: every marked-up value must match what users can see on the page.
- Validate with the Rich Results Test and URL Inspection, then monitor the related Search Console report for errors and warnings after deployment.
Template Approach
const pageData = {
productName: "Trailhead 35L Hiking Backpack",
reviewerName: "Dana Whitfield",
rating: 4,
reviewDate: "2026-09-21"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "Review",
"itemReviewed": { "@type": "Product", "name": pageData.productName },
"author": { "@type": "Person", "name": pageData.reviewerName },
"reviewRating": {
"@type": "Rating",
"ratingValue": pageData.rating,
"bestRating": 5,
"worstRating": 1
},
"datePublished": pageData.reviewDate
};
{
"@context": "https://schema.org",
"@type": "Review",
"itemReviewed": {
"@type": "Product",
"name": "Trailhead 35L Hiking Backpack"
},
"author": {
"@type": "Person",
"name": "Dana Whitfield"
},
"reviewRating": {
"@type": "Rating",
"ratingValue": 4,
"bestRating": 5,
"worstRating": 1
},
"datePublished": "2026-09-21"
}
Conditional-logic Framework
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$/;
function isAbsoluteUrl(value) {
return typeof value === "string" && /^https?:\/\/[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$/i.test(value);
}
// Returns the ISO 8601 string unchanged (timezone offset kept), or null if unparseable.
function toIsoDateTime(value) {
if (typeof value !== "string") return null;
const text = value.trim();
const m = text.match(ISO_DATE_TIME);
if (!m) return null;
const [, y, mo, d, h = "00", mi = "00", s = "00"] = m;
const date = new Date(Date.UTC(+y, +mo - 1, +d));
if (date.getUTCMonth() !== +mo - 1 || date.getUTCDate() !== +d) return null;
if (+h > 23 || +mi > 59 || +s > 59) return null;
return text;
}
function toIsoDate(value) {
const iso = toIsoDateTime(value);
return iso ? iso.slice(0, 10) : null;
}
// Drops null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((v) => v !== undefined);
return items.length ? items : undefined;
}
if (value && typeof value === "object") {
const out = {};
for (const [key, v] of Object.entries(value)) {
const c = compact(v);
if (c !== undefined) out[key] = c;
}
return Object.keys(out).length ? out : undefined;
}
return value === null || value === undefined || value === "" ? undefined : value;
}
function passesPageGates(source) {
if (source.indexable === false) return false;
if (source.canonicalUrl && source.url && source.canonicalUrl !== source.url) return false;
return source.contentVisible !== false;
}
const REVIEWABLE_TYPES = [
"Book", "Course", "CreativeWorkSeason", "CreativeWorkSeries", "Episode", "Event", "Game", "HowTo",
"LocalBusiness", "MediaObject", "Movie", "MusicPlaylist", "MusicRecording", "Organization", "Product",
"Recipe", "SoftwareApplication"
]; // add the subtypes you use (for example Restaurant, MobileApplication)
const SELF_SERVING_TYPES = ["LocalBusiness", "Organization"];
function toNumber(value) {
if (value === null || value === undefined || value === "" || typeof value === "boolean") return null;
const number = Number(value); // "4,4" fails here: Google needs a dot decimal separator
return Number.isFinite(number) ? number : null;
}
function buildRating(type, raw) {
const ratingValue = toNumber(raw.value);
const bestRating = toNumber(raw.best) ?? 5;
const worstRating = toNumber(raw.worst) ?? 1;
if (ratingValue === null || worstRating >= bestRating) return null;
if (ratingValue < worstRating || ratingValue > bestRating) return null;
return { "@type": type, ratingValue, bestRating, worstRating };
}
// One visible review: Review. A visible average across many reviewers: AggregateRating.
function chooseReviewType(source) {
if (source.aggregate) return "AggregateRating";
return source.review ? "Review" : null;
}
function buildReviewSnippetSchema(source) {
// (a) Page gates: indexable, self-canonical, review content immediately visible on the page.
if (!passesPageGates(source)) return null;
// (b) Required: itemReviewed with a supported @type and a name.
const item = source.item || {};
if (!item.name || !REVIEWABLE_TYPES.includes(item.type)) return null;
// Self-serving: an organization or local business reviewed on a site it controls is ineligible.
if (SELF_SERVING_TYPES.includes(item.type) && source.siteControlledByReviewedEntity) return null;
const itemReviewed = {
"@type": item.type,
name: item.name,
url: isAbsoluteUrl(item.url) ? item.url : null
};
const type = chooseReviewType(source);
if (type === "Review") {
// Review: author (name under 100 characters) and reviewRating.ratingValue are required.
const review = source.review;
const authorName = (review.authorName || "").trim();
const reviewRating = buildRating("Rating", review.rating || {});
if (!authorName || authorName.length >= 100 || !reviewRating) return null;
return compact({
"@context": "https://schema.org",
"@type": "Review",
itemReviewed,
author: { "@type": review.authorIsOrganization ? "Organization" : "Person", name: authorName },
reviewRating,
datePublished: toIsoDate(review.datePublished)
});
}
if (type === "AggregateRating") {
// AggregateRating: ratingValue plus ratingCount or reviewCount are required.
const agg = source.aggregate;
const rating = buildRating("AggregateRating", agg);
const ratingCount = Number.isInteger(agg.ratingCount) && agg.ratingCount > 0 ? agg.ratingCount : null;
const reviewCount = Number.isInteger(agg.reviewCount) && agg.reviewCount > 0 ? agg.reviewCount : null;
if (!rating || (ratingCount === null && reviewCount === null)) return null;
return compact({
"@context": "https://schema.org",
...rating,
itemReviewed,
ratingCount,
reviewCount
});
}
return null;
}
// Usage:
// const source = {
// "url": "https://www.example.com/reviews/trailhead-35l-backpack",
// "canonicalUrl": "https://www.example.com/reviews/trailhead-35l-backpack",
// "indexable": true,
// "contentVisible": true,
// "siteControlledByReviewedEntity": false,
// "item": {
// "type": "Product",
// "name": "Trailhead 35L Hiking Backpack",
// "url": "https://www.example.org/products/trailhead-35l"
// },
// "review": {
// "authorName": "Dana Whitfield",
// "rating": {
// "value": 4,
// "best": 5,
// "worst": 1
// },
// "datePublished": "2026-09-21"
// }
// };
// const schema = buildReviewSnippetSchema(source); // null when a gate or required property fails
import re
from datetime import date, datetime, timezone
ISO_DATE_TIME = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$")
ABSOLUTE_URL = re.compile(r"^https?://[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$", re.I)
def is_absolute_url(value):
return isinstance(value, str) and bool(ABSOLUTE_URL.match(value))
# Returns the ISO 8601 string unchanged (timezone offset kept), or None if unparseable.
def to_iso_date_time(value):
if not isinstance(value, str):
return None
text = value.strip()
m = ISO_DATE_TIME.match(text)
if not m:
return None
y, mo, d = int(m[1]), int(m[2]), int(m[3])
try:
date(y, mo, d)
except ValueError:
return None
if int(m[4] or 0) > 23 or int(m[5] or 0) > 59 or int(m[6] or 0) > 59:
return None
return text
def to_iso_date(value):
iso = to_iso_date_time(value)
return iso[:10] if iso else None
# Drops None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [c for c in (compact(v) for v in value) if c is not None]
return items or None
if isinstance(value, dict):
out = {k: c for k, c in ((k, compact(v)) for k, v in value.items()) if c is not None}
return out or None
return None if value is None or value == "" else value
def passes_page_gates(source):
if source.get("indexable") is False:
return False
if source.get("canonicalUrl") and source.get("url") and source["canonicalUrl"] != source["url"]:
return False
return source.get("contentVisible") is not False
REVIEWABLE_TYPES = [
"Book", "Course", "CreativeWorkSeason", "CreativeWorkSeries", "Episode", "Event", "Game", "HowTo",
"LocalBusiness", "MediaObject", "Movie", "MusicPlaylist", "MusicRecording", "Organization", "Product",
"Recipe", "SoftwareApplication",
] # add the subtypes you use (for example Restaurant, MobileApplication)
SELF_SERVING_TYPES = ["LocalBusiness", "Organization"]
def to_number(value):
if isinstance(value, bool) or value is None or value == "":
return None
try:
number = float(value) # "4,4" fails here: Google needs a dot decimal separator
except (TypeError, ValueError):
return None
if number != number or number in (float("inf"), float("-inf")):
return None
return int(number) if number.is_integer() else number
def build_rating(rating_type, raw):
rating_value = to_number(raw.get("value"))
best = to_number(raw.get("best"))
worst = to_number(raw.get("worst"))
best = 5 if best is None else best
worst = 1 if worst is None else worst
if rating_value is None or worst >= best:
return None
if rating_value < worst or rating_value > best:
return None
return {"@type": rating_type, "ratingValue": rating_value, "bestRating": best, "worstRating": worst}
def positive_int(value):
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
# One visible review: Review. A visible average across many reviewers: AggregateRating.
def choose_review_type(source):
if source.get("aggregate"):
return "AggregateRating"
return "Review" if source.get("review") else None
def build_review_snippet_schema(source):
# (a) Page gates: indexable, self-canonical, review content immediately visible on the page.
if not passes_page_gates(source):
return None
# (b) Required: itemReviewed with a supported @type and a name.
item = source.get("item") or {}
if not item.get("name") or item.get("type") not in REVIEWABLE_TYPES:
return None
# Self-serving: an organization or local business reviewed on a site it controls is ineligible.
if item["type"] in SELF_SERVING_TYPES and source.get("siteControlledByReviewedEntity"):
return None
item_reviewed = {
"@type": item["type"],
"name": item["name"],
"url": item.get("url") if is_absolute_url(item.get("url")) else None,
}
review_type = choose_review_type(source)
if review_type == "Review":
# Review: author (name under 100 characters) and reviewRating.ratingValue are required.
review = source["review"]
author_name = (review.get("authorName") or "").strip()
review_rating = build_rating("Rating", review.get("rating") or {})
if not author_name or len(author_name) >= 100 or not review_rating:
return None
return compact({
"@context": "https://schema.org",
"@type": "Review",
"itemReviewed": item_reviewed,
"author": {
"@type": "Organization" if review.get("authorIsOrganization") else "Person",
"name": author_name,
},
"reviewRating": review_rating,
"datePublished": to_iso_date(review.get("datePublished")),
})
if review_type == "AggregateRating":
# AggregateRating: ratingValue plus ratingCount or reviewCount are required.
agg = source["aggregate"]
rating = build_rating("AggregateRating", agg)
rating_count = positive_int(agg.get("ratingCount"))
review_count = positive_int(agg.get("reviewCount"))
if not rating or (rating_count is None and review_count is None):
return None
return compact({
"@context": "https://schema.org",
**rating,
"itemReviewed": item_reviewed,
"ratingCount": rating_count,
"reviewCount": review_count,
})
return None
{
"@context": "https://schema.org",
"@type": "Review",
"itemReviewed": {
"@type": "Product",
"name": "Trailhead 35L Hiking Backpack",
"url": "https://www.example.org/products/trailhead-35l"
},
"author": {
"@type": "Person",
"name": "Dana Whitfield"
},
"reviewRating": {
"@type": "Rating",
"ratingValue": 4,
"bestRating": 5,
"worstRating": 1
},
"datePublished": "2026-09-21"
}
The Output As An Entity Graph
The conditional-logic output above, parsed in your browser and drawn as the graph a crawler builds from it. Every nested object is a node and the property that holds it is the edge. Tap a node to inspect its properties, or drag to rearrange.
Why Conditional Logic Is Better Than Static Templates
- Prevents emitting empty, null, or stale fields that frequently trigger rich result warnings.
- Supports multiple page states (for example: no rating yet, no image yet, no offers yet) without duplicate templates.
- Lets engineering teams centralize schema policy checks and validation in reusable code paths.
- Makes large-scale schema maintenance safer when content models evolve over time.
Property Reference
What Google's Review snippet documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
| Property | Expects | Notes | |
|---|---|---|---|
| reviewRating | Required | Rating | Review: add reviewRating, the rating given in this review. |
| reviewRating.ratingValue | Required | Number or Text | The rating given in this review. |
| reviewRating.bestRating | Recommended | Number | The highest value allowed in this rating system. If omitted, 5 is assumed. |
| reviewRating.worstRating | Recommended | Number | The lowest value allowed in this rating system. If omitted, 1 is assumed. |
| author | Required | Person or Organization | Review: add author. The reviewer's name must be a valid name. Up to 100 characters. |
| author.name | Required | Text | A valid name. Up to 99 characters. |
| itemReviewed | Required | Book, Course, CreativeWorkSeason, CreativeWorkSeries, Episode, Event, Game, HowTo, LocalBusiness, MediaObject, Movie, MusicPlaylist, MusicRecording, Organization, Product, Recipe or SoftwareApplication | The item that is being reviewed (omit only when nested in the reviewed item via review or aggregateRating). |
| itemReviewed.name | Required | Text | The name of the item that is being reviewed. Required even when nested (the parent item's name). |
| ratingValue | Required | Number or Text | AggregateRating: add ratingValue, the average rating for the item. |
| datePublished | Recommended | Date or DateTime | Review: add datePublished in ISO 8601 format. |
| ratingCount | Recommended | Number | AggregateRating: add ratingCount or reviewCount: the total number of ratings for the item on your site. |
| reviewCount | Recommended | Number | AggregateRating: add reviewCount or ratingCount: the number of people who provided a review with or without a rating. |
| bestRating | Recommended | Number | AggregateRating: add bestRating. If omitted, 5 is assumed. |
| worstRating | Recommended | Number | AggregateRating: add worstRating. If omitted, 1 is assumed. |