Review Schema Best Practices for Shopify Review Apps
If you have ever wondered why your product shows one rating by the buy box and a different rating in Google, this guide is for you. The short version is simple. Whatever a shopper sees in the UI should be exactly what crawlers find in your HTML. No surprises. No stale numbers. Below we walk through a clean snippet you can drop into your theme, then we compare the top implementation patterns like a product review so you can choose with confidence.
What do search engines and assistants actually need?
- Product with stable identity:
sku
,gtin
ormpn
, brand, canonical URL. - Offer that reflects live price and availability at first paint.
- AggregateRating with the same average and count you render near your buy box.
- Review objects for the exact reviews visible on the page. Do not mark up hidden or paginated content.
- Server-rendered JSON-LD. Client-only injection is unreliable for crawlers.
Why matching matters
Liquid snippet with small adapters
This snippet renders Product and Offer, then attaches AggregateRating and a sample of visible reviews. It includes tiny adapters so you can normalize data coming from different providers without naming brands. Map your data into reviews_avg
, reviews_count
, and reviews_visible
before output.
{% comment %} snippets/review-schema.liquid Purpose: Output Product, Offer, AggregateRating, and Review JSON-LD. Assumptions: - You can access review data via one of: A) Metafields on product or variant B) App snippet variables exposed server side C) A cached section that includes review JSON - You will set: reviews_avg => number like 4.6 reviews_count => integer like 217 reviews_visible => array of reviews with keys: author, body, rating, published_at {% endcomment %} {%- comment -%} ADAPTERS: PICK THE FIRST AVAILABLE SOURCE {%- endcomment -%} {%- assign reviews_avg = product.metafields.reviews.avg | default: reviews_avg -%} {%- assign reviews_count = product.metafields.reviews.count | default: reviews_count -%} {%- if reviews_avg == blank and product.metafields.custom.review_widget_avg != blank -%} {%- assign reviews_avg = product.metafields.custom.review_widget_avg -%} {%- endif -%} {%- if reviews_count == blank and product.metafields.custom.review_widget_count != blank -%} {%- assign reviews_count = product.metafields.custom.review_widget_count -%} {%- endif -%} {%- comment -%} reviews_visible should be an array of hashes. If your provider exposes a JSON blob in a metafield, parse it into a Liquid array with split filters or render a server snippet that loops through reviews already visible on page. {%- endcomment -%} {%- capture product_schema -%} { "@context": "https://schema.org", "@type": "Product", "@id": "{{ product.url | absolute_url }}#product", "url": "{{ product.url | absolute_url }}", "name": {{ product.title | json }}, "image": [{% for img in product.images %}{{ img.src | image_url: width: 1200 | absolute_url | json }}{% unless forloop.last %},{% endunless %}{% endfor %}], "description": {{ product.description | strip_html | strip_newlines | truncate: 500 | json }}, {% if product.selected_or_first_available_variant.sku != blank %}"sku": {{ product.selected_or_first_available_variant.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", "priceCurrency": "{{ cart.currency.iso_code }}", "price": "{{ product.selected_or_first_available_variant.price | money_without_currency | replace: ',', '' }}", "availability": "{% if product.selected_or_first_available_variant.available %}https://schema.org/InStock{% else %}https://schema.org/OutOfStock{% endif %}", "url": "{{ product.url | absolute_url }}", "itemCondition": "https://schema.org/NewCondition" } } {%- endcapture -%} <script type="application/ld+json">{{ product_schema }}</script> {%- if reviews_count and reviews_count != blank and reviews_count != '0' -%} {%- assign normalized_avg = reviews_avg | times: 10 | round | divided_by: 10.0 -%} <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "AggregateRating", "itemReviewed": { "@id": "{{ product.url | absolute_url }}#product" }, "ratingValue": "{{ normalized_avg }}", "reviewCount": "{{ reviews_count }}" } </script> {%- endif -%} {%- if reviews_visible and reviews_visible.size > 0 -%} {%- comment -%} OUTPUT ONLY WHAT IS RENDERED ON PAGE {%- endcomment -%} <script type="application/ld+json"> { "@context": "https://schema.org", "@graph": [ { "@type": "Product", "@id": "{{ product.url | absolute_url }}#product" } {% for r in reviews_visible limit: 20 %} ,{ "@type": "Review", "itemReviewed": { "@id": "{{ product.url | absolute_url }}#product" }, "author": { "@type": "Person", "name": {{ r.author | default: "Verified Buyer" | json }} }, "datePublished": "{{ r.published_at | date: "%Y-%m-%d" }}", "reviewBody": {{ r.body | strip_html | strip_newlines | json }}, "reviewRating": { "@type": "Rating", "ratingValue": "{{ r.rating }}", "bestRating": "5", "worstRating": "1" } } {% endfor %} ] } </script> {%- endif -%}
Deep comparison: the 4 most common implementation patterns
We will compare these like products you could buy. Instead of brand names we use archetypes that map to most platforms on the market.
Archetype | How it works | Where schema is rendered | Strengths | Trade offs | Who should pick this |
---|---|---|---|---|---|
Enterprise Suite | Full UGC platform with reviews, Q&A, photos, syndication, and moderation. Exposes server variables or metafield sync for averages and counts. | Usually server-rendered via metafields or app sections. Some widgets also inject client script. | Reliable SSR, governance, locale support, and APIs. Easier to keep parity across regions and templates. | Complex to configure. More moving parts. Higher cost. | Large catalogs or multi-region stores that need strict control and audit trails. |
Mid‑Market Customizable | Lean feature set with solid metafield writes and simple server snippets. Good API coverage. | Server by default through metafields or include snippets. Optional client-only widgets exist. | Great balance of control and simplicity. Lower theme bloat. Faster to ship. | Requires some theme mapping and occasional integrity checks. | Most Shopify themes. Teams with a developer a few hours per month. |
Lightweight Value | Widget first. Often injects JSON-LD on the client. May expose minimal metafields. | Client by default. Server only if you wire your own snippet. | Lowest lift to get reviews on page. Good for MVP or single SKU brands. | Risk of client-only schema, hydration flicker, and mismatch with UI if you paginate. | New stores and temporary tests that can accept limitations. |
Headless‑Native Provider | APIs and webhooks only. You fetch on the server, normalize, and emit JSON-LD during SSR. | Server always. Controlled by your framework. | Maximum control, consistency, and performance. One adapter for all surfaces. | All responsibility on your team. You must maintain parity with Online Store if you use it. | Headless builds and multi-storefront setups with engineering support. |
Feature-by-feature scorecard
Scored 1 to 5. Higher is better.
Capability | Enterprise Suite | Mid‑Market Customizable | Lightweight Value | Headless‑Native |
---|---|---|---|---|
Server rendered schema by default | 5 | 4 | 2 | 5 |
Metafield sync: avg, count, sample reviews | 5 | 4 | 2 | 3 |
Widget injects client-only schema | 2 | 3 | 4 | 1 |
Locale i18n support | 5 | 4 | 2 | 5 |
Media UGC (photos, video) | 5 | 4 | 3 | 5 |
Moderation & spam controls | 5 | 4 | 2 | 5 |
APIs and webhooks | 5 | 4 | 2 | 5 |
Ease of theme integration | 3 | 4 | 5 | 3 |
Risk of schema/UI mismatch | 2 | 3 | 4 | 1 |
Performance footprint | 3 | 4 | 3 | 5 |
Cost to operate | 2 | 4 | 5 | 3 |
Recommendation by team size
- Solo or small team. Pick Mid‑Market Customizable. Use metafields plus the snippet above. Add a weekly integrity check.
- Growing team with developer support. Use a Section Rendering API cache or an app proxy that inlines JSON-LD at first paint.
- Headless. Normalize in your server layer and emit JSON-LD during SSR. Keep Online Store in parity if it is still used for some templates.
Adapter pattern for headless projects
If you are headless, shape various provider payloads into one internal model. Emit JSON-LD from the server so crawlers get complete markup without running scripts.
// reviews-adapter.ts export type NormalizedReview = { author: string body: string rating: number publishedAt: string } export type NormalizedSummary = { avg: number; count: number } export function normalizeSummary(payload: any): NormalizedSummary { if (payload?.summary?.average && payload?.summary?.count) { return { avg: Number(payload.summary.average), count: Number(payload.summary.count) } } if (payload?.avg_rating && payload?.reviews_count) { return { avg: Number(payload.avg_rating), count: Number(payload.reviews_count) } } // Fallback return { avg: 0, count: 0 } } export function normalizeReviews(payload: any[]): NormalizedReview[] { return (payload || []).slice(0, 20).map(r => ({ author: r.author || "Verified Buyer", body: String(r.body || "").trim(), rating: Number(r.rating || r.stars || 0), publishedAt: (r.published_at || r.created_at || "").slice(0, 10) })) } // server usage // const summary = normalizeSummary(providerResponse) // const reviews = normalizeReviews(providerResponse.reviews) // render JSON-LD on the server with these values
Implementation playbooks
Metafield feed + theme snippet
- Sync
avg
andcount
to namespaced product metafields. - Optionally sync the latest 10–20 reviews to a JSON metafield for server output.
- Render the snippet above and link
@id
from AggregateRating and Review to the Product. - Run a weekly job that compares UI vs schema numbers and alerts if they drift.
Section render cache
- Render a hidden section that includes normalized JSON for reviews.
- Warm a cache by calling the Section Rendering API for top PDPs.
- Inline the JSON-LD in the HTML. Bust cache on new reviews or price change.
App proxy endpoint
- Expose
/apps/reviews-json?product_id=
that returns normalized summary and a review sample. - During SSR, fetch and inline the JSON-LD. Avoid client hydration for schema.
- Version the shape so you can evolve it without breaking themes.
Headless
- Fetch from your provider on the server. Normalize with an adapter.
- Emit Product, Offer, AggregateRating, and Review during SSR.
- Share the same adapter with your Online Store theme if both exist.
Edge cases and pitfalls to avoid
- Client-only injection. If schema appears only after JS runs, you risk no rich result. Always server render.
- Paginated reviews. Do not mark up reviews that are hidden on first paint. Keep schema to content that is visible.
- Wrong identity. Add
gtin
ormpn
. Use stable URLs and one@id
per PDP. - Price mismatch. Sync Offer price and currency with the variant shown by default.
- Locale drift. Ratings may differ by region. Keep per-locale metafields if you localize content.
Validation checklist
- View source to confirm JSON-LD exists without client execution.
- Match UI average and count exactly to schema values.
- Strip HTML tags in
reviewBody
. Keep formatting for the visible UI only. - Use ISO dates like
YYYY-MM-DD
. - Mark up only what is visible at first paint.
Frequently asked questions (FAQ)
Should JSON-LD be server-rendered or client-injected?
How many reviews should I include in JSON-LD?
Can I mark up paginated or hidden reviews?
How should I keep rating parity with the UI?
Where should AggregateRating
link?
itemReviewed
with @id
pointing to your Product node (e.g., #product
) and keep that ID consistent across related objects.