Product Page Implementation Intermediate 10 min read

Recommendations and Complementary Products

Implement product recommendations, complementary products, and cross-selling sections using Shopify's APIs and manual curation.

Product recommendations help customers discover relevant items and increase average order value. Shopify provides built-in recommendation algorithms plus tools for manual curation.

Shopify Product Recommendations

Shopify’s recommendation API uses machine learning to suggest relevant products:

{# Access recommendations via the recommendations object #}
{% if recommendations.performed %}
{%- for product in recommendations.products -%}
{% render 'product-card', product: product %}
{%- endfor -%}
{% endif %}

Basic Recommendations Section

{# sections/product-recommendations.liquid #}
<section class="product-recommendations">
<div class="container">
<h2 class="product-recommendations__heading">
{{ section.settings.heading | default: 'You may also like' }}
</h2>
<div
class="product-recommendations__grid"
data-product-recommendations
data-url="{{ routes.product_recommendations_url }}?product_id={{ product.id }}&limit={{ section.settings.product_count }}&section_id={{ section.id }}"
>
{# Initial recommendations (server-rendered) #}
{% if recommendations.performed and recommendations.products_count > 0 %}
{%- for product in recommendations.products -%}
<div class="product-recommendations__item">
{% render 'product-card', product: product %}
</div>
{%- endfor -%}
{% endif %}
</div>
</div>
</section>
{% schema %}
{
"name": "Product Recommendations",
"settings": [
{
"type": "text",
"id": "heading",
"label": "Heading",
"default": "You may also like"
},
{
"type": "range",
"id": "product_count",
"label": "Number of products",
"min": 2,
"max": 10,
"step": 1,
"default": 4
}
]
}
{% endschema %}

Loading Recommendations via JavaScript

For better performance, load recommendations asynchronously:

class ProductRecommendations extends HTMLElement {
connectedCallback() {
const url = this.dataset.url;
if (!url) return;
this.loadRecommendations(url);
}
async loadRecommendations(url) {
try {
const response = await fetch(url);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const recommendations = doc.querySelector('[data-product-recommendations]');
if (recommendations && recommendations.innerHTML.trim()) {
this.innerHTML = recommendations.innerHTML;
} else {
// No recommendations, hide the section
this.closest('.product-recommendations').hidden = true;
}
} catch (error) {
console.error('Failed to load recommendations:', error);
this.closest('.product-recommendations').hidden = true;
}
}
}
customElements.define('product-recommendations', ProductRecommendations);
<product-recommendations
class="product-recommendations__grid"
data-url="{{ routes.product_recommendations_url }}?product_id={{ product.id }}&limit=4"
>
{# Loading placeholder #}
<div class="product-recommendations__loading">
{% render 'loading-spinner' %}
</div>
</product-recommendations>

Recommendation Types

Intent-Based (Default)

Products customers are likely to buy:

/recommendations/products?product_id=123&intent=related

Complementary Products

Products that go well together:

/recommendations/products?product_id=123&intent=complementary

Complementary Products Section

{# sections/complementary-products.liquid #}
{%- assign complementary_products = product.metafields.shopify--discovery--product_recommendation.complementary_products.value -%}
{%- if complementary_products.count > 0 -%}
<section class="complementary-products">
<div class="container">
<h2 class="complementary-products__heading">
{{ section.settings.heading | default: 'Frequently bought together' }}
</h2>
<div class="complementary-products__bundle">
{# Current product #}
<div class="complementary-products__item complementary-products__item--current">
{% render 'product-card-mini', product: product %}
</div>
<span class="complementary-products__plus">+</span>
{# Complementary products #}
{%- for comp_product in complementary_products limit: 2 -%}
<div class="complementary-products__item">
{% render 'product-card-mini', product: comp_product %}
</div>
{%- unless forloop.last -%}
<span class="complementary-products__plus">+</span>
{%- endunless -%}
{%- endfor -%}
</div>
{# Bundle price and add all button #}
<div class="complementary-products__total">
{%- assign bundle_price = product.price -%}
{%- for comp_product in complementary_products limit: 2 -%}
{%- assign bundle_price = bundle_price | plus: comp_product.price -%}
{%- endfor -%}
<p class="complementary-products__price">
Total: {{ bundle_price | money }}
</p>
<button
type="button"
class="complementary-products__add-all button"
data-add-bundle
>
Add All to Cart
</button>
</div>
</div>
</section>
{%- endif -%}

Use metafields for curated recommendations:

{# Access related products metafield (list of product references) #}
{%- assign related = product.metafields.custom.related_products.value -%}
{%- if related.size > 0 -%}
<section class="related-products">
<h2>Related Products</h2>
<div class="product-grid">
{%- for related_product in related limit: 4 -%}
<div class="product-grid__item">
{% render 'product-card', product: related_product %}
</div>
{%- endfor -%}
</div>
</section>
{%- endif -%}

Show products from the same collection:

{%- assign current_collection = product.collections.first -%}
{%- if current_collection -%}
<section class="related-products">
<h2>More from {{ current_collection.title }}</h2>
<div class="product-grid">
{%- for related_product in current_collection.products limit: 5 -%}
{%- unless related_product.id == product.id -%}
<div class="product-grid__item">
{% render 'product-card', product: related_product %}
</div>
{%- endunless -%}
{%- endfor -%}
</div>
<a href="{{ current_collection.url }}" class="related-products__view-all">
View All {{ current_collection.title }}
</a>
</section>
{%- endif -%}

Recently Viewed Products

Track and display recently viewed products:

{# sections/recently-viewed.liquid #}
<section class="recently-viewed" data-recently-viewed hidden>
<div class="container">
<h2>Recently Viewed</h2>
<div class="product-grid" data-recently-viewed-products>
{# Products loaded via JavaScript #}
</div>
</div>
</section>
class RecentlyViewed extends HTMLElement {
constructor() {
super();
this.storageKey = 'recently_viewed';
this.maxProducts = 8;
}
connectedCallback() {
this.recordProduct();
this.displayProducts();
}
recordProduct() {
const productId = this.dataset.productId;
if (!productId) return;
let viewed = this.getViewedProducts();
// Remove if already exists
viewed = viewed.filter((id) => id !== productId);
// Add to front
viewed.unshift(productId);
// Limit array size
viewed = viewed.slice(0, this.maxProducts);
localStorage.setItem(this.storageKey, JSON.stringify(viewed));
}
getViewedProducts() {
try {
return JSON.parse(localStorage.getItem(this.storageKey)) || [];
} catch {
return [];
}
}
async displayProducts() {
const viewed = this.getViewedProducts();
const currentProductId = this.dataset.productId;
// Filter out current product
const productsToShow = viewed.filter((id) => id !== currentProductId).slice(0, 4);
if (productsToShow.length === 0) return;
const container = this.querySelector('[data-recently-viewed-products]');
// Fetch each product's HTML
const productCards = await Promise.all(productsToShow.map((id) => this.fetchProductCard(id)));
container.innerHTML = productCards.filter(Boolean).join('');
this.hidden = false;
}
async fetchProductCard(productId) {
try {
const response = await fetch(`/products/${productId}?view=card`);
if (!response.ok) return null;
return await response.text();
} catch {
return null;
}
}
}
customElements.define('recently-viewed', RecentlyViewed);

Complete Recommendations Section

{# sections/product-recommendations.liquid #}
{%- liquid
assign products_to_show = section.settings.products_per_row | times: section.settings.rows
-%}
<product-recommendations
class="product-recommendations"
data-url="{{ routes.product_recommendations_url }}?product_id={{ product.id }}&limit={{ products_to_show }}&section_id={{ section.id }}"
>
<div class="container">
{%- if section.settings.heading != blank -%}
<h2 class="product-recommendations__heading h3">
{{ section.settings.heading }}
</h2>
{%- endif -%}
<div
class="product-recommendations__content"
data-product-recommendations
>
{# Server-rendered recommendations #}
{%- if recommendations.performed and recommendations.products_count > 0 -%}
<ul
class="product-grid"
style="--columns: {{ section.settings.products_per_row }};"
>
{%- for product in recommendations.products -%}
<li class="product-grid__item">
{%- render 'product-card',
product: product,
show_vendor: section.settings.show_vendor,
show_rating: section.settings.show_rating
-%}
</li>
{%- endfor -%}
</ul>
{%- else -%}
<div class="product-recommendations__placeholder">
{# Placeholder shown during async load #}
<div class="product-recommendations__skeleton">
{%- for i in (1..products_to_show) -%}
<div class="skeleton-card"></div>
{%- endfor -%}
</div>
</div>
{%- endif -%}
</div>
</div>
</product-recommendations>
{% schema %}
{
"name": "Product Recommendations",
"settings": [
{
"type": "text",
"id": "heading",
"label": "Heading",
"default": "You may also like"
},
{
"type": "range",
"id": "products_per_row",
"label": "Products per row",
"min": 2,
"max": 5,
"step": 1,
"default": 4
},
{
"type": "range",
"id": "rows",
"label": "Rows",
"min": 1,
"max": 3,
"step": 1,
"default": 1
},
{
"type": "checkbox",
"id": "show_vendor",
"label": "Show vendor",
"default": false
},
{
"type": "checkbox",
"id": "show_rating",
"label": "Show rating",
"default": false
}
],
"presets": [
{
"name": "Product Recommendations"
}
]
}
{% endschema %}

Recommendations Styles

.product-recommendations {
padding: var(--spacing-2xl) 0;
}
.product-recommendations__heading {
margin-bottom: var(--spacing-lg);
}
.product-recommendations__placeholder {
display: grid;
grid-template-columns: repeat(var(--columns, 4), 1fr);
gap: var(--spacing-lg);
}
.skeleton-card {
aspect-ratio: 3/4;
background: linear-gradient(
90deg,
var(--color-background-secondary) 25%,
var(--color-border) 50%,
var(--color-background-secondary) 75%
);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: var(--border-radius);
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Complementary products bundle */
.complementary-products__bundle {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: var(--spacing-md);
}
.complementary-products__plus {
font-size: 1.5rem;
color: var(--color-text-light);
}
.complementary-products__total {
text-align: center;
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--color-border);
}
.complementary-products__price {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: var(--spacing-sm);
}

Add All to Cart

Handle adding multiple products:

document.querySelector('[data-add-bundle]')?.addEventListener('click', async () => {
const button = document.querySelector('[data-add-bundle]');
const items = document.querySelectorAll('[data-bundle-variant]');
button.disabled = true;
button.textContent = 'Adding...';
const itemsToAdd = Array.from(items).map((item) => ({
id: item.dataset.bundleVariant,
quantity: 1,
}));
try {
await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: itemsToAdd }),
});
document.dispatchEvent(new CustomEvent('cart:updated'));
button.textContent = 'Added!';
} catch (error) {
button.textContent = 'Try Again';
} finally {
button.disabled = false;
}
});

Practice Exercise

Build a recommendations section that:

  1. Shows 4 recommended products
  2. Loads asynchronously for performance
  3. Shows skeleton placeholders while loading
  4. Hides if no recommendations available
  5. Includes a “More from this collection” fallback

Test with:

  • Products with strong recommendation data
  • New products with limited data
  • Products in multiple collections

Key Takeaways

  1. Use recommendations object for AI-powered suggestions
  2. Load asynchronously for better performance
  3. intent=complementary for “bought together” products
  4. Metafields enable manual curation
  5. Collection-based fallback for new products
  6. Recently viewed uses localStorage
  7. Skeleton loaders improve perceived performance
  8. Handle empty states gracefully

Module Complete!

Congratulations on completing Module 10! You’ve learned to build complete product pages with:

  • Template architecture and schemas
  • Media galleries with zoom and video
  • Variant selection with availability
  • Add-to-cart forms with AJAX
  • Quantity controls and line-item properties
  • Dynamic content with metafields and tabs
  • Product recommendations

Next up: Module 11: Cart Page and Cart Drawer for managing the shopping cart experience.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...