Product Detail Test — Theme Integration
A Product Image & Title Test compares different titles, descriptions and images for the same product. Unlike every other Eraya test type, this one is rendered by your theme, not by JavaScript — so it needs a one-time integration into your Liquid before your first test can run.
This page is for developers integrating it by hand. You only need to do this once: after the integration is in place, every future product test runs without touching the theme again.
Why the theme renders it (and not the SDK)
It's a fair question, because every other Eraya visual test patches the DOM from JavaScript. This one deliberately does not.
A theme renders product.title in far more places than it looks: the <h1>, the breadcrumb, the browser <title>, JSON-LD structured data, the sticky add-to-cart bar, cart line items, search results. If the SDK swapped three of those and missed two, it would have no way to detect it — what a page renders is a fact only Liquid has. The shopper would see a mix that exists in neither variation, and the experiment would measure something nobody designed.
Rendering in Liquid makes that failure impossible by construction. The trade-off is this integration, which is why it exists.
The flow
When a test goes live, Eraya writes a metafield onto each product in the test containing every variation's overrides.
On the visitor's first page, the Eraya SDK buckets them and writes a cart attribute naming their variation, then reloads once.
Every page from then on is server-rendered: your Liquid reads the metafield, reads the cart attribute, and renders that variation.
Prerequisites
Eraya app embed enabled on the theme
The SDK does the bucketing and writes the cart attribute. No theme.liquid edit is needed — the embed handles it.
A backup of your theme
Duplicate the theme before you start. These are edits to core product rendering.
Shopify Liquid familiarity
You'll be editing main-product, card-product, gallery and cart snippets.
Will this work on my theme?
The contract is theme-agnostic — a product metafield plus a cart attribute, both plain Liquid. Any theme on any Shopify plan can read them, Online Store 2.0 or vintage. Steps 1–3 (resolver, media pool, gallery) work anywhere.
Steps 4 and 5 are shaped by Dawn conventions and need adapting:
A hide_variants section setting strips variant images
Skip that snippet — it exists to stop Dawn's rule from removing images your variation added.
The variant picker reads product.variants | json
Find what your picker actually reads and apply the same rewrite to that. Some themes serialise the whole product, some use a <script type="application/json"> with a custom shape. The rule is unchanged: never leave a media reference pointing at a slide the override removed.
The real limit: surfaces rendered from JSON, not Liquid.
Anything your theme builds from Shopify's Ajax APIs — /products/<handle>.js, /search/suggest.json, /cart.js — receives the real product data. Those endpoints know nothing about the metafield, so a quick-view modal or a JSON-driven search dropdown will show the original title and image while the product page shows the variation.
Dawn avoids this by rendering those surfaces through the Section Rendering API, which runs your Liquid and therefore picks up the integration for free. Heavily JS-driven themes often don't.
Two of these matter differently:
Discovery surfaces — quick view, JSON search, JS-rendered collection cards. These come before the shopper decides, so they're part of what you're testing. Patch them with the JavaScript API.
Cart surfaces built from
/cart.js. Leave them. The shopper has already seen the variation and acted on it, so the cart is downstream of the decision the experiment measures. More to the point, Shopify's checkout and the order itself always show the real product title — you cannot change that from a theme. Rewriting the cart line only moves the inconsistency one step later, and a cart that disagrees with the confirmation email is worse than one that doesn't.
A quick way to see what you're dealing with before you start: search your theme for products/ + .js, suggest.json and cart.js. Hits outside cart code mean JS work ahead; few or none means the Liquid integration covers you.
Let an AI coding agent do it
If you use Claude Code, Cursor, Copilot or similar, you can hand the whole integration over. Pull your theme locally first (shopify theme pull), open the theme folder in your agent, and paste the prompt below.
Work on a duplicate theme, never the live one. The agent edits core product rendering across a dozen files. Review its diff and preview the result before you publish.
The prompt is self-contained — it carries the full data contract, so the agent doesn't need to read this page.
To check the agent's work, use Testing your integration below — force a variation with the cart snippet and confirm every surface changes together.
The data contract
Everything below is stable API. Eraya changes it additively.
1. The product metafield
Namespace
app--258333999105
Key
product_test
Type
json
Written
when a test starts, on every product in the test
Cleared
when the test stops (written as {})
Shape:
Rules worth knowing before you write code against it:
The control never has an entry. Falling through to the real product is the control, so omitting it is what lets Liquid treat "no entry" as "no override" without a separate flag.
Keys are omitted, never empty. A variation that overrides only the title has no
mediakey at all. This matters — see the empty-array gotcha.variationsis keyed by variation id, matching the cart attribute's value.vis the payload version. Gate on it, so a futurev: 2renders the real product on an un-updated theme instead of breaking.
2. The cart attribute
The SDK writes it. You only read it.
Never write anything else under the _eraya_product_test_ prefix. Eraya attributes orders by matching this exact prefix on line-item properties and recovering the test id with name.replace(prefix, ''). A sibling key like _eraya_product_test_type_123 would match first and produce a variation of "product" against a test id of "type_123" — which doesn't error, it just yields a test with traffic and zero orders on both arms. That reads as a real result.
3. The media reference pools (optional)
app--258333999105
test_media_1, test_media_2, test_media_3, test_media_4
list.file_reference
Only needed if your tests use images from the Shopify Files library rather than images already attached to the product.
Liquid can turn a media id back into a renderable object only if that media belongs to the product being rendered. An image pulled from Files resolves to nothing, and the gallery silently falls back to the control. The pools fix that: a list.file_reference metafield resolves any file into a media object, attached to the product or not. They're sharded across four keys for capacity.
Step 1 — The resolver block
This is the core. It goes at the top of every file that renders a tested product surface, before anything is output.
Every path that isn't "this visitor is bucketed into a variation that overrides this product" leaves eraya_entry nil and eraya_title / eraya_description holding the real values. The block is safe to add before you've built anything else — with no test running it's a no-op.
Then replace the render sites in that file:
Including the ones that are easy to miss: | escape in alt and aria-label attributes, data- attributes, and anything feeding structured data.
Step 2 — The media pool
Skip this if your tests only ever use images already attached to the product.
Pool assembly uses paginate, which is a block tag — it cannot live inside a {%- liquid -%} tag, so it sits between the resolver block and the gallery block:
Step 3 — The gallery override
Then replace product.media with eraya_media throughout the file's gallery logic — the loop, media_count, the where: 'media_type' filters and the thumbnail list.
Note the resolution order: pool first, product second. And note that the whole thing fails open — a stale or missing pool renders the real gallery, never a blank one.
Step 4 — Per-variant featured media
Only needed if your tests bind images to specific variants.
Themes that hide variant images
Dawn-family themes have a hide_variants setting that strips variant images from the gallery. An explicit gallery override has to win over that rule, or it will strip images the merchant deliberately chose to show:
Step 5 — The variant JSON blob
Themes serialise product.variants | json into the page for the variant picker to read. If the gallery changed but that blob still points at a removed slide, the picker's switchTo throws — taking price and availability updates down with it.
The fix is to rebuild the blob per variant rather than string-matching ids:
Then output {{ eraya_variants_json }} where the theme had {{ product.variants | json }}.
The limit: 250 is a deliberate ceiling — products above it fall back to the untouched blob rather than bloating the page.
Which surfaces to patch
The title a shopper sees in the cart has to match the one they saw on the product page, or the experiment is measuring a confusing experience rather than a clean variation. Here's the full surface map, using Dawn file names as the reference:
Product page
sections/main-product.liquid
Title, description, gallery, variant JSON
Media gallery
snippets/product-media-gallery.liquid
Gallery, variant featured media
Media lightbox
snippets/product-media-modal.liquid
Gallery, variant featured media
Variant picker
snippets/product-variant-picker.liquid
Variant JSON blob
Collection / grid cards
snippets/card-product.liquid
Title, first image
Thumbnails
snippets/product-thumbnail.liquid
Title (alt text, data-shopify-title)
Featured product section
sections/featured-product.liquid
Same as product page
Cart page
sections/main-cart-items.liquid
Title, line image
Cart drawer
snippets/cart-drawer.liquid
Title, line image
Add-to-cart notification
sections/cart-notification-product.liquid
Title, image
Predictive search
sections/predictive-search.liquid
Title, image
Your theme will differ. Search for product.title, card_product.title, item.product.title and product.media across the whole theme and work from the results — that's more reliable than working from this table.
Gotchas
These four cost real debugging time on the reference integration. All of them fail silently.
Gotcha 1: id type coercion
Payload ids are JSON strings. media.id in Liquid is an integer. Liquid's == is type-sensitive, so where: 'id', "31234567890" matches nothing at all.
Gotcha 2: an empty array is truthy
if eraya_entry.media is true even for []. The guard only works because the writer omits the key entirely when there's no gallery override. If you build your own payload writer, never emit [].
Gotcha 3: variant_media keys are strings
variant.id is an integer, so index with an appended empty string:
Gotcha 4: paginate can't live inside {% liquid %}
It's a block tag. Pool assembly must sit between your liquid tags, not inside one.
JavaScript API
If you have custom JS — a third-party review widget, a bundle builder, a custom gallery — that needs to know the visitor's assignment, the SDK exposes it:
getProductTestAssignment(productId) — synchronous
Which variation this visitor is in, or null if the product isn't under test or they aren't bucketed yet. Everything it reads is already in memory, so there's no network cost — use it for analytics tagging or to branch on the arm.
getProductTest(productId) — async
The assignment plus whatever the variation overrides for that product.
Async because the override content isn't in the page config — the shop-level metafield carries assignment data only, so this reads Eraya's app proxy at /apps/eraya-proxy/config.json. Results are memoised per product, and concurrent calls for the same product share one request.
Control returns a result with no override fields set. That's a meaningful answer ("you're in control, nothing is changed"), not an absence — and it costs no network call.
getProductVariantMedia(productId, variantId) — async
null does not mean "no image". It means this variation didn't override this specific variant, so the theme falls through to the variant's real featured_media — which the SDK can't see. If you want "what is the gallery showing right now", read media[0] off getProductTest() instead.
Patching a JSON-rendered surface
This is what you use for a quick-view modal or a JSON search dropdown — anywhere the markup came from /products/<handle>.js or /search/suggest.json and therefore carries the real product data.
Call it right after you inject the JSON-rendered markup, before it becomes visible — the same reason the Liquid path exists is the reason a visible swap is worse than none.
Patch every field you patch anywhere. If the modal shows the variation's title but its original image, the shopper sees a combination that exists in neither arm — the exact failure the Liquid-only design avoids. Either override title and gallery on that surface, or leave the surface alone entirely.
Mark the elements you intend to override (data-eraya-title, data-eraya-image above) rather than matching on class names — theme classes change with every update, and a selector that silently stops matching gives you a half-applied variation with no error.
Testing your integration
Force a variation
Set the cart attribute directly and reload:
Get both ids from the test's detail page in Eraya. Clear with null to return to whatever the SDK assigns you.
Check each one
No test running
Every surface renders the real product
Control bucket
Every surface renders the real product
Variant bucket
Title, description and gallery all change together
Add to cart in a variant
Cart line shows the variation's title and image
Switch variants
Price and availability still update (Step 5 works)
Test stopped
Everything reverts with no theme change
That last one matters: stopping a test clears the metafield, so Liquid falls straight back to the real product. Ending an experiment never requires a theme edit.
Verify attribution
Place a test order in a variant bucket and confirm it appears on the test's results page. If traffic is climbing but both arms show zero orders, the cart attribute prefix is wrong — see the warning in the data contract.
Ending an integration
The Liquid is inert when no test is running: no metafield means no eraya_entry, which means the real product renders. You can safely leave it in place between tests — that's the intended steady state.
To remove it entirely, delete the resolver, pool and gallery blocks and revert eraya_title / eraya_description / eraya_media / eraya_variants_json back to their product.* originals.
Last updated