Free code · No app required
A wishlist on Shopify does not need an app. This page gives you the whole thing: a heart button for your product cards, the JavaScript that saves selections, and a wishlist page that pulls live prices from Shopify. It runs on localStorage and Shopify’s public /products/{handle}.js endpoint — no backend, no API key, no monthly fee. Roughly 2 KB of JavaScript. Every file below passes Shopify’s own Theme Check.
This implementation saves product handles in the visitor's browser and re-fetches each product when the wishlist page loads. Because only the handle is stored, the price, image and availability a customer sees are always current — a wishlist that caches prices will show stale numbers after a sale.
| Works | Does not work |
|---|---|
| Guests, with no account and no signup | Syncing across a customer's phone and laptop |
| Survives reloads and browser restarts | Surviving a cleared browser or private window |
| Live price and stock on the wishlist page | Emailing a customer when a saved item drops in price |
| Any Online Store 2.0 theme | Vintage (pre-2.0) themes without edits |
The three 'does not work' items all need the list stored against a customer account rather than a browser. That is a genuinely different build, and it is the point at which paying someone starts to make sense. Everything above the line is free and on this page.
Five files. Copy each one into the path shown. Nothing here calls an external service.
snippets/wishlist-button.liquidThe heart button. Drop it into any product card or the product page.
{%- comment -%}
Wishlist button — no app, no backend.
Usage: {% render 'wishlist-button', product: product %}
{%- endcomment -%}
<button
type="button"
class="wl-btn"
data-wl-handle="{{ product.handle }}"
data-wl-id="{{ product.id }}"
aria-label="Add {{ product.title | escape }} to wishlist"
aria-pressed="false"
>
<svg class="wl-icon" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path d="M12 21s-7.5-4.6-9.6-8.6C.6 9.1 2.2 5.5 5.6 4.7c2-.5 3.9.3 5 1.8l1.4 1.9 1.4-1.9c1.1-1.5 3-2.3 5-1.8 3.4.8 5 4.4 3.2 7.7C19.5 16.4 12 21 12 21z"
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
</button>assets/wishlist.jsAll the logic: saves handles to localStorage, keeps every heart on the page in sync, updates the header count.
(function () {
'use strict';
var KEY = 'shopify_wishlist';
function read() {
try { return JSON.parse(localStorage.getItem(KEY)) || []; }
catch (e) { return []; }
}
function write(list) {
localStorage.setItem(KEY, JSON.stringify(list));
// Same-tab listeners (the storage event only fires in OTHER tabs).
document.dispatchEvent(new CustomEvent('wishlist:change', { detail: list }));
}
function toggle(handle) {
var list = read();
var i = list.indexOf(handle);
if (i > -1) list.splice(i, 1); else list.push(handle);
write(list);
return i === -1; // true = it was just added
}
function paintOne(btn, list) {
var on = list.indexOf(btn.dataset.wlHandle) > -1;
btn.classList.toggle('is-active', on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
}
function paintAll() {
var list = read();
document.querySelectorAll('.wl-btn').forEach(function (b) { paintOne(b, list); });
document.querySelectorAll('[data-wl-count]').forEach(function (el) {
el.textContent = list.length;
el.hidden = list.length === 0;
});
}
// Delegated, so buttons added later (quick view, infinite scroll, section
// re-render after a theme-editor change) work without re-binding.
document.addEventListener('click', function (e) {
var btn = e.target.closest('.wl-btn');
if (!btn) return;
e.preventDefault();
toggle(btn.dataset.wlHandle);
paintAll();
});
document.addEventListener('wishlist:change', paintAll);
window.addEventListener('storage', function (e) { if (e.key === KEY) paintAll(); });
document.addEventListener('DOMContentLoaded', paintAll);
// Shopify fires this after a section re-renders in the theme editor.
document.addEventListener('shopify:section:load', paintAll);
})();assets/wishlist.cssMinimal styling. Uses currentColor so it inherits your theme.
.wl-btn {
background: none;
border: 0;
padding: 6px;
line-height: 0;
cursor: pointer;
color: inherit;
transition: transform .15s ease;
}
.wl-btn:hover { transform: scale(1.12); }
.wl-btn.is-active .wl-icon path { fill: currentColor; }
.wl-count[hidden] { display: none; }
.wl-count {
display: inline-block;
min-width: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 12px;
line-height: 18px;
text-align: center;
background: currentColor;
}
.wl-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 24px;
}
.wl-empty { padding: 48px 0; text-align: center; opacity: .7; }sections/wishlist-page.liquidThe wishlist page itself. Reads the saved handles and pulls live product data from Shopify's own /products/{handle}.js — so price and availability are never stale.
<div class="page-width">
<h1>{{ section.settings.heading | default: 'Your wishlist' }}</h1>
<div class="wl-grid" id="wl-grid"></div>
<p class="wl-empty" id="wl-empty" hidden>
Nothing saved yet. Tap the heart on any product to add it here.
</p>
</div>
<script>
(function () {
var grid = document.getElementById('wl-grid');
var empty = document.getElementById('wl-empty');
var money = {{ shop.money_format | json }};
function fmt(cents) {
return money.replace(/\{\{\s*amount\s*\}\}/, (cents / 100).toFixed(2));
}
async function render() {
var handles = [];
try { handles = JSON.parse(localStorage.getItem('shopify_wishlist')) || []; } catch (e) {}
grid.innerHTML = '';
empty.hidden = handles.length > 0;
for (const handle of handles) {
try {
// Public JSON for any product. No API key, no app.
const res = await fetch('/products/' + handle + '.js');
if (!res.ok) continue; // product deleted or unpublished
const p = await res.json();
const img = p.featured_image || (p.images && p.images[0]) || '';
const card = document.createElement('div');
card.className = 'wl-card';
card.innerHTML =
'<a href="/products/' + p.handle + '">' +
(img ? '<img src="' + img + '&width=400" alt="" loading="lazy" width="400">' : '') +
'<h3>' + p.title + '</h3>' +
'</a>' +
'<p>' + fmt(p.price) + '</p>' +
'<button class="wl-btn is-active" data-wl-handle="' + p.handle + '" aria-pressed="true">Remove</button>';
grid.appendChild(card);
} catch (e) { /* skip this one, keep rendering the rest */ }
}
}
document.addEventListener('DOMContentLoaded', render);
document.addEventListener('wishlist:change', render);
})();
</script>
{% schema %}
{
"name": "Wishlist page",
"settings": [
{ "type": "text", "id": "heading", "label": "Heading", "default": "Your wishlist" }
],
"presets": [{ "name": "Wishlist page" }]
}
{% endschema %}snippets/wishlist-count.liquidOptional. The little counter for your header.
<a href="/pages/wishlist" class="wl-link" aria-label="Wishlist">
Wishlist <span class="wl-count" data-wl-count hidden>0</span>
</a>Every file above, already in the right theme folder, plus a README with the install steps. Unzip it straight into your theme instead of copying 5 blocks by hand.
One download with every file in its correct theme folder. We'll also email you when the next free guide ships — one click to unsubscribe, and we never pass your address on. The code above stays free whether you enter an email or not.
In your theme, go to Online Store → Themes → … → Edit code. Create snippets/wishlist-button.liquid, assets/wishlist.js and assets/wishlist.css, and paste the code from each block above.
Open layout/theme.liquid and add these two lines just before </head>:
{{ 'wishlist.css' | asset_url | stylesheet_tag }}
<script src="{{ 'wishlist.js' | asset_url }}" defer></script>In Dawn this is snippets/card-product.liquid; in Horizon it is blocks/product-card.liquid. Find the block that renders the product image and add the render tag inside it. On the product page, add the same line to sections/main-product.liquid next to the Add to cart button.
{% render 'wishlist-button', product: product %}Add sections/wishlist-page.liquid with the code above. Then in the admin create a page with the handle "wishlist" and assign it a template that renders this section. The URL becomes /pages/wishlist.
Add snippets/wishlist-count.liquid and render it in your header snippet where the cart icon lives:
{% render 'wishlist-count' %}Yes. A Shopify wishlist works without an app by storing the saved product handles in the browser's localStorage and reading live product data from Shopify's public /products/{handle}.js endpoint. No backend, no app subscription and no API key is needed. The trade-off is that the wishlist lives in that one browser: it survives page reloads and browser restarts, but it does not follow the customer to another device. If you need cross-device sync, that requires storing the list against the customer account, which does need either an app or a small backend.
In localStorage under the key shopify_wishlist, as a plain JSON array of product handles — for example ["blue-tshirt","red-cap"]. Only handles are stored, never prices or images, which is deliberate: the wishlist page re-fetches each product on load, so a price change, a sale, or an out-of-stock flag is always current. Storing a price snapshot is the most common mistake in DIY wishlist tutorials and it shows customers stale numbers.
Yes, on both, and on any Online Store 2.0 theme. The only file that differs is the product card. In Dawn the card is snippets/card-product.liquid; in Horizon it is blocks/product-card.liquid. Everything else — the JS, the CSS, the wishlist page section — is theme-independent because it hooks on a CSS class rather than on theme markup. The click handler is delegated from document, so hearts inside quick-view modals and infinite-scroll pages work with no extra binding.
This implementation adds roughly 2 KB of JavaScript and no blocking requests, so the effect on LCP is negligible. The wishlist page does fetch one request per saved product, but that happens after the page has already rendered. The common performance mistake is loading a wishlist app on every page of the store to power a heart icon — that typically costs 40–100 KB plus a third-party connection on templates that never use it.
Guests can use it immediately, with no account. That is the main advantage of the localStorage approach and the main reason it converts: you are not asking for a signup before someone has decided to buy. The cost is that the list is per-browser. A common middle path is to keep localStorage for guests and, when a customer does log in, copy the list into a customer metafield so it follows them — that part does need a small app or a Shopify Function.
The code above covers a normal storefront wishlist. Teams come to us when they need the parts it deliberately leaves out — each one needs the list stored against the customer rather than the browser, which is a different build.
We build Shopify themes and apps, and we have shipped three of our own to the Shopify App Store. Tell us what you are trying to do and our team will go through the requirement with you — what it actually involves, what you do not need, and the simplest way to get there. This is paid work, and we will set it up at the lowest cost it can be done properly for. You will know the price before anything starts.
Reply within a few hours · We go through the requirement with you first