Development of "Recently Viewed" Block for E-Commerce
The recently viewed products block helps users return to examined items without searching through the catalog. This is one of the simplest personalization elements with minimal development cost and measurable impact on return visits to product cards. Development takes 1–2 business days.
Storing Browse History
For guests, history is stored in localStorage. For authorized users — optionally synchronized with server:
// hooks/useRecentlyViewed.ts
const STORAGE_KEY = 'recently_viewed';
const MAX_ITEMS = 20;
export const useRecentlyViewed = () => {
const [items, setItems] = useState<number[]>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
});
const addProduct = useCallback((productId: number) => {
setItems(prev => {
const filtered = prev.filter(id => id !== productId);
const updated = [productId, ...filtered].slice(0, MAX_ITEMS);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
});
}, []);
const clearHistory = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setItems([]);
}, []);
return { productIds: items, addProduct, clearHistory };
};
On each product page — call addProduct(product.id):
// In product page component
const { addProduct } = useRecentlyViewed();
useEffect(() => { addProduct(product.id); }, [product.id]);
Server Synchronization for Authorized Users
For authorized users, history is synchronized with the server — to persist across devices:
// routes
Route::middleware('auth:sanctum')->post('/me/recently-viewed', [RecentlyViewedController::class, 'sync']);
Route::middleware('auth:sanctum')->get('/me/recently-viewed', [RecentlyViewedController::class, 'index']);
public function sync(Request $request): JsonResponse
{
$request->validate(['product_ids' => 'required|array|max:20', 'product_ids.*' => 'integer']);
$user = $request->user();
// Update order: transmitted list — actual state
$user->recentlyViewed()->sync(
collect($request->product_ids)->mapWithKeys(fn($id, $pos) => [
$id => ['position' => $pos, 'viewed_at' => now()]
])
);
return response()->json(['synced' => count($request->product_ids)]);
}
Synchronization — on login (merge localStorage with server history) and on tab close (beforeunload + navigator.sendBeacon).
Loading Product Data
History stores only product_id. To display the block, product data is needed — one API request:
const RecentlyViewedBlock = () => {
const { productIds } = useRecentlyViewed();
const visibleIds = productIds.slice(0, 8); // show no more than 8
const { data: products } = useQuery({
queryKey: ['recently-viewed-products', visibleIds],
queryFn: () => api.get('/products/batch', { params: { ids: visibleIds.join(',') } }),
enabled: visibleIds.length > 0,
staleTime: 300_000,
});
if (!products?.length) return null;
// Maintain history order
const ordered = visibleIds
.map(id => products.find((p: Product) => p.id === id))
.filter(Boolean);
return (
<section>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">You Recently Viewed</h2>
<button onClick={clearHistory} className="text-sm text-gray-400 hover:text-gray-600">
Clear History
</button>
</div>
<ProductCarousel products={ordered} />
</section>
);
};
Endpoint for Batch Loading
public function batch(Request $request): JsonResponse
{
$request->validate(['ids' => 'required|string']);
$ids = array_filter(array_map('intval', explode(',', $request->ids)));
$ids = array_slice($ids, 0, 20); // limit
$products = Product::whereIn('id', $ids)
->where('is_active', true)
->select(['id', 'name', 'slug', 'price', 'sale_price', 'rating_avg', 'rating_count'])
->with('thumbnail')
->get()
->keyBy('id');
// Return in order of transmitted ids
$ordered = collect($ids)->map(fn($id) => $products->get($id))->filter()->values();
return response()->json(ProductCardResource::collection($ordered));
}
Block Placement
- Home page: for returning users — instead of or next to popular products
- Category page: at the bottom, after main product grid
- Product page: under "Similar Products" block
- Cart: in sidebar on desktop
- Empty search results: "Maybe you're looking for something you viewed?"
Excluding Current Product
On product page X, the "Recently Viewed" block excludes product X itself — otherwise it will inevitably be first:
const productIds = useRecentlyViewed().productIds.filter(id => id !== currentProductId);
Privacy
"Clear History" button gives users control over data. Retention in localStorage is unlimited by browser, but TTL can be added manually:
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const { items, savedAt } = JSON.parse(stored);
const isExpired = Date.now() - savedAt > 30 * 24 * 60 * 60 * 1000; // 30 days
if (isExpired) localStorage.removeItem(STORAGE_KEY);
}







