How to Add a Recently Viewed Products Section to Shopify
Why a Recently Viewed Products Section Boosts Repeat Browsing
A shopper lands on your store from a paid ad, views three products, gets distracted, and leaves. An hour later they come back — and have to start over. A recently viewed products section fixes that. It's a persistent, personalized shelf that remembers what each visitor looked at and puts it back in front of them, on the product page, the collection page, or the cart.
For small e-commerce stores, this matters more than it does for Amazon. You don't have a recommendation engine trained on millions of sessions. What you do have is a cheap, reliable signal: what this specific person clicked on five minutes ago. Stores that surface that signal typically see better return-visit engagement because shoppers don't have to re-find the item they were already interested in.
There's no native toggle for this in Shopify. You build it, or you install an app. This guide covers the build — the Liquid section, the localStorage logic, and the rendering — plus when paying for an app is the smarter call.
How Shopify Tracks Recently Viewed Products (and Why It Doesn't by Default)
Shopify tracks plenty: sessions, conversion rates, product analytics, customer order history. What it does not do out of the box is track a guest's browsing history on the storefront.
The reason is architectural. Shopify's servers render your theme, but they don't maintain a per-visitor product view log for anonymous browsers. Customer accounts do have order history, but that's a different thing entirely — it's post-purchase, not pre-purchase browsing. For the majority of your traffic, which is logged-out, there's no server-side record to query.
That leaves two options:
- Client-side storage —
localStoragein the browser holds an array of viewed product handles. Fast, free, no app, no data leaving the visitor's device. - Server-side tracking via an app — a third-party service stores view events against a cookie or device ID, often syncing to a customer profile once they log in or buy.
For most stores under a few thousand SKUs, localStorage is the right answer. It's instant, it costs nothing, and it sidesteps cookie-consent complexity since you're not transmitting personal data anywhere. The tradeoff: it doesn't follow the shopper across devices, and it clears if they wipe their browser data.
Building the Section: Liquid Template and Schema Setup
Start by creating a new section file: sections/recently-viewed.liquid. Sections are the right container here because they get schema, which means you can toggle the block on or off, reorder it, and edit the heading from the theme editor without touching code.
The section needs four schema settings at minimum:
| Setting ID | Type | Purpose |
|---|---|---|
heading |
text |
Section title, defaults to "Recently Viewed" |
product_limit |
range |
How many products to show (4–12 is sensible) |
show_on_product |
checkbox |
Render below the product form |
show_on_collection |
checkbox |
Render at the bottom of collection pages |
A minimal schema block looks like this:
{% schema %}
{
"name": "Recently Viewed",
"settings": [
{ "type": "text", "id": "heading", "label": "Heading", "default": "Recently Viewed" },
{ "type": "range", "id": "product_limit", "min": 4, "max": 12, "step": 1, "default": 6, "label": "Products to show" },
{ "type": "checkbox", "id": "show_on_product", "label": "Show on product pages", "default": true },
{ "type": "checkbox", "id": "show_on_collection", "label": "Show on collection pages", "default": true }
],
"presets": [{ "name": "Recently Viewed" }]
}
{% endschema %}
The markup itself is a placeholder container. Everything inside it gets populated by JavaScript, because Liquid renders on the server and has no idea what the visitor browsed. Give it a stable ID so your script can find it:
<div id="recently-viewed" class="recently-viewed" data-limit="{{ section.settings.product_limit }}" hidden>
<h2 class="recently-viewed__heading">{{ section.settings.heading }}</h2>
<div class="recently-viewed__grid" data-recently-viewed-grid></div>
</div>
Note the hidden attribute. The section should stay invisible until JavaScript confirms there's something to show. Otherwise new visitors see an empty heading, which looks broken.
Adding the localStorage Logic to Capture Product Views
Create assets/recently-viewed.js. Two responsibilities: record views, and render the block.
Recording happens on every product page load. Read the existing array, remove the current product if it's already there, unshift it to the front, trim to a max length, and write it back:
const KEY = 'rv_products';
const MAX = 12;
function recordView(handle) {
let list = [];
try { list = JSON.parse(localStorage.getItem(KEY)) || []; } catch (e) { list = []; }
list = list.filter(h => h !== handle);
list.unshift(handle);
list = list.slice(0, MAX);
localStorage.setItem(KEY, JSON.stringify(list));
}
Wrap the JSON.parse in a try/catch. Storage gets corrupted, extensions interfere, and a thrown error will kill the rest of your scripts.
Trigger it from the product template. The cleanest way is to pass the handle through a data attribute rather than parsing it out of the URL:
<script>
window.rvCurrentHandle = {{ product.handle | json }};
</script>
Then in your JS entry point:
if (window.rvCurrentHandle) recordView(window.rvCurrentHandle);
This runs before rendering. If you're on a product page, the current product is now at the front of the list — which means you'll want to exclude it when rendering, or you'll show the shopper the page they're already looking at.
Rendering the Recently Viewed Block on Product and Collection Pages
Rendering means turning handles into product cards. You can't query Shopify for products by handle from the browser without the Storefront API, and pulling in the API for this is overkill. The simpler pattern: fetch each handle's product data from Shopify's public JSON endpoint.
/products/{handle}.js
This returns a JSON object with title, price, featured image, and URL — everything a card needs. Fetch them in parallel:
async function renderRecent(excludeHandle) {
let list = [];
try { list = JSON.parse(localStorage.getItem(KEY)) || []; } catch (e) { return; }
list = list.filter(h => h !== excludeHandle).slice(0, limit);
if (!list.length) return;
const products = await Promise.all(
list.map(h => fetch(`/products/${h}.js`).then(r => r.ok ? r.json() : null).catch(() => null))
);
const valid = products.filter(Boolean);
if (!valid.length) return;
// build card markup, inject into [data-recently-viewed-grid]
// then remove the [hidden] attribute from #recently-viewed
}
Two details that matter. First, Promise.all with per-item catch so one deleted product doesn't break the whole block. Second, only unhide the section after you've confirmed valid.length > 0. Products get unpublished, handles change — your stored list will go stale, and the code has to tolerate that gracefully.
On collection pages, pass null as the exclude handle, since there's no current product. On product pages, pass the current handle.
Styling and Testing Your Section Across Themes
Style with a CSS grid that collapses cleanly:
.recently-viewed__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
auto-fill with minmax gives you responsive columns without media queries. On a 1200px container you'll get six or seven cards; on mobile, two.
Test these cases before you ship:
- Dawn and other Online Store 2.0 themes — sections work natively, schema appears in the editor.
- Vintage themes (older Debut, Brooklyn, Supply) — sections may not be supported in the same way. You may need to hardcode the include in
product.liquidandcollection.liquidinstead of relying on the section editor. - Empty state — clear localStorage, load a product page, confirm nothing renders and no empty heading appears.
- One item — view a single product, navigate to another, confirm the first one shows.
- Deleted product — unpublish a product that's in your list and confirm the fetch failure is handled silently.
- Theme editor — the section should render in the customizer preview without breaking.
Also test in Safari private browsing. Some browsers restrict localStorage in private mode, and your code should degrade to simply not showing the section rather than throwing.
When a Paid Recently Viewed Products App Is Worth It
Building this yourself takes an afternoon and costs nothing. Apps like Rebuy, Recomatic, or the various recently-viewed apps in the Shopify App Store add things the DIY version can't do easily. Check current pricing on each before committing.
An app earns its fee when you need:
- Cross-device tracking tied to logged-in customers, not just browser storage
- Recommendation logic beyond "what you clicked" — frequently bought together, similar category, AI-driven
- Email and SMS integration that feeds viewed products into abandoned-browse flows
- Analytics showing which recommended products actually convert
- Zero-maintenance implementation, which matters if you don't have a developer on call
If you're a solo operator with a straightforward catalog and no dev time, a free or low-cost app is a reasonable shortcut. If you have basic Liquid and JS skills, the custom build gives you full control over markup, styling, and performance — and no monthly fee.
Common Pitfalls and Performance Tips
A few things that trip people up:
- Storing more than you need. Twelve handles is plenty. Storing fifty bloats localStorage and slows rendering for no benefit.
- Blocking render. Don't put the fetch calls in a synchronous script tag in
<head>. Load the JS withdeferso it doesn't block first paint. - Fetching all products on every page. Only fetch on pages where the section renders. On the homepage, skip it entirely unless you specifically want it there.
- Ignoring the empty state. Showing a heading with no products is worse than showing nothing.
- No caching. The
/products/{handle}.jsresponses are cacheable. If you're fetching the same handles repeatedly, browser caching handles it — but don't add cache-busting query strings. - Forgetting currency and locale. The
.jsendpoint returns prices in the store's default currency. On multi-currency stores, test that the displayed price matches what the shopper sees elsewhere. - Accessibility. Give the section an
aria-label, and make sure card links have discernible text. Screen reader users shouldn't hear "link, link, link."
One performance note worth repeating: the section should never delay your Largest Contentful Paint. Because it's hidden until populated, and because it sits below the fold on most product templates, it won't compete with your hero image or product gallery. Keep it that way — don't move it above the fold on a whim.
Conclusion
A recently viewed section is one of the highest-return, lowest-effort additions you can make to a Shopify store. The localStorage approach costs nothing, respects visitor privacy, and takes a single afternoon to build with the Liquid section and JavaScript outlined above. Start there, measure whether returning visitors engage with it, and only reach for a paid app if you need cross-device tracking or recommendation logic that goes beyond click history.
FAQ
Does this work for logged-out visitors?
Yes. That's the point of using localStorage — it works for every visitor regardless of whether they have an account, because the data lives in their browser, not on Shopify's servers.
Will the section slow down my store?
Not if you load the script with defer, keep the section hidden until it's populated, and limit the stored list to around twelve handles. The fetches are small JSON requests that browser caching handles well.
What happens when a product in the list gets deleted or unpublished? The fetch for that handle returns a non-OK response, your per-item catch filters it out, and the remaining products render normally. The stale handle stays in localStorage until it ages out of the list, which is harmless.