Shopify recommendations, tracking and prefetching
02/09/2026 in #shopify #recommendations #tracking #prefetchingThe series
Part 7 of 7 of the series Building a Shopify storefront with @studiometa/ui.
This closing article covers three finishing touches for a Shopify storefront: lazy-loading a product-recommendations section, tracking analytics events, and prefetching links, all with @studiometa/ui on top of @studiometa/js-toolkit. Recommendations lead the way: they are computed per product and returned by their own API, so they are meant to load after the page, not block it, which makes them a natural fit for lazy-loading.
The demo below tracks an add-to-cart click and lazy-loads a recommendations block when it scrolls into view:
Click "Add to cart" and the analytics line updates; the recommendations arrive a moment after the block enters the viewport. Open it in the playground to edit it.
Table of content
Lazy-loading the recommendations section
The Product Recommendations API returns recommendations for a product at GET /recommendations/products. Pass a section_id and it renders one of your theme sections with the recommendations filled in, so the response is ready-to-inject HTML — plain text, not the JSON of the Section Rendering API used in the earlier articles. The recommendations object is empty on the initial page render, which is why this has to be a client-side fetch, and because it is computed per product and sits below the fold, it is a natural candidate for deferring until the section scrolls into view.
That is two behaviours: fetch when visible, then swap the result in. Fetch does the second — it reads the response as text (what this endpoint returns) and swaps elements into the page by matching id, the same swap as in the Section Rendering article, except the URL comes from its src option instead of an href. To drive the first, InViewOnce emits an in-view event the first time the element enters the viewport and never again, and an Action turns that event into a Fetch call:
<div
data-component="Action InViewOnce Fetch"
data-option-src="{{ routes.product_recommendations_url }}?section_id=product-recommendations&product_id={{ product.id }}&limit=4&intent=related"
data-on:in-view="Fetch.fetch()">
{% comment %} Rendered empty on load; Fetch swaps this wrapper by id once it enters the viewport. {% endcomment %}
{% section 'product-recommendations' %}
</div>
{% section %} renders the recommendations section empty on first load, wrapped by Shopify in <div id="shopify-section-product-recommendations">, which doubles as the loading placeholder. When the block scrolls into view, InViewOnce fires, the Action calls Fetch.fetch(), and the response's identically-wrapped section is swapped in by id. Use intent=related for "You may also like" and intent=complementary for "Pairs well with"; the API caps results at 10.
Register every component this article uses once, in your theme's main script (each is covered in the sections below):
import { import registerComponentsregisterComponents } from '@studiometa/js-toolkit';
import {
class Action<T extends BaseProps = BaseProps>Action class.
Action,
class Fetch<T extends BaseProps = BaseProps>Fetch class.
Fetch,
import InViewOnceInViewOnce,
class PrefetchWhenOver<T extends BaseProps = BaseProps>PrefetchWhenOver class.
PrefetchWhenOver,
class PrefetchWhenVisiblePrefetchWhenVisible class.
PrefetchWhenVisible,
import TrackShopifyTrackShopify,
} from '@studiometa/ui';
import registerComponentsregisterComponents(class Action<T extends BaseProps = BaseProps>Action class.
Action, class Fetch<T extends BaseProps = BaseProps>Fetch class.
Fetch, import InViewOnceInViewOnce, class PrefetchWhenVisiblePrefetchWhenVisible class.
PrefetchWhenVisible, class PrefetchWhenOver<T extends BaseProps = BaseProps>PrefetchWhenOver class.
PrefetchWhenOver, import TrackShopifyTrackShopify);
On an <a> or <form>, Fetch triggers itself; here it sits on a <div> and stays idle until the Action calls Fetch.fetch(), so the request is deferred until the section is in view — the same lazy behaviour Dawn hand-rolls with an IntersectionObserver, without the request code. For a loading and error state, Fetch emits bubbling fetch-before, fetch-after and fetch-error events; drive a Transition from an Action on a parent, exactly as the Section Rendering demo does for its #loader.
How the demo simulates it
The playground has no store, so the demo mocks GET /recommendations/products to return a rendered product list wrapped in <div id="shopify-section-product-recommendations"> (so Fetch swaps it by id), and stubs window.Shopify.analytics so the tracking has somewhere to publish:
module window
var window: Window & typeof globalThis
The window property of a Window object points to the window object itself.
window.function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>fetch = async (input: RequestInfo | URLinput, init: RequestInit | undefinedinit) => {
const const url: URLurl = new var URL: new (url: string | URL, base?: string | URL) => URLThe URL interface is used to parse, construct, normalize, and encode URL.
URL class is a global reference for import { URL } from 'node:url'
https://nodejs.org/api/url.html#the-whatwg-url-api
URL(
typeof input: RequestInfo | URLinput === 'string' ? input: stringinput : input: Request | URLinput instanceof var URL: {
new (url: string | URL, base?: string | URL): URL;
prototype: URL;
canParse(url: string | URL, base?: string | URL): boolean;
createObjectURL(obj: Blob | MediaSource): string;
parse(url: string | URL, base?: string | URL): URL | null;
revokeObjectURL(url: string): void;
}
The URL interface is used to parse, construct, normalize, and encode URL.
URL class is a global reference for import { URL } from 'node:url'
https://nodejs.org/api/url.html#the-whatwg-url-api
URL ? input: URLinput.URL.href: stringThe href property of the URL interface is a string containing the whole URL.
href : input: Requestinput.Request.url: stringThe url read-only property of the Request interface contains the URL of the request.
url,
'http://localhost',
);
if (const url: URLurl.URL.pathname: stringThe pathname property of the URL interface represents a location in a hierarchical structure.
pathname.String.endsWith(searchString: string, endPosition?: number): booleanReturns true if the sequence of elements of searchString converted to a String is the
same as the corresponding elements of this object (converted to a String) starting at
endPosition – length(this). Otherwise returns false.
endsWith('/recommendations/products')) {
return new var Response: new (body?: BodyInit | null, init?: ResponseInit) => ResponseThe Response interface of the Fetch API represents the response to a request.
Response(recommendationsMarkup(), { ResponseInit.headers?: HeadersInit | undefinedheaders: { 'content-type': 'text/html' } });
}
return realFetch(input: RequestInfo | URLinput, init: RequestInit | undefinedinit);
};
Swap the mock for your store: delete the window.fetch block and the window.Shopify stub. The src already points at the real recommendations route, and window.Shopify.analytics exists on a live storefront. Use locale-aware URLs ({{ routes.product_recommendations_url }} in Liquid, window.Shopify.routes.root in JavaScript).
Tracking events
TrackShopify publishes analytics events declaratively through window.Shopify.analytics.publish. Add a data-track:<event> attribute with a JSON payload, and the event fires on that DOM event:
<button
data-component="TrackShopify"
data-track:click='{"event": "app:add_to_cart", "product": "canvas-cap"}'>
Add to cart
</button>
Its sibling Track publishes to window.dataLayer for Google Tag Manager or Google Analytics 4 (GA4). TrackContext lets a parent supply payload fields inherited by every tracked element inside it, for stamping a list name or position onto product-card events. The event name is the payload's event key, which you should namespace (here app:add_to_cart) so it does not collide with Shopify's own web-pixel events. You can also track mounted (fires on mount, for impressions) and a viewport-based view event.
Prefetching links
The last touch cuts the delay when a customer follows a product link. PrefetchWhenVisible adds a <link rel="prefetch"> for a link once it scrolls into view, so the destination is warm by the time the customer clicks:
<a href="{{ product.url }}" data-component="PrefetchWhenVisible">{{ product.title }}</a>
Its sibling PrefetchWhenOver prefetches on mouseenter instead. Both only prefetch same-origin URLs, skip the current page, and de-duplicate across the page, so they are safe to add to product links.
One nice consequence: registerComponents adds each component to a global registry backed by a MutationObserver, so elements injected after startup are mounted automatically. The recommendation links Fetch just swapped in get PrefetchWhenVisible and TrackShopify with no extra step — you register once and both server-rendered and dynamically loaded markup are enhanced.
The end of the series
That completes the storefront: AJAX navigation and section rendering, collection filtering, a variant selector, an AJAX cart drawer, predictive search, and now recommendations, tracking and prefetching. Every piece enhances the HTML your theme already renders. The through-line, and the setup, live in the pillar.