← Back to all work
SCR289 Optimizely Web Large UK home improvement retailer

"Shop Similar" follow-me carousel

A two-variant test injecting a sticky similar-products carousel onto PDPs for Google Shopping landers, with breadcrumb-derived API fetching, sessionStorage caching, and a "follow-me" mechanic that persists the carousel as users navigate within the same category.

Shop Similar Products carousel rendered above the PDP showing four similar drill products

The problem

Google Shopping landers arriving on PDPs for the top-50 power-tool SKUs were exhibiting a known pattern: when the specific product wasn't quite right, users were exiting and re-entering the site through Google Shopping multiple times rather than browsing alternatives on-site. The hypothesis was that giving these users immediate access to similar products from the same category — without forcing them through the existing PLP flow — would reduce exit rate and lift add-to-bag rate.

The hypothesis

We believe that adding a similar-products carousel to the PDP for Google Shopping landers will result in a decrease in exit rate from these users and an increase in add-to-basket rate. This is due to users having alternative products to browse, removing the need to exit and re-enter through Google Shopping.

The solution

Variation 1 — Carousel, no brand filter. A "Shop Similar Products" carousel renders above the PDP for users landing from Google Shopping on a top-50 SKU. Products come from the same category as the viewed product (derived from the last breadcrumb), with a 25% lower price threshold and +£100 upper price ceiling to protect AOV.

Variation 2 — Carousel with brand filter. Same as V1, but adds a brand filter on the underlying PLP fetch — testing whether brand-loyal users convert better when shown only same-brand alternatives.

Both variants implement a "follow-me" behaviour: once a user clicks a product in the carousel, the carousel persists across subsequent PDPs in the same category, allowing continuous browsing of suggestions without re-fetching.

Side-by-side wireframes comparing Control, Variation 1, and Variation 2 carousels on PDP
Control vs V1 (no brand filter) vs V2 (brand-filtered)

Implementation

Built as an Optimizely Web IIFE on a Next.js SPA. The technically interesting parts were the data-fetching strategy and the cache-driven follow-me mechanic — both designed to render fast, avoid redundant network calls, and stay accurate as the user navigates.

// Folder structure

SCR289/
  src/
    lib/
      components/
        product.js
        sliderWrapper.js
      data/
        data.js               // top-50 SKU list
      helpers/
        initSwiper.js
        swiper.js
        utils.js
    experiment.js
    _swiper.scss
    _variables.scss
    experiment.scss
    triggers.js

Breadcrumb-derived API fetch

Rather than building a separate similar-products endpoint, the carousel scrapes the host's existing PLP. The last breadcrumb link points to the product's narrowest category — that URL becomes the data source, with price bounds derived from the current PDP's price (±25% lower, +£100 upper, capped at 100 results).

V2 appends a brand query parameter sourced from the PDP's brand logo alt attribute.

const init = () => {
  const { basicVatPriceDisplay, prodPrice, prodPriceIncVat } = window.utag.data;

  const productPrice = Number(
    basicVatPriceDisplay === 'INC-VAT' ? prodPriceIncVat[0] : prodPrice[0]
  );

  // 25% lower price threshold protects AOV/RPS
  const priceQueryString = {
    price_from: productPrice * 0.75,
    price_to: productPrice + 100,
  };

  // Last breadcrumb = product's narrowest category PLP
  const lastBreadcrumb = document.querySelector(
    '[data-qaid="breadcrumb-qa"] > li:last-child a'
  );
  const plpUrl = new URL(lastBreadcrumb.href);
  plpUrl.searchParams.set('price_from', priceQueryString.price_from);
  plpUrl.searchParams.set('price_to', priceQueryString.price_to);
  plpUrl.searchParams.set('page_size', 100);

  // V2: brand filter sourced from PDP brand logo
  if (VARIATION === '2') {
    const brandLogo = document.querySelector('[data-qaid="pdp-brand-logo"]');
    const brand = brandLogo?.alt.trim().replace(/\s+/g, '_').toLowerCase();
    if (brand) plpUrl.searchParams.set('brand', brand);
  }
};

sessionStorage cache + follow-me mechanic

