Product Page Components Intermediate 12 min read

Related Products and Recommendations Carousel

Build a product recommendations carousel using Shopify Product Recommendations API. Create smooth scrolling and responsive layouts.

Product recommendations help customers discover more products and increase average order value. We’ll build a carousel that fetches recommendations from Shopify’s API.

Theme Integration

This component is typically rendered below the main product info:

sections/product-main.liquid (or sections/product-recommendations.liquid)
└── <div id="product-page-root">
└── ProductPage (React)
└── Recommendations ← You are here
└── ProductCard (multiple)

The recommendations data is fetched client-side from Shopify’s Product Recommendations API. See Product Page Architecture for the mount point setup.

Data Source

Prop/StateSourceOrigin
productIdParent (ProductPage)product.id from Liquid
productsShopify APIGET /recommendations/products.json
isLoadingLocal state-
canScrollLeftLocal state- (derived from scroll position)
canScrollRightLocal state- (derived from scroll position)

Note: Unlike other components, recommendations data is fetched client-side via Shopify’s Product Recommendations API, not from Liquid JSON.

Fetching Recommendations

Shopify provides the Product Recommendations API:

src/utils/recommendations.ts
/**
* Type for products returned by Shopify's Recommendations API.
* Simplified structure for carousel display.
*/
interface RecommendedProduct {
id: number;
title: string;
handle: string;
url: string;
price: number;
compareAtPrice: number | null; // Original price for sale items.
featuredImage: {
url: string;
alt: string;
} | null;
}
/**
* Fetch product recommendations from Shopify's Product Recommendations API.
* @param productId - The ID of the current product.
* @returns Promise resolving to array of recommended products.
*/
export async function getRecommendations(productId: number): Promise<RecommendedProduct[]> {
// Shopify's recommendations endpoint.
// limit parameter controls how many products to return (max 10).
const url = `/recommendations/products.json?product_id=${productId}&limit=8`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch recommendations');
}
const data = await response.json();
return data.products;
}

Recommendations Component

src/components/product/Recommendations/Recommendations.tsx
import { useEffect, useState } from 'react';
import { getRecommendations } from '@/utils/recommendations';
import { ProductCard } from '@/components/collection/ProductCard';
import { Carousel } from './Carousel';
import styles from './Recommendations.module.css';
interface RecommendationsProps {
productId: number; // ID of the current product to get recommendations for.
}
/**
* Recommendations fetches and displays related products.
* Uses Shopify's Product Recommendations API for personalized suggestions.
*/
export function Recommendations({ productId }: RecommendationsProps) {
const [products, setProducts] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Fetch recommendations when productId changes.
useEffect(() => {
getRecommendations(productId)
.then(setProducts)
.catch(console.error)
.finally(() => setIsLoading(false)); // Always clear loading state.
}, [productId]);
// Show loading skeleton while fetching.
if (isLoading) {
return <RecommendationsSkeleton />;
}
// Don't render section if no recommendations.
if (products.length === 0) {
return null;
}
return (
<section className={styles.section}>
<h2 className={styles.title}>You May Also Like</h2>
{/* Display products in a scrollable carousel */}
<Carousel>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</Carousel>
</section>
);
}
/**
* Loading skeleton while recommendations are being fetched.
* Shows placeholder cards to prevent layout shift.
*/
function RecommendationsSkeleton() {
return (
<section className={styles.section}>
<div className={styles.skeletonTitle} />
<div className={styles.skeletonGrid}>
{/* Create 4 placeholder cards */}
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className={styles.skeletonCard} />
))}
</div>
</section>
);
}
src/components/product/Recommendations/Carousel.tsx
import { useRef, useState, useCallback } from 'react';
import styles from './Carousel.module.css';
interface CarouselProps {
children: React.ReactNode[]; // Array of items to display in carousel.
}
/**
* Carousel provides horizontal scrolling with navigation arrows.
* Uses CSS scroll-snap for native-feeling touch scrolling on mobile.
*/
export function Carousel({ children }: CarouselProps) {
const scrollRef = useRef<HTMLDivElement>(null); // Ref to the scrollable container.
const [canScrollLeft, setCanScrollLeft] = useState(false); // Track if left arrow should show.
const [canScrollRight, setCanScrollRight] = useState(true); // Track if right arrow should show.
// Update arrow visibility based on scroll position.
const updateScrollState = useCallback(() => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
// Can scroll left if not at the start.
setCanScrollLeft(scrollLeft > 0);
// Can scroll right if not at the end (with 10px buffer).
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10);
}, []);
// Programmatic scrolling for arrow button clicks.
const scroll = (direction: 'left' | 'right') => {
if (!scrollRef.current) return;
// Scroll by 80% of container width for smooth paging.
const scrollAmount = scrollRef.current.clientWidth * 0.8;
const newPosition = scrollRef.current.scrollLeft + (direction === 'left' ? -scrollAmount : scrollAmount);
scrollRef.current.scrollTo({
left: newPosition,
behavior: 'smooth', // Smooth animation.
});
};
return (
<div className={styles.carousel}>
{/* Left navigation arrow - only show when can scroll left */}
{canScrollLeft && (
<button
type="button"
className={`${styles.arrow} ${styles.left}`}
onClick={() => scroll('left')}
aria-label="Scroll left"
>
<ArrowIcon direction="left" />
</button>
)}
{/* Scrollable track containing slides */}
<div
ref={scrollRef}
className={styles.track}
onScroll={updateScrollState} // Update arrows on scroll.
>
{children.map((child, index) => (
<div key={index} className={styles.slide}>
{child}
</div>
))}
</div>
{/* Right navigation arrow - only show when can scroll right */}
{canScrollRight && (
<button
type="button"
className={`${styles.arrow} ${styles.right}`}
onClick={() => scroll('right')}
aria-label="Scroll right"
>
<ArrowIcon direction="right" />
</button>
)}
</div>
);
}
/**
* Arrow icon component with direction prop.
*/
function ArrowIcon({ direction }: { direction: 'left' | 'right' }) {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d={direction === 'left' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6'} />
</svg>
);
}
src/components/product/Recommendations/Carousel.module.css
/* Container for positioning arrows relative to track. */
.carousel {
position: relative;
}
/* Scrollable track with CSS scroll-snap for smooth scrolling. */
.track {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory; /* Snap to slides. */
scrollbar-width: none; /* Hide scrollbar in Firefox. */
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS. */
}
/* Hide scrollbar in Chrome/Safari. */
.track::-webkit-scrollbar {
display: none;
}
/* Individual slide sizing - 4 per row on desktop. */
.slide {
flex: 0 0 calc(25% - 0.75rem); /* 4 columns minus gap. */
scroll-snap-align: start; /* Snap to slide start. */
}
/* Tablet: 3 slides per view. */
@media (max-width: 1023px) {
.slide {
flex: 0 0 calc(33.333% - 0.667rem);
}
}
/* Mobile: 2 slides per view. */
@media (max-width: 767px) {
.slide {
flex: 0 0 calc(50% - 0.5rem);
}
}
/* Navigation arrow buttons. */
.arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border: none;
border-radius: 50%;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: transform 0.15s ease;
}
.arrow:hover {
transform: translateY(-50%) scale(1.05); /* Slight grow on hover. */
}
/* Position arrows outside the carousel. */
.left {
left: -1.5rem;
}
.right {
right: -1.5rem;
}
/* Hide arrows on mobile - users can swipe instead. */
@media (max-width: 767px) {
.arrow {
display: none;
}
}

