Skip to main content

UI structure

This guide is for developers building custom frontend components (headless) for Shopify stores that use the Discovery OS Shopify app. Follow it so your UI stays compatible with the app’s automatic tracking and analytics (ATS): impressions, clicks, and session-aware search payloads.

Before you start: Enable the app embed and read route/widget ids from pa-discoveryos-config.


1. Widget Identification​

The recommendations response does not include a widget type. After the app embed loads, window.paDiscoveryOsApp.storeData.widgetList lists every widget synced to the store:

[
{
"widget_id": "023a8a1d-e4c7-f111-858f-02ffe2f1bf67",
"widget_name": "TEST - Search Spotlight",
"widget_type": "1"
},
{
"widget_id": "9c3903e0-aed3-f111-858f-02ffe2f1bf67",
"widget_name": "TEST - Product Bundle",
"widget_type": "11"
}
]

Match widget.id from recommendations.route.widgets to widget_id. widget_type is a string.

widget_typeWidget
"1"Recommendation Widget (Products, Banner)
"11"Bundle Widget
const widgetList = window.paDiscoveryOsApp?.storeData?.widgetList ?? [];

const bundleWidget = recommendations.route.widgets.find((widget) => {
const listed = widgetList.find((entry) => entry.widget_id === widget.id);
return listed?.widget_type === '11';
});

If there is multiple widgets of same type, you may pick required widget by widgetId or name.


2. HTML structure and attributes​

Add the classes and data attributes below. Once these attributes are present in the HTML, the app automatically tracks all relevant events without needing additional client-side scripts.

2.1. The wrapper element (container)​

The wrapper must surround the product cards and banner slots.

AttributeTypeSource / value
data-pa-route-idRequiredrecommendations.route.id
data-pa-widget-idRequiredwidget.id
data-pa-search-contextRequired if dependend on search.The search term.

2.2. The product card​

Add these attributes only on PA, sponsored, and recommended products. Standard native products do not need them.

Attribute / classTypeDescription
pa-product-cardclass-name, RequiredClass name required to track PA products
data-pa-ref-idattribute, RequiredShopify product id. Data map: product.refId.
data-pa-product-indexattribute, RequiredPosition index, starting at 1.
data-pa-hmacattributeslot.hmac Required for sponsored / retail media products.
data-pa-tactic-labelattributeslot.label Required for sponsored / retail media products.

2.3. The banner slot​

Add these attributes only on PA banner. Standard native banner do not need them.

Attribute / classTypeDescription
pa-bannerclass-name, RequiredOn every banner the app should track.
data-pa-banner-idattribute, Required.banner.id.
data-pa-banner-indexattribute, Required.Position index, starting at 1.
data-pa-hmacattributeslot.hmac Required for sponsored / retail media banner.
data-pa-tactic-labelattributeslot.label Required for sponsored / retail media banner.

3. Widget UI sample structures​

3.1. Search preview​

<div
data-pa-search-context="{{ search.terms }}"
data-pa-route-id="000-000-000-000" // <searchRouteId>
data-pa-widget-id="aaa-aaa-aaa-aaa" // <searchWidgetId>
>
{% for item in search.results %}
{% if item.object_type == 'product' %}
<div
class="pa-product-card"
data-pa-ref-id="{{ item.id }}"
data-pa-product-index="{{ forloop.index }}"
>
<a href="{{ item.url }}">{{ item.title }}</a>
</div>
{% endif %}
{% endfor %}
</div>

3.2. Search results page (SERP) — Liquid​

<div
data-pa-search-context="{{ search.terms }}"
data-pa-route-id="111-111-111-111" // <serpRouteId>
data-pa-widget-id="bbb-bbb-bbb-bbb" // <serpWidgetId>
>
{% for item in search.results %}
{% if item.object_type == 'product' %}
<div
class="pa-product-card"
data-pa-ref-id="{{ item.id }}"
data-pa-product-index="{{ forloop.index }}"
>
<a href="{{ item.url }}">{{ item.title }}</a>
</div>
{% endif %}
{% endfor %}
</div>

3.3. Collection page — Liquid​

<div
data-pa-route-id="222-222-222-222" // <collectionRouteId>
data-pa-widget-id="bbb-bbb-bbb-bbb" // <collectionWidgetId>
>
{% for product in collection.products %}
<div
class="pa-product-card"
data-pa-ref-id="{{ product.id }}"
data-pa-product-index="{{ forloop.index }}"
>
<a href="{{ product.url }}">{{ product.title }}</a>
</div>
{% endfor %}
</div>

3.4. Recommendation widget​

data-pa-hmac and data-pa-tactic-label are only on sponsored (retail media) products, so they are added only when the slot has an hmac.

