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 }}§ion_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=relatedComplementary Products
Products that go well together:
/recommendations/products?product_id=123&intent=complementaryComplementary 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 -%}Manual Related Products
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 -%}Collection-Based Related Products
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 }}§ion_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:
- Shows 4 recommended products
- Loads asynchronously for performance
- Shows skeleton placeholders while loading
- Hides if no recommendations available
- 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
- Use
recommendationsobject for AI-powered suggestions - Load asynchronously for better performance
intent=complementaryfor “bought together” products- Metafields enable manual curation
- Collection-based fallback for new products
- Recently viewed uses localStorage
- Skeleton loaders improve perceived performance
- 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...