Headless Shopify reviews: fetching Loox photo and video reviews without ruining LCP
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
On a headless Shopify store, skip the Loox script widget on product pages and render reviews on the server from the Loox Storefront API. The API is public (no key; you pass your public store ID), returns published reviews with photo and video URLs, and has a bottomline endpoint with the review count and average rating. Called from a Next.js server component it answered without an Origin header, while a browser on a domain Loox doesn't know got 403. Render the review text into the HTML, resize the 500 KB+ original photos through the image optimizer, show video reviews as a thumbnail until the shopper clicks, and print one Product JSON-LD block whose aggregateRating comes from the same bottomline the page shows. In our lab test the server-rendered page weighed 223 KB, against 1.8 MB with the same media loaded eagerly. The official widget couldn't render on our test domain at all: its review iframe only loads on domains you allow-list, which needs Loox's Convert plan. Loox is our pick for photo and video reviews on Shopify; Yotpo fits enterprise brands that want reviews, loyalty and SMS from one vendor.
Photo and video reviews sell, and they are also the heaviest thing on a product page. On a classic Shopify theme the Loox app takes care of placement. On a headless storefront built with Next.js or Hydrogen you choose: paste Loox's script widget into your React page, or read the reviews from Loox's API and render them yourself. This guide does the second, measures both, and builds the part search engines care about: one valid AggregateRating that matches what shoppers see.
We read Loox's API reference (its OpenAPI schema, October 2026) and help center, and called the live Storefront API using the public demo store from Loox's own documentation. The Next.js 16.4.2 app below was built and served with next start; review data for the test product came from that live API, and failures (a 500, zero reviews, a hostile review) were injected for other products through a local proxy. Page weight, LCP and layout shift were measured in headless Edge with mobile settings, a Slow 4G-like network (1.6 Mbps, 150 ms) and 4x CPU slowdown, median of three runs. These are lab numbers on one product, not field data from a real store. All 23 checks pass.
1. The short answer
| Loox script widget | Storefront API, rendered on the server | |
|---|---|---|
| Review text in your HTML | No: it loads in an iframe from loox.io | Yes |
| Works on a headless domain | Only after you allow-list the domain (Convert plan and up) | Yes, from your server |
| JavaScript on your page | loox.js, 27 KB compressed (97 KB raw) | Only a small click handler for video |
| Review photos | Whatever the widget loads (27 image requests in our test) | Resized by your image optimizer, lazy-loaded |
| Structured data | Yours to add, and to keep in sync | Built from the same response the page renders |
| Effort | Paste two tags | About 140 lines of TypeScript, below |
2. What the script widget does on a headless store
Loox's guide for external domains is short: add loox.js?shop=STORE.myshopify.com near the end of the body, then place a <div id="looxReviews" data-product-id="..."> where reviews should go. The script then injects an iframe from loox.io/widget/.../reviews/PRODUCT_ID. Three things follow from that:
- It refuses unknown domains. On our test domain the browser blocked the iframe because of the
frame-ancestorspolicy Loox sends: only domains in Settings > General > External domains may frame it, and Loox's help center says widgets on external domains need the Convert plan or higher. - It's heavy below the fold. Loaded on its own, the widget's iframe document for our test product made 32 requests and transferred 637 KB, 27 of them images from images.loox.io. That's on top of the 27 KB script on your page.
- The reviews aren't in your page. They live in a document on loox.io. Google says it generally tries to associate framed content with the page that embeds it, but that this isn't guaranteed (Search Central office hours, December 2023).
The widget is still the easy route, and on a standard Shopify theme the app embed is the supported one. On a headless product page, where you already render everything on the server, the API fits better.
3. The Loox Storefront API
Loox now documents two APIs. For product pages, use the Storefront API:
| Storefront API | Merchant API | |
|---|---|---|
| Base URL | storefront-api.loox.io/storefront/v1/store/{publicStoreId} | api.loox.io/api/v1/store/{publicStoreId} |
| Auth | None: the public store ID in the URL | X-Api-Secret-Key header |
| Data | Published reviews, abbreviated names ("Sarah M."), no emails or order IDs | Full reviewer details, emails, order IDs |
| Caching | Edge-cached, purged when a review is published or removed | Never cached |
| Limits | None published; up to 250 reviews a page | 120 requests a minute per key |
The endpoints a product page needs are /product-reviews?product_id=... (reviews with text, rating, date, verified flag and media) and /product-reviews/bottomline/{productId} (total count, average rounded to one decimal, and the rating distribution). Both are on the Convert and Unlimited plans. Loox keys products by the numeric Shopify ID, so strip the gid://shopify/Product/ prefix.
What we found when calling it:
- The CORS check is on the Origin header. A request with no Origin, which is what a server sends, got 200. The same request with a foreign Origin got 403 with
"Origin not allowed."So server-side rendering works without adding your domain anywhere; a browser fetch from your headless domain needs it allow-listed. - Responses are cached for a week at the edge (
Cache-Control: public, max-age=60, s-maxage=604800) and purged when reviews change, so frequent calls are cheap for Loox and fast for you. - Videos are Cloudflare Stream iframes. A video's
urlishttps://iframe.videodelivery.net/{id}, with a separatethumbnailUrl(15 KB in our sample). Photo URLs point at the original upload: the one we checked was 724 KB, and adding awidthparameter didn't change it. - The docs' example and the live API disagree on one value. The example response says
"type": "image"; the schema and the live API say"photo". The code below treats anything that isn'tvideoas a photo. - Python's default User-Agent gets 403 from Cloudflare in front of the API. Node's
fetch, which Next.js uses, was fine.
4. Step 1: fetch reviews on the server
One small module. It fails soft: if Loox is slow or down, the product page renders without reviews instead of erroring. It also filters media URLs to Loox's own hosts, which matters twice: those URLs end up in <img> and <iframe> tags, and next/image throws on any host missing from images.remotePatterns, which would take the whole page down.
// Loox Storefront API: public data, no API key. A server-side fetch sends no Origin header and is
// answered; a browser on a domain Loox doesn't know gets 403. Reviewer names arrive abbreviated
// ("Sarah M.") and emails/order IDs are never included, so nothing private reaches the HTML.
const API = process.env.LOOX_API ?? 'https://storefront-api.loox.io'
const STORE = process.env.LOOX_PUBLIC_STORE_ID // Loox admin: Settings > API Keys
export type Media = {type: 'photo' | 'video'; url: string; thumbnailUrl?: string}
export type Review = {
id: string; rating: number; body: string | null; date: string; verified?: boolean
reviewer?: {name: string}; media?: Media[]
}
export type Bottomline = {totalReviews: number; averageRating: number}
async function loox<T>(path: string, productId: string): Promise<T | null> {
try {
const res = await fetch(`${API}/storefront/v1/store/${STORE}${path}`, {
next: {revalidate: 3600, tags: [`loox:${productId}`]}, signal: AbortSignal.timeout(3000)})
if (!res.ok) throw new Error(`Loox ${res.status}`)
return await res.json()
} catch (e) {
// ponytail: the page then renders, and is cached, without reviews until the next revalidate (1 h)
console.error(`Loox ${path}: ${(e as Error).message}`)
return null
}
}
// Media URLs come from a third party and end up in <img src> and <iframe src>: keep known hosts only.
// next/image also throws on any host missing from images.remotePatterns, which would fail the page.
const PHOTO = /^https:\/\/images\.loox\.io\//
const VIDEO = /^https:\/\/iframe\.videodelivery\.net\/[\w-]+$/
const safe = (m: Media) => m.type === 'video' // the docs' example says "image"; the live API says "photo"
? VIDEO.test(m.url) && (!m.thumbnailUrl || PHOTO.test(m.thumbnailUrl))
: PHOTO.test(m.url)
export const getBottomline = (productId: string) =>
loox<Bottomline>(`/product-reviews/bottomline/${productId}`, productId)
export async function getReviews(productId: string, limit = 12): Promise<Review[]> {
// sort=featured: pinned first, then reviews with photos or video, then newest
const d = await loox<{reviews: Review[]}>(
`/product-reviews?product_id=${productId}&limit=${limit}&sort=featured`, productId)
return (d?.reviews ?? []).map((r) => ({...r, media: (r.media ?? []).filter(safe)}))
}
In next.config.ts, allow the two image hosts:
images: {remotePatterns: [{protocol: 'https', hostname: 'images.loox.io'}, {protocol: 'https', hostname: 'cdn.shopify.com'}]}
5. Step 2: one AggregateRating, built on the server
Google's review snippet rules (updated September 2026) that matter here: ratings must be sourced directly from users, the marked-up reviews must be readily available on the page, the review must be about a specific item, and an AggregateRating nested in a Product needs no itemReviewed. At least one of ratingCount or reviewCount is required, and a count of zero isn't a rating at all.
Duplicates are the usual problem on headless builds, because several layers each think they own the markup: a Product block in the layout, another from an SEO component, and a third from a reviews component that prints its own rating. Search engines then see two products, or one product with two ratings. The fix is organisational: one function builds the page's only Product node, and nothing else prints Product or rating markup.
import type {Product} from './shopify'
import type {Bottomline, Review} from './loox'
// The page's only Product node, built on the server from the same data the page renders.
// Nothing else (layout, review component, app embed) may print Product or AggregateRating markup.
export function productJsonLd(p: Product, url: string, bottom: Bottomline | null, shown: Review[]) {
const ld: Record<string, unknown> = {
'@context': 'https://schema.org', '@type': 'Product', '@id': `${url}#product`, name: p.title, url,
image: p.image ? [p.image.url] : undefined, description: p.description || undefined,
offers: {'@type': 'Offer', url, price: p.price.amount, priceCurrency: p.price.currencyCode,
availability: `https://schema.org/${p.availableForSale ? 'InStock' : 'OutOfStock'}`},
}
if (bottom && bottom.totalReviews > 0) { // a rating with a count of 0 is invalid: leave it out
ld.aggregateRating = {'@type': 'AggregateRating', ratingValue: bottom.averageRating,
reviewCount: bottom.totalReviews, bestRating: 5, worstRating: 1}
// Mark up only reviews that are visible on this page, and only with a real reviewer name
ld.review = shown.filter((r) => r.reviewer?.name).map((r) => ({
'@type': 'Review', datePublished: r.date.slice(0, 10), reviewBody: r.body || undefined,
author: {'@type': 'Person', name: r.reviewer!.name},
reviewRating: {'@type': 'Rating', ratingValue: r.rating, bestRating: 5, worstRating: 1},
}))
}
// JSON.stringify leaves "<" alone, so a review containing "</script>" would close the tag
return JSON.stringify(ld).replace(/</g, '\\u003c')
}
Two details are easy to miss. JSON.stringify doesn't escape <, so a review containing </script> would close your script tag and run whatever follows; the last line prevents that, and the test feeds it exactly that review. And the rating comes from the bottomline endpoint, not from averaging the 12 reviews you fetched, so the stars match Loox's own count.
If you'd rather not call Loox for the rating, Loox also writes it to Shopify's standard reviews.rating and reviews.rating_count product metafields (and its own loox.avg_rating and loox.num_reviews). They're only readable from the Storefront API if the metafield definitions are exposed to it, and developers have reported getting null when they weren't.
6. Step 3: the page, and video that waits for a click
The product image is the LCP element, so it's preloaded and never lazy. Everything from Loox is below it: review text in the HTML, photos through the image optimizer at 160 px, and the review section marked content-visibility: auto, so the browser skips laying it out until it's near the viewport.
import Image from 'next/image'
import {notFound} from 'next/navigation'
import {getProduct} from '@/lib/shopify'
import {getBottomline, getReviews} from '@/lib/loox'
import {productJsonLd} from '@/lib/product-jsonld'
import ReviewVideo from './review-video'
export const revalidate = 3600
export default async function ProductPage({params}: {params: Promise<{handle: string}>}) {
const {handle} = await params
const product = await getProduct(handle)
if (!product) notFound()
const [bottom, reviews] = await Promise.all([getBottomline(product.looxId), getReviews(product.looxId)])
const url = `${process.env.SITE_URL}/products/${handle}`
const ld = productJsonLd(product, url, bottom, reviews.slice(0, 5))
return (
<main>
<script type="application/ld+json" dangerouslySetInnerHTML={{__html: ld}} />
{product.image && (
// The LCP element: preloaded, high priority, never lazy
<Image src={product.image.url} alt={product.image.altText ?? product.title} preload
width={product.image.width} height={product.image.height} sizes="(max-width: 768px) 100vw, 50vw" />
)}
<h1>{product.title}</h1>
<p>{product.price.amount} {product.price.currencyCode}</p>
{bottom && bottom.totalReviews > 0 && (
<p><a href="#reviews">{bottom.averageRating} out of 5 stars, {bottom.totalReviews} reviews</a></p>
)}
{/* Below the fold: in the HTML for crawlers, but not laid out or painted until scrolled near */}
<section id="reviews" style={{contentVisibility: 'auto', containIntrinsicSize: 'auto 1500px'}}>
<h2>Customer reviews</h2>
{reviews.map((r) => (
<article key={r.id}>
<p aria-label={`${r.rating} out of 5 stars`}>{'★'.repeat(r.rating) + '☆'.repeat(5 - r.rating)}</p>
<p><strong>{r.reviewer?.name}</strong>{r.verified && ' · Verified buyer'} · <time dateTime={r.date}>{r.date.slice(0, 10)}</time></p>
{r.body && <p>{r.body}</p>}
{r.media!.map((m) => m.type === 'video'
? <ReviewVideo key={m.url} src={m.url} poster={m.thumbnailUrl} label={`video review by ${r.reviewer?.name}`} />
// Loox serves the original upload (often 500 KB+); the image optimizer resizes it to 160px
: <Image key={m.url} src={m.url} alt={`Photo from ${r.reviewer?.name}'s review`} width={160} height={160}
sizes="160px" style={{objectFit: 'cover'}} />)}
</article>
))}
</section>
</main>
)
}
Video reviews render as a button with the thumbnail. Clicking swaps in the Stream iframe at the same size, so there's no layout shift:
'use client'
import {useState} from 'react'
// A thumbnail and a button until the shopper asks for the video: no player JS, no stream bytes
// and no third-party iframe before the click. The box keeps its size, so swapping causes no layout shift.
export default function ReviewVideo({src, poster, label}: {src: string; poster?: string; label: string}) {
const [playing, setPlaying] = useState(false)
const box = {width: 160, height: 284, border: 0, padding: 0, position: 'relative', background: '#111'} as const
if (playing) {
return <iframe src={`${src}?autoplay=true`} title={label} style={box}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen" />
}
return (
<button type="button" aria-label={`Play ${label}`} onClick={() => setPlaying(true)} style={box}>
{poster && <img src={poster} alt="" loading="lazy" decoding="async" width={160} height={284}
style={{objectFit: 'cover', width: '100%', height: '100%'}} />}
<span aria-hidden="true" style={{position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
color: '#fff', fontSize: 40}}>▶</span>
</button>
)
}
That click matters. In our test, starting one video review loaded 765 to 980 KB from Cloudflare Stream within eight seconds: the player plus the first video segments. Embedded eagerly, every visitor pays that, whether they watch or not.
7. What we measured
| Version | Requests | LCP (median of 3) | Layout shift |
|---|---|---|---|
| Server-rendered, lazy media | 12 | About 0.9 s | 0 |
| Server-rendered, eager media | 21 | About 0.9 s | 0 |
| Script widget (iframe refused) | 11 | About 0.7 s | 0.001 |
The LCP column surprised us: eager media barely moved it. The product image was preloaded with high priority in all three versions, so it won the race for bandwidth, and the review media competed only with each other. The widget version came out fastest because nothing rendered in its iframe. So the lesson isn't that reviews wreck LCP by themselves. It's that you protect LCP by keeping the hero image first and keeping review media out of the first screen. What eager media and the widget really cost is everything after that: about 1.6 MB more transferred in the eager version, which matters on a metered phone plan and for every interaction that follows.
Where reviews do hurt LCP is when a review element becomes the largest thing on the first screen: a UGC carousel above the fold, or a hero that isn't preloaded and loses the race to 724 KB originals. On mobile, check which element your LCP actually is before you move the review section.
8. Test results
| Check | Result |
|---|---|
| Exactly one JSON-LD block and one Product node; one AggregateRating across all JSON-LD | Pass |
| Rating and count equal the live Loox bottomline, and are visible on the page | Pass |
| Every marked-up review (max 5) is visible on the page | Pass |
| Server-side calls send no Origin header; repeat views make no new Loox calls | Pass |
| Zero reviews: no rating markup. Loox returning 500: page still 200, no rating markup | Pass |
A review containing </script><script> stays inside the JSON-LD and never runs | Pass |
Unknown media hosts and javascript: URLs dropped without breaking the page | Pass |
| LCP element is the product image, layout shift 0 | Pass |
| No original photo downloaded; resized review photos under 40 KB each | Pass |
| No video player or Loox script before a click; the player loads on click | Pass |
| Widget on a domain that isn't allow-listed: its iframe is refused | Pass (confirmed) |
Not tested: a real Loox account of our own (we used Loox's public demo store), Loox webhooks, Hydrogen, and field data from real shoppers.
9. Our opinion: trade-offs
- Use the API on headless product pages. Text in the HTML, media under your control, and one rating you can reason about. The cost is the code above and a dependency on Loox's response format.
- The widget is fine where SEO and weight don't matter, for example a reviews page that is mostly a gallery, or a quick launch before the custom component exists.
- Keep the cache. An hour of revalidation is plenty for reviews. If you need new reviews to show faster, Loox webhooks can call your revalidation endpoint, but Loox says deliveries are best-effort and not retried, so keep the timer as a backstop.
- The ceiling of fail-soft: if Loox fails during a revalidation, that page is cached without reviews until the next one. For most stores an hour without stars beats an error page.
Related: on-demand ISR for a headless Shopify store and server-rendering Google and Instagram social proof in WooCommerce.
10. Tools
Top pick
| Tool | Role | Why it fits |
|---|---|---|
| Photo and video reviews for Shopify | Collects photo and video reviews and now exposes them through a public Storefront API and an authenticated Merchant API, with webhooks. Free Beginner plan; the API, video reviews and external-domain widgets need Convert, from $49.99 a month, with a 14-day free trial (pricing page, October 2026). |
Alternatives: free tools and references
We don't earn anything from these links.
- Yotpo: reviews, loyalty and SMS from one vendor, aimed at larger brands that want a single enterprise contract.
- Loox API reference: the endpoints and schemas used here.
- Google review snippet guidelines: the rules for AggregateRating.
- Next.js Image: remote patterns, sizes and preload.
- Cloudflare Stream player: the player behind Loox video URLs.
11. Pitfalls
- Two sources of rating markup, for example a layout Product block and a reviews component that prints its own.
- Averaging the reviews you fetched instead of using the bottomline, so the stars disagree with the count.
- Printing
reviewCount: 0for products without reviews. - Using
JSON.stringifyoutput raw inside a script tag. - Passing Loox's original photo URLs straight to
<img>: 500 KB+ each. - Embedding Stream iframes for every video review instead of waiting for a click.
- Calling the Storefront API from the browser on a headless domain you haven't allow-listed: 403.
- Using the Merchant API for page rendering, which puts customer emails one bug away from your HTML and isn't cached.
On a headless Shopify store, render Loox reviews on the server from the Storefront API: text in the HTML, photos resized, videos behind a click, and one Product node whose rating comes from Loox's bottomline. Keep the product image first and preloaded, and LCP stays where it was. You save the widget's iframe and script, and you get structured data you control.