First PDP in a category: fetch SKUs from the price-bound PLP, hydrate full product data, render the carousel, persist the result to sessionStorage. Subsequent PDPs in the same category: the cache hit renders instantly with no network call. If the user navigates to a different category, the cache invalidates against the new PDP's category ID and the carousel removes itself.

const cacheKey = `${ID}__data-${VARIATION}`;
const cached = sessionStorage.getItem(cacheKey);

if (cached) {
  const stored = JSON.parse(cached);
  const currentCategory = window.utag.data.prodCategoryId[0];

  // Cache invalidates if user has moved to a different category
  const inSameCategory = stored
    .map((item) => item.lastBreadcrumbLink)
    .some((link) => link.includes(currentCategory));

  if (!inSameCategory) {
    document.querySelectorAll(`.${ID}__sliderWrapper`).forEach((el) => el.remove());
    document.documentElement.classList.remove(ID, `${ID}-${VARIATION}`);
    return;
  }

  sliderRender(targetPoint, stored);
  initSwiper(`.${ID}__sliderBox`, VARIATION, stored.length);
  return;
}

// Cache miss — fetch, render, persist
fetchSkus(plpUrl)
  .then((skus) => getProductsData(skus))
  .then((products) => {
    const filtered = products.filter(Boolean);
    if (!filtered.length) return;

    sliderRender(targetPoint, filtered);
    initSwiper(`.${ID}__sliderBox`, VARIATION, filtered.length);
    sessionStorage.setItem(cacheKey, JSON.stringify(filtered.slice(0, 20)));
  });

Audience gate — top-50 SKU + Google Shopper

The carousel only renders if (a) the current URL matches one of the top-50 power-tool SKUs maintained in data.js, and (b) traffic is identifiable as a Google Shopper, OR the cache is already populated (so the follow-me carousel persists for users who entered via Shopping). Anyone else gets the standard PDP and an event fires to confirm suppression.

pollerLite([
  () => typeof window.tealiumDataLayer === 'object',
  () => window.utag !== undefined,
  () => window.__NEXT_DATA__ !== undefined
], () => {
  const isToplistProduct = products.find((sku) =>
    window.location.href.toLowerCase().includes(sku.toLowerCase())
  );
  const cached = sessionStorage.getItem(`${ID}__data-${VARIATION}`);

  if ((isToplistProduct && isGoogleShopper()) || cached) {
    setTimeout(init, DOM_RENDER_DELAY);
  } else {
    trackEvent('User not shown "shop similar" carousel due to onward navigation');
  }
});

Click-source attribution

To distinguish carousel-driven PDP visits from organic ones in analytics, a sessionStorage flag sets when a carousel product is clicked, then fires a dedicated event on the next PDP load. The flag self-clears on any non-carousel product interaction.

// On carousel click — flag the next PDP
if (target.closest(`.${ID}__productLink`)) {
  trackEvent('User clicks a product from shop similar carousel');
  sessionStorage.setItem('clickedFromSCR289', 'true');
}

// On next PDP load — fire attribution event
if (isProductPage && sessionStorage.getItem('clickedFromSCR289')) {
  trackEvent('User lands on PDP from shop similar carousel');
}

// User clicked something else — clear the flag
if (target.closest('[data-qaid="pdp-best-seller-container"]')) {
  sessionStorage.removeItem('clickedFromSCR289');
}

Acceptance criteria

  • Test runs on all devices
  • Targets top-50 power-tool Google Shopping SKUs by click volume
  • Two variations — V1 (no brand filter), V2 (brand filter applied)
  • 25% lower price threshold to protect AOV/RPS
  • Carousel persists ("follows") across PDPs in the same category
  • Carousel removes itself when user navigates to a different category
  • Conditions Met fires only when both audience gates are satisfied
  • Click-source attribution events distinguish carousel-driven traffic in analytics

Success metrics

Primary metric: add-to-bag rate. Secondary metrics: PDP exit rate, PDP bounce rate, conversion rate, revenue per session, number of PDPs viewed, average order value, session duration.

Post-test analysis

Results pending. This section will be updated with anonymised outcome highlights once the analysis is published and approved for share.

← Back to all work