
Recently Viewed Products
- 58 installs
- 41 repo stars
- Updated March 13, 2026
- finsilabs/awesome-ecommerce-skills
Show shoppers the products they recently browsed using browser storage so they can pick up where they left off.
About
Tracks and displays recently viewed products from browser storage to help returning shoppers resume browsing. A developer uses it to add a lightweight re-engagement widget without server state.
- Uses browser storage, no server-side session needed
- Helps shoppers resume where they left off
Recently Viewed Products by the numbers
- 58 all-time installs (skills.sh)
- Ranked #1,228 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/finsilabs/awesome-ecommerce-skills --skill recently-viewed-productsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 41 |
| Last updated | March 13, 2026 |
| Repository | finsilabs/awesome-ecommerce-skills ↗ |
What it does
Show shoppers the products they recently browsed using browser storage so they can pick up where they left off.
Files
Recently Viewed Products
Overview
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.
When to Use This Skill
- When implementing a "Continue where you left off" experience for returning visitors
- When adding a "Recently Viewed" widget to product detail pages or the cart drawer
- When integrating with a personalization engine that requires client-side behavioral data
- When building a headless storefront and need lightweight browsing history without a backend dependency
Core Instructions
Step 1: Determine the merchant's platform and choose the right approach
| 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 |
Step 2: Enable Recently Viewed on your platform
---
Shopify
Using a built-in section (OS2.0 themes — Dawn, Sense, Craft): 1. Go to Online Store → Themes → Customize 2. Navigate to a product page template 3. Click Add section and search for "Recently Viewed" — it appears as Recently viewed products in the section list 4. Configure it:
- Set Maximum products to show (4–8 recommended)
- Set the section heading text ("Recently Viewed", "Your Browsing History", etc.)
- Set product card style: show price, show rating, etc.
5. The section uses JavaScript and browser storage (localStorage) automatically — no additional configuration needed
For the homepage: 1. Go to your homepage template 2. Add a Recently viewed products section in the same way 3. This section will only render content for returning visitors who have previously browsed products
LimeSpot Personalizer (cross-device, AI-powered): 1. Install from the Shopify App Store 2. LimeSpot tracks views server-side (not just in the browser) so recently viewed persists across devices and browsers 3. Configure the Recently Viewed widget placement in the LimeSpot dashboard — it can appear on any page via app blocks
---
WooCommerce
Built-in Recently Viewed widget: 1. Go to Appearance → Widgets 2. Find the WooCommerce Recently Viewed Products widget 3. Drag it to your Shop Sidebar or Footer widget area 4. Configure:
- Number of products to show: 4–6 recommended
- Show title: Yes/No
5. Alternatively, place the shortcode [woocommerce_recently_viewed_products per_page="4"] directly in a product page template, sidebar, or Gutenberg block
For Classic Editor / Gutenberg:
- In Gutenberg, add a Shortcode block and paste
[woocommerce_recently_viewed_products per_page="4"] - For Elementor: use the Shortcode widget to embed the same code
WooCommerce stores recently viewed product IDs in the visitor's session (PHP session / cookie) — no plugin required. The widget reads from this session automatically.
---
BigCommerce
Built-in Recently Viewed widget (Cornerstone theme): 1. Go to Storefront → My Themes → Customize 2. Navigate to a product page template 3. In the sidebar, find the Recently Viewed section and enable it (or adjust its position in the page layout) 4. Set the number of products to display 5. BigCommerce uses browser cookies to track recently viewed products — no app needed
Page Builder approach: 1. Go to Storefront → Page Builder 2. Open any page where you want the widget 3. Drag the Recently Viewed Products widget from the widget panel onto the page 4. Configure display settings in the widget panel on the right
---
Custom / Headless
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 });
}Best Practices
- Store only product IDs, not full product objects — prices and availability change; always re-fetch from the server when rendering the widget
- Exclude the current product — pass
excludeIdto prevent showing the product the shopper is already viewing - Fail silently — the recently viewed widget is non-critical; catch all errors and render nothing rather than breaking the page
- Hydrate after mount — in SSR frameworks, read from
localStorageinsideuseEffectto avoid server/client mismatch - Respect privacy consent — only write to storage after the user has accepted analytics cookies if your cookie policy requires it
- Consider TTL expiry — purge entries older than 30 days on read to avoid showing discontinued products
Common Pitfalls
| 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 |
Related Skills
- @wishlist-save-for-later
- @product-page-design
- @product-comparison
- @storefront-theming
{
"context": "Tests whether the agent correctly implements sessionStorage-based tracking for GDPR compliance, a cookie fallback with correct attributes, and hydration-safe integration of recordView in a Next.js-style component.",
"type": "weighted_checklist",
"checklist": [
{
"name": "sessionStorage in session module",
"max_score": 12,
"description": "lib/recentlyViewedSession.js uses sessionStorage (not localStorage) for all read and write operations"
},
{
"name": "Session module silent failure",
"max_score": 8,
"description": "lib/recentlyViewedSession.js wraps sessionStorage calls in try/catch and does not throw on error"
},
{
"name": "Cookie name correct",
"max_score": 10,
"description": "lib/recentlyViewedCookie.js uses the cookie name 'rv_products' (exact string) when reading and writing the cookie"
},
{
"name": "Cookie SameSite=Lax attribute",
"max_score": 8,
"description": "The Set-Cookie string returned by buildRecentlyViewedCookie includes 'SameSite=Lax'"
},
{
"name": "Cookie Secure attribute",
"max_score": 8,
"description": "The Set-Cookie string returned by buildRecentlyViewedCookie includes 'Secure'"
},
{
"name": "Cookie 30-day Max-Age",
"max_score": 8,
"description": "The Set-Cookie string returned by buildRecentlyViewedCookie sets Max-Age to 2592000 (30 * 24 * 60 * 60 seconds)"
},
{
"name": "Cookie deduplication",
"max_score": 8,
"description": "buildRecentlyViewedCookie removes any existing occurrence of newProductId from currentIds before prepending it"
},
{
"name": "Cookie ID cap",
"max_score": 8,
"description": "buildRecentlyViewedCookie slices the updated array to a maximum of 12 entries"
},
{
"name": "useEffect for recordView",
"max_score": 12,
"description": "ProductDetailPage calls recordView inside a useEffect hook, NOT during the render function body or at the component's top level"
},
{
"name": "README distinguishes modes",
"max_score": 8,
"description": "README.md explains that the session module (sessionStorage) is used for pre-consent/GDPR environments and the cookie module is for server-side/incognito fallback"
},
{
"name": "Cookie Path attribute",
"max_score": 10,
"description": "The Set-Cookie string returned by buildRecentlyViewedCookie includes 'Path=/'"
}
]
}
Privacy-Compliant Recently Viewed Tracking for a European Storefront
Problem/Feature Description
A European home goods retailer is rebuilding their storefront using Next.js. Their legal team has flagged that cross-session product tracking requires explicit opt-in under their cookie policy, so the default implementation must not persist browsing history beyond the current session. However, once a user grants consent, the site should switch to persistent cross-session tracking.
The team also discovered that some visitors browse in private/incognito mode where localStorage is unavailable entirely. In those cases, the server needs a way to read and write recently viewed history using cookies instead, so the experience degrades gracefully rather than silently dropping history. The cookie implementation will run in server-side API routes.
The engineering lead wants a clear, self-contained implementation showing both the session-only mode (no consent) and the cookie-based server fallback, along with the product detail page component that correctly integrates the tracking call without causing a hydration error in Next.js.
Output Specification
Produce the following files:
1. lib/recentlyViewedSession.js — A variant of the storage utility that uses sessionStorage instead of localStorage (same API: recordView, getRecentlyViewedIds, clearHistory). This is the default mode used before consent is granted.
2. lib/recentlyViewedCookie.js — A server-side utility with two exported functions:
getRecentlyViewedFromCookie(cookieHeader)— parses the cookie header and returns an array of product IDsbuildRecentlyViewedCookie(currentIds, newProductId)— returns a complete Set-Cookie header string for updating the recently viewed cookie
3. components/ProductDetailPage.jsx — A React component that accepts a product prop and renders the product content plus the recently viewed widget. It must record the product view without causing a hydration mismatch.
Write a short README.md that explains when to use each module (session vs. cookie fallback) and what cookie attributes are set.
{
"context": "Tests whether the agent implements the core recently-viewed storage utility with the correct storage key, item cap, TTL constant, deduplication logic, IDs-only storage model, and silent error handling.",
"type": "weighted_checklist",
"checklist": [
{
"name": "Storage key constant",
"max_score": 8,
"description": "The module defines a storage key constant with the value 'rv_products' used for localStorage access"
},
{
"name": "Max items cap",
"max_score": 8,
"description": "The module defines a maximum items constant set to 12 (not a different number)"
},
{
"name": "TTL constant value",
"max_score": 8,
"description": "The module defines a TTL constant equal to 30 days in milliseconds (30 * 24 * 60 * 60 * 1000)"
},
{
"name": "IDs-only storage",
"max_score": 10,
"description": "The module stores only product IDs and timestamps — it does NOT store full product objects (no name, price, image, or URL fields in the stored data structure)"
},
{
"name": "Deduplication before prepend",
"max_score": 10,
"description": "recordView filters out any existing entry for the product ID before prepending the new entry (preventing duplicates in the list)"
},
{
"name": "Prepend newest first",
"max_score": 8,
"description": "recordView inserts the newly viewed product at position 0 (most recent first), not appended at the end"
},
{
"name": "Slice to max items",
"max_score": 8,
"description": "recordView slices the updated array to MAX_ITEMS after prepending, so stored history never exceeds 12 entries"
},
{
"name": "TTL filtering on read",
"max_score": 10,
"description": "getRecentlyViewedIds filters out entries whose viewedAt timestamp is older than the TTL (i.e., older than 30 days from now)"
},
{
"name": "excludeId filtering",
"max_score": 8,
"description": "getRecentlyViewedIds accepts an excludeId parameter and omits that ID from the returned array"
},
{
"name": "Silent failure on read",
"max_score": 8,
"description": "The storage read function wraps JSON.parse / localStorage.getItem in a try/catch and returns an empty array on error (does not throw)"
},
{
"name": "Silent failure on write",
"max_score": 7,
"description": "The storage write function wraps localStorage.setItem in a try/catch and does not re-throw quota or access errors"
},
{
"name": "clearHistory removes key",
"max_score": 7,
"description": "clearHistory calls localStorage.removeItem with the correct storage key to wipe history"
}
]
}
Browsing History Utility for Product Catalog
Problem/Feature Description
An outdoor gear retailer is launching a headless storefront rebuild and the team needs a standalone JavaScript utility module for tracking which products a shopper has browsed. The utility will be shared across multiple page types — product detail pages, the homepage, and search results — so it must be a clean, importable module with a well-defined API.
The module should store browsing history in the browser so returning visitors can pick up where they left off. It needs to handle the case where a shopper views the same product multiple times (only the most recent visit should count), and history should automatically age out to avoid showing discontinued or long-forgotten products. The team cares about keeping the stored data lean — product details change frequently, so the module must not cache anything that could go stale.
Output Specification
Write a self-contained JavaScript module at lib/recentlyViewed.js that exports the following functions:
recordView(productId)— records that the given product was viewedgetRecentlyViewedIds(excludeId)— returns an ordered array of recently viewed product IDs, excluding the given IDclearHistory()— removes all stored history
Also write a short lib/recentlyViewed.test.js file (plain JS, no test framework required — just runnable with Node.js using assert) that demonstrates the deduplication and TTL filtering behavior.
{
"context": "Tests whether the agent builds the RecentlyViewedWidget with correct order-preservation, silent failure handling, excludeId support, batch API validation, published-only filtering, and cart drawer conditional display logic.",
"type": "weighted_checklist",
"checklist": [
{
"name": "Fetch by POST to batch endpoint",
"max_score": 10,
"description": "RecentlyViewedWidget fetches product data via a POST request (not GET) to the batch products endpoint"
},
{
"name": "Preserve viewed order",
"max_score": 12,
"description": "After receiving the API response, the widget reconstructs product order to match the stored view order (e.g., uses a Map keyed by ID and maps over the original ids array), rather than rendering in the arbitrary order returned by the API"
},
{
"name": "Widget returns null when empty",
"max_score": 8,
"description": "RecentlyViewedWidget returns null (renders nothing) when there are no products to show"
},
{
"name": "Widget catches fetch errors silently",
"max_score": 8,
"description": "The fetch call in RecentlyViewedWidget has a .catch handler or try/catch that suppresses errors without crashing or showing an error UI"
},
{
"name": "excludeId prop used",
"max_score": 8,
"description": "RecentlyViewedWidget accepts and passes an excludeId prop/parameter to getRecentlyViewedIds so the currently viewed product is excluded"
},
{
"name": "API array validation",
"max_score": 9,
"description": "The by-ids handler rejects requests where ids is not an array or is empty, returning a 400 status"
},
{
"name": "API max length validation",
"max_score": 8,
"description": "The by-ids handler rejects requests where ids.length exceeds 20, returning a 400 status"
},
{
"name": "Published-only filter",
"max_score": 10,
"description": "The by-ids handler queries only published products (e.g., published: true in the where clause), not all products matching the IDs"
},
{
"name": "Skeleton or below-fold placement",
"max_score": 8,
"description": "RecentlyViewedWidget includes a min-height or placeholder (skeleton) element while products are loading, OR the component is positioned/styled to be below the fold — to prevent layout shift"
},
{
"name": "Cart drawer conditional display",
"max_score": 10,
"description": "CartDrawer shows the recently viewed widget only when cartItems.length is less than 3 (not always, and not based on a different threshold)"
},
{
"name": "aria-label on widget section",
"max_score": 9,
"description": "The widget's root element uses an aria-label attribute with a value indicating 'recently viewed' content"
}
]
}
Recently Viewed Widget and Product Lookup Endpoint
Problem/Feature Description
A fashion retailer's engineering team is building a "You recently viewed" feature to reduce bounce rates on product detail pages. They need two pieces: a React component that renders a row of recently viewed product cards, and a server-side API endpoint that the component calls to retrieve up-to-date product information.
The widget will appear on every product detail page, so it must not interfere with the rest of the page — if the network call fails or no history exists, the page should render exactly as if the widget was never there. The team is also worried about showing items that have since gone out of stock or been removed from the catalog, so the API must guarantee only live products are returned. The widget should also appear in the cart drawer as a subtle upsell, but only when the cart is relatively empty.
Output Specification
Produce the following files:
1. components/RecentlyViewedWidget.jsx — A React component that accepts excludeId and maxItems props. It should read stored history, fetch fresh product data, and render a grid of product cards. Assume a helper getRecentlyViewedIds(excludeId) is importable from ../lib/recentlyViewed.
2. api/products/by-ids.js — A server-side handler function getProductsByIds(req, res) for a POST endpoint that receives an ids array in the request body and returns matching products. Assume a db.products.findMany(...) ORM call is available.
3. components/CartDrawer.jsx — A React component that accepts a cartItems prop and renders the cart contents, conditionally showing the recently viewed widget.
You do not need to implement lib/recentlyViewed.js — assume it exists and exports getRecentlyViewedIds. Do not wire up routing or a full server; just produce the component and handler files.
{
"name": "finsi/recently-viewed-products",
"version": "0.1.0",
"summary": "Track and display browsing history with sessionStorage/cookie strategies",
"skills": {
"recently-viewed-products": {
"path": "SKILL.md"
}
}
}