skills/storefront-ui/recently-viewed-products/SKILL.md
Show shoppers the products they recently browsed using browser storage so they can easily pick up where they left off on your store
npx skillsauth add finsilabs/awesome-ecommerce-skills recently-viewed-productsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Track which products a shopper views and display them in a "Recently Viewed" widget on product pages, the cart, and the homepage. Uses browser storage for within-session and cross-session history. Product data is always re-fetched from the server to avoid showing stale prices or out-of-stock items.
| Platform | Recommended Approach | Why |
|----------|---------------------|-----|
| Shopify | Enable the built-in Recently Viewed section in your OS2.0 theme, or install Also Bought • Related products / LimeSpot Personalizer | Dawn and most OS2.0 themes include a native Recently Viewed section; LimeSpot ($15/mo) adds AI-powered personalization including recently viewed across sessions |
| WooCommerce | Use WooCommerce's built-in Recently Viewed Products widget or shortcode, or install YITH WooCommerce Frequently Bought Together | WooCommerce includes a recently viewed widget out of the box; place it via Appearance → Widgets or with the [recent_products] shortcode |
| BigCommerce | Use the Recently Viewed widget in the BigCommerce Page Builder or enable it in the Cornerstone theme settings | Cornerstone and BigCommerce Page Builder both include a native Recently Viewed widget that uses browser cookies automatically |
| Custom / Headless | Implement localStorage-based view tracking with a server-side batch endpoint to fetch fresh product data | Client-side storage means no server state needed; re-fetching data on display ensures prices and stock are current |
Using a built-in section (OS2.0 themes — Dawn, Sense, Craft):
For the homepage:
LimeSpot Personalizer (cross-device, AI-powered):
Built-in Recently Viewed widget:
[woocommerce_recently_viewed_products per_page="4"] directly in a product page template, sidebar, or Gutenberg blockFor Classic Editor / Gutenberg:
[woocommerce_recently_viewed_products per_page="4"]WooCommerce stores recently viewed product IDs in the visitor's session (PHP session / cookie) — no plugin required. The widget reads from this session automatically.
Built-in Recently Viewed widget (Cornerstone theme):
Page Builder approach:
localStorage-based view tracking:
// lib/recentlyViewed.js
const STORAGE_KEY = 'rv_products';
const MAX_ITEMS = 12;
const TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export function recordView(productId) {
const items = getStoredItems();
const filtered = items.filter(i => i.id !== productId); // remove existing entry
const updated = [{ id: productId, viewedAt: Date.now() }, ...filtered].slice(0, MAX_ITEMS);
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); } catch {}
}
export function getRecentlyViewedIds(excludeId = null) {
const now = Date.now();
return getStoredItems()
.filter(i => now - i.viewedAt < TTL_MS && i.id !== excludeId)
.map(i => i.id);
}
function getStoredItems() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); }
catch { return []; }
}
Record view on the product detail page:
// In ProductDetailPage.jsx — read localStorage only in useEffect to avoid SSR mismatch
import { useEffect } from 'react';
import { recordView } from '../lib/recentlyViewed';
export function ProductDetailPage({ product }) {
useEffect(() => { recordView(product.id); }, [product.id]);
return (
<div>
{/* product content */}
<RecentlyViewedWidget excludeId={product.id} maxItems={4} />
</div>
);
}
Widget component — re-fetches fresh product data from the server:
// RecentlyViewedWidget.jsx
export function RecentlyViewedWidget({ excludeId, maxItems = 4 }) {
const [products, setProducts] = useState([]);
useEffect(() => {
const ids = getRecentlyViewedIds(excludeId).slice(0, maxItems);
if (ids.length === 0) return;
fetch('/api/products/by-ids', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
})
.then(r => r.json())
.then(data => {
// Preserve the viewed order (API may return in any order)
const map = new Map(data.products.map(p => [p.id, p]));
setProducts(ids.map(id => map.get(id)).filter(Boolean));
})
.catch(() => {}); // Non-critical — fail silently
}, [excludeId, maxItems]);
if (products.length === 0) return null;
return (
<section aria-label="Recently viewed">
<h2>Recently Viewed</h2>
<div className="recently-viewed-grid">
{products.map(product => (
<article key={product.id}>
<a href={product.url}>
<img src={product.image} alt={product.name} loading="lazy" width="150" height="150" />
<p>{product.name}</p>
<p>${product.price}</p>
</a>
</article>
))}
</div>
</section>
);
}
Batch product endpoint (Node.js):
// api/products/by-ids.js
export async function getProductsByIds(req, res) {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 20)
return res.status(400).json({ error: 'Invalid ids' });
const products = await db.products.findMany({
where: { id: { in: ids }, published: true },
select: { id: true, name: true, price: true, image: true, url: true, inStock: true },
});
res.json({ products });
}
excludeId to prevent showing the product the shopper is already viewinglocalStorage inside useEffect to avoid server/client mismatch| Problem | Solution |
|---------|----------|
| Hydration mismatch in Next.js / SSR | Read localStorage only inside useEffect, never during render; initialize state as empty array |
| localStorage throws in private browsing | Wrap all reads/writes in try/catch; fall back to in-memory array for the current session |
| Widget shows out-of-stock or deleted products | Filter server response to only include published: true products; never trust stored IDs to reflect current catalog state |
| Duplicate product appears at multiple positions | Before prepending, filter out the existing entry for that product ID |
| Widget causes layout shift on load | Reserve the widget's height with a min-height skeleton while data loads, or position it below the fold |
tools
Let shoppers save products to a wishlist, share it with friends, and get notified when saved items come back in stock or drop in price
development
Build a themeable storefront with design tokens and CSS custom properties that supports white-labeling, multi-brand variants, and dark mode
development
Speed up product discovery with instant search suggestions, fuzzy typo matching, and category-aware results powered by Algolia or Elasticsearch
development
Build a mobile-first storefront with thumb-friendly navigation, sticky add-to-cart buttons, and touch-optimized components for high mobile conversion