Alternative: Grid Layout for Recently Viewed

src/components/product/RecentlyViewed/RecentlyViewed.tsx
import { useEffect, useState } from 'react';
import { ProductCard } from '@/components/collection/ProductCard';
import styles from './RecentlyViewed.module.css';
// localStorage key for persisting viewed products.
const STORAGE_KEY = 'recently-viewed';
// Maximum number of products to remember.
const MAX_ITEMS = 8;
/**
* RecentlyViewed displays products the user has previously viewed.
* Data is stored in localStorage and persists across sessions.
*/
export function RecentlyViewed({ currentProductId }: { currentProductId: number }) {
const [products, setProducts] = useState<any[]>([]);
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
// Exclude current product from recently viewed.
const ids = JSON.parse(stored).filter((id: number) => id !== currentProductId);
// Fetch products by IDs...
// You would need an API endpoint or Storefront API query here.
}
}, [currentProductId]);
// Don't render if no recently viewed products.
if (products.length === 0) return null;
return (
<section className={styles.section}>
<h2 className={styles.title}>Recently Viewed</h2>
{/* Grid layout instead of carousel for recently viewed. */}
<div className={styles.grid}>
{products.slice(0, 4).map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</section>
);
}
/**
* Track a product view by adding its ID to localStorage.
* Call this on product page load to build the history.
*/
export function trackProductView(productId: number) {
const stored = localStorage.getItem(STORAGE_KEY);
const ids: number[] = stored ? JSON.parse(stored) : [];
// Add new ID at start, remove duplicates, limit to MAX_ITEMS.
const updated = [productId, ...ids.filter((id) => id !== productId)].slice(0, MAX_ITEMS);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
}

Key Takeaways

  1. Shopify API: Use /recommendations/products.json endpoint
  2. Smooth scrolling: CSS scroll-snap-type for native feel
  3. Navigation arrows: Show/hide based on scroll position
  4. Responsive slides: Adjust slide count per breakpoint
  5. Loading skeleton: Show placeholder during fetch
  6. Recently viewed: Track with localStorage

Your Product Page components are complete! In the next module, we’ll build Cart and Checkout components.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...