David Lewin David Lewin
E-Commerce, Research & Ads

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.

Short answer

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.

How this guide was made

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 widgetStorefront API, rendered on the server
Review text in your HTMLNo: it loads in an iframe from loox.ioYes
Works on a headless domainOnly after you allow-list the domain (Convert plan and up)Yes, from your server
JavaScript on your pageloox.js, 27 KB compressed (97 KB raw)Only a small click handler for video
Review photosWhatever the widget loads (27 image requests in our test)Resized by your image optimizer, lazy-loaded
Structured dataYours to add, and to keep in syncBuilt from the same response the page renders
EffortPaste two tagsAbout 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-ancestors policy 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 APIMerchant API
Base URLstorefront-api.loox.io/storefront/v1/store/{publicStoreId}api.loox.io/api/v1/store/{publicStoreId}
AuthNone: the public store ID in the URLX-Api-Secret-Key header
DataPublished reviews, abbreviated names ("Sarah M."), no emails or order IDsFull reviewer details, emails, order IDs
CachingEdge-cached, purged when a review is published or removedNever cached
LimitsNone published; up to 250 reviews a page120 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 url is https://iframe.videodelivery.net/{id}, with a separate thumbnailUrl (15 KB in our sample). Photo URLs point at the original upload: the one we checked was 724 KB, and adding a width parameter 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't video as 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.
Where the reviews travel
Where the reviews travelShopify Storefront API / Loox Storefront API → Next.js server component → HTML to the browser → Shopper clicks playShopify Storefront API: product, price, image, numeric product IDShopify Storefront APIproduct, price, image,numeric product IDLoox Storefront API: bottomline plus the first 12 reviews, no keyLoox Storefront APIbottomline plus the first 12reviews, no keyNext.js server component: both fetches cached for an hour, JSON-LD built from the same dataNext.js server componentboth fetches cached for an hour, JSON-LD built from the samedataHTML to the browser: product image preloaded, review text, resized photos, video thumbnailsHTML to the browserproduct image preloaded, review text, resized photos, videothumbnailsShopper clicks play: only then the Cloudflare Stream player loadsShopper clicks playonly then the Cloudflare Stream player loads
Nothing from Loox runs in the browser until someone asks for a video. Crawlers get the review text and one Product node in the first response.

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

Transferred on one mobile product page view
Transferred on one mobile product page viewServer-rendered, lazy media (this guide): 223 KB; Script widget, estimated where allowed: 823 KB; Server-rendered, every photo and video eager: 1804 KBServer-rendered, lazy media (this guide): 223 KBServer-rendered, lazy media (this guide)223 KBScript widget, estimated where allowed: 823 KBScript widget, estimated where allowed823 KBServer-rendered, every photo and video eager: 1804 KBServer-rendered, every photo and video eager1804 KB
Headless Edge, 390 px viewport, Slow 4G-like throttling, no scrolling, same product and reviews. The widget figure is an estimate: our page with the script (186 KB, iframe refused on our domain) plus the widget's iframe document measured on its own (637 KB).
VersionRequestsLCP (median of 3)Layout shift
Server-rendered, lazy media12About 0.9 s0
Server-rendered, eager media21About 0.9 s0
Script widget (iframe refused)11About 0.7 s0.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

CheckResult
Exactly one JSON-LD block and one Product node; one AggregateRating across all JSON-LDPass
Rating and count equal the live Loox bottomline, and are visible on the pagePass
Every marked-up review (max 5) is visible on the pagePass
Server-side calls send no Origin header; repeat views make no new Loox callsPass
Zero reviews: no rating markup. Loox returning 500: page still 200, no rating markupPass
A review containing </script><script> stays inside the JSON-LD and never runsPass
Unknown media hosts and javascript: URLs dropped without breaking the pagePass
LCP element is the product image, layout shift 0Pass
No original photo downloaded; resized review photos under 40 KB eachPass
No video player or Loox script before a click; the player loads on clickPass
Widget on a domain that isn't allow-listed: its iframe is refusedPass (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

ToolRoleWhy it fits
LooxPhoto and video reviews for ShopifyCollects 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.

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: 0 for products without reviews.
  • Using JSON.stringify output 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.
Bottom line

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.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.