<div
data-pa-route-id={recommendations.route.id}
data-pa-widget-id={widget.id}
>
{widget.slots.map((slot, index) => {
const product = slot.products[0];
return (
<div
key={product.refId}
className="pa-product-card"
data-pa-ref-id={product.refId}
data-pa-product-index={index + 1}
{...(slot.hmac && {
'data-pa-hmac': slot.hmac,
'data-pa-tactic-label': slot.label,
})}
>
<a href={product.url}>{product.name}</a>
</div>
)
})}
</div>

3.5. Banner​

The banner sits in the same wrapper. data-pa-hmac and data-pa-tactic-label follow the product-card rules and are required when the slot is sponsored.

<div
data-pa-route-id={recommendations.route.id}
data-pa-widget-id={widget.id}
>
{widget.slots.map((slot, index) => {
const banner = slot.banners[0];
return (
<div
key={banner.id}
className="pa-banner"
data-pa-banner-id={banner.id}
data-pa-banner-index={index + 1}
{...(slot.hmac && {
'data-pa-hmac': slot.hmac,
'data-pa-tactic-label': slot.label,
})}
>
<div>{banner.title}</div>
</div>
);
})}
</div>

3.6. Bundle widget​

A bundle widget is placed on the product detail page. Call the Recommendations API with expandProductDetails=true and refId set to the numeric Shopify id of the product being viewed. expandProductDetails is required so each product includes prices and the variants attribute used for the total and add to cart.

Use the widget whose widget type is "11".

From recommendations.route:

  • currentProduct is the product on the page. It is not inside a slot.
  • Each slot contributes one companion product, slot.products[0].

Render the current product first, then the slot products. Two slots plus the current product is three products. If currentProduct is missing or the widget has no slots, render nothing.

Each product, including the current product, has a checkbox. All start checked. Show how many are checked and a total: the sum of prices[0].price for the checked products, with the currency from prices[0].code. Checking or unchecking a product updates the total.

One button adds only the checked products. Disable it when none are checked. Add them with Shopify POST /cart/add.js:

{ "items": [{ "id": 47036108308636, "quantity": 1 }] }

id is the numeric legacyResourceId of the first variant. Read it from the product attributes entry named variants. That value is a JSON string, and it is only present when expandProductDetails=true.

The current product is index 1 and has no slot, so it does not get data-pa-hmac or data-pa-tactic-label. Slot products get those attributes only when slot.hmac is present.

function variantId(product) {
const variantsAttribute = product.attributes?.find((attribute) => attribute.name === 'variants');
const raw = variantsAttribute?.options?.[0]?.value;
if (!raw) return null;
const variant = JSON.parse(raw);
return Number(variant.legacyResourceId);
}

function BundleWidget({ recommendations, widget }) {
const currentProduct = recommendations.route.currentProduct;

if (!currentProduct || widget.slots.length === 0) return null;

const items = [
{ product: currentProduct, slot: null },
...widget.slots.map((slot) => ({ product: slot.products[0], slot })),
];
const [selectedIds, setSelectedIds] = useState(items.map(({ product }) => product.refId));
const selected = items.filter(({ product }) => selectedIds.includes(product.refId));
const currency = currentProduct.prices?.[0]?.code ?? '';
const total = selected.reduce(
(sum, { product }) => sum + (Number(product.prices?.[0]?.price) || 0),
0,
);

const toggle = (refId) => {
setSelectedIds((current) =>
current.includes(refId) ? current.filter((id) => id !== refId) : [...current, refId],
);
};

const addSelectedToCart = async () => {
const cartItems = selected
.map(({ product }) => variantId(product))
.filter(Boolean)
.map((id) => ({ id, quantity: 1 }));

await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: cartItems }),
});
};

return (
<div data-pa-route-id={recommendations.route.id} data-pa-widget-id={widget.id}>
{items.map(({ product, slot }, index) => (
<label key={product.refId}>
<input
type="checkbox"
checked={selectedIds.includes(product.refId)}
onChange={() => toggle(product.refId)}
/>
<div
className="pa-product-card"
data-pa-ref-id={product.refId}
data-pa-product-index={index + 1}
{...(slot?.hmac && {
'data-pa-hmac': slot.hmac,
'data-pa-tactic-label': slot.label,
})}
>
<a href={product.url}>{product.name}</a>
</div>
</label>
))}
<div>
{selected.length} {selected.length === 1 ? 'item' : 'items'} selected · Total: {currency}{' '}
{total.toFixed(2)}
</div>
<button type="button" onClick={addSelectedToCart} disabled={selected.length === 0}>
Add to cart
</button>
</div>
);
}