TL;DR Shopify never shipped a dedicated Wishlist API.
You have two practical roads:
(A) keep things entirely in the browser with localStorage, or
(B) build a secure, server-side bridge that writes to Customer Metafields via the Admin API.
Shopify exposes REST and GraphQL APIs for almost everything else, including products, carts, orders, and checkout.
Wishlists never made the cut, so merchants usually:
If you’re new, start with localStorage. When sales or feature demands outgrow that, you’d need to migrate the same UI to a metafield backend without the need for customer retraining.
Here’s what you need to do to get started with your own Shopify API:
This method lets you create a temporary wishlist for your customers without needing to get into the messy backend code. It has its quirks, so decide for yourself:
That said, let’s dive in:
Inside your product template (or card snippet):
{% comment %}Show the button only for logged-in *or* guest users{% endcomment %}
<button class="wishlist-btn"
data-product-handle="{{ product.handle }}"
data-product-id="{{ product.id }}">
♥ Add to Wishlist
</button>
Style it however you like (heart icon, “Save for later,” etc.).
Create the file assets/wishlist.js and include it in your theme by adding a <script src="{{ 'wishlist.js' | asset_url }}"></script> tag just before the closing </body> tag in your theme.liquid file (typically found in the footer section):
document.addEventListener('DOMContentLoaded', () => {
const STORAGE_KEY = 'shopifyWishlist'; // one array, JSON-encoded
// read / write helpers
const getList = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
const setList = (arr) => localStorage.setItem(STORAGE_KEY, JSON.stringify(arr));
const toggleBtn = (btn, inList) => {
btn.classList.toggle('is-added', inList);
btn.textContent = inList ? '✓ Wishlisted' : '♥ Add to Wishlist';
};
// initialise every button on page load
document.querySelectorAll('.wishlist-btn').forEach(btn => {
const pid = btn.dataset.productId; // string
toggleBtn(btn, getList().includes(pid));
btn.addEventListener('click', () => {
let list = getList();
const idx = list.indexOf(pid);
if (idx >= 0) { list.splice(idx, 1); } // remove
else { list.push(pid); } // add
setList(list);
toggleBtn(btn, idx < 0);
});
});
});
Here’s what this script is doing:
To get started, you need to create a new template – templates/page.wishlist.liquid.
Next, markup the container; this is one way to do it:
<div class="wishlist-page">
<h1>{{ page.title }}</h1>
<div id="wishlist-grid"></div>
</div>
<script src="{{ 'wishlist.js' | asset_url }}"></script>
<script>
document.addEventListener('DOMContentLoaded', renderWishlist);
async function renderWishlist() {
const grid = document.getElementById('wishlist-grid');
const ids = JSON.parse(localStorage.getItem('shopifyWishlist') || '[]');
if (ids.length === 0) {
grid.innerHTML = '<p>Your wishlist is empty.</p>';
return;
}
// Fetch each product’s JSON (Shopify AJAX API)
const cards = await Promise.all(ids.map(async id => {
try {
const res = await fetch(`/products/${id}.js`); // works if id is *handle*
const p = await res.json();
return `
<div class="wishlist-item">
<a href="${p.url}">
<img src="${p.featured_image}" alt="${p.title}">
<h3>${p.title}</h3>
</a>
<p>${Shopify.formatMoney(p.price, theme.moneyFormat)}</p>
</div>`;
} catch(e) { console.error('Missing product', id); return ''; }
}));
grid.innerHTML = cards.join('');
}
</script>
Note: If you stored numeric IDs instead of handles, map them to handles first (AJAX /products/<handle>.js only accepts handles). Small stores often pick handles because they’re easier to read and work straight out of Liquid later.
LocalStorage doesn’t hit your backend, so there’s no credential risk, but as soon as you step up to Metafields you must:
If you want everything (multi-list support, cross-device sync, price-drop emails, guest → account sync, Klaviyo flows, native account-page widget) install Froonze VIP Loyalty & Wishlist and be done by lunch.
You still keep control (custom CSS, JS hooks, optional), but the heavy lifting, UI polish, and compliance land on our plate, not yours.
In method 1, we parked everything in localStorage. Great MVP, terrible for cross-device shoppers.
Now we wire the same UI to Shopify’s Admin API, safely, so wishlists live in the customer record and follow them everywhere:
Browser ──▶ /apps/wishlist/add (App Proxy URL on your store)
│
▼
Your App/Function (Node, Ruby, etc.)
│ (Admin API – secured token, server-side only)
▼
Customer Metafield namespace:"wishlist" key:"products"
▲
(same path for /remove, /list, etc.)
You’re getting the following out of this Shopify API method:
Every customer now has customer.metafields.wishlist.products.
From here onwards, it’s your own creative playground. But let’s try setting it up with the above constraints:
In the app admin → App proxy
Subpath: /apps/wishlist
Sub-path prefix: https://your-server.com/proxy
Now Shopify will forward
GET /apps/wishlist/ping → https://your-server.com/proxy/ping
and append HMAC + logged_in_customer_id.
import crypto from 'crypto';
import express from 'express';
import {Shopify} from '@shopify/shopify-api';
const app = express();
const token = process.env.SHOPIFY_ADMIN_TOKEN;
const shop = process.env.SHOP; // my-store.myshopify.com
const API = new Shopify.Clients.Rest(shop, token);
// — validate every proxy hit —
function verifyProxy(req, res, next) {
const {shop, path_prefix, timestamp, signature} = req.query;
const base = Object
.entries({shop, path_prefix, timestamp})
.map(([k,v]) => `${k}=${v}`)
.sort()
.join('');
const calc = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET)
.update(base)
.digest('hex');
if (calc !== signature) return res.status(401).send('Bad HMAC');
next();
}
app.use(express.json());
app.use('/proxy', verifyProxy);
app.post('/proxy/add', async (req, res) => {
const customerId = req.query.logged_in_customer_id;
const {productGid} = req.body;
if (!customerId) return res.status(401).send('Login required');
// 1. read current metafield
const meta = await API.get({
path: `customers/${customerId}/metafields`,
query: { namespace: 'wishlist', key: 'products' }
});
let list = [];
if (meta.body.metafields.length) {
list = JSON.parse(meta.body.metafields[0].value);
}
if (!list.includes(productGid)) list.push(productGid);
// 2. upsert metafield
const payload = {
namespace: 'wishlist',
key: 'products',
type: 'json',
value: JSON.stringify(list),
};
if (meta.body.metafields.length) {
await API.put({path: `metafields/${meta.body.metafields[0].id}`, data: {metafield: payload}, type:'json'});
} else {
payload.owner_id = customerId;
payload.owner_resource = 'customer';
await API.post({path:'metafields', data:{metafield: payload}, type:'json'});
}
res.json({ok:true, list});
});
app.post('/proxy/remove', async (req, res) => {
const customerId = req.query.logged_in_customer_id;
const {productGid} = req.body;
if (!customerId) return res.status(401).send('Login required');
const meta = await API.get({
path: `customers/${customerId}/metafields`,
query: { namespace: 'wishlist', key: 'products' }
});
if (!meta.body.metafields.length) return res.json({ok:true, list:[]});
let list = JSON.parse(meta.body.metafields[0].value)
.filter(gid => gid !== productGid);
await API.put({
path: `metafields/${meta.body.metafields[0].id}`,
data: { metafield: { value: JSON.stringify(list) } },
type: 'json'
});
res.json({ok:true, list});
});
No Admin key leaves the server.
HMAC confirms every call is Shopify-signed. logged_in_customer_id guarantees users can touch only their own list.
Deploy (on Render, Fly.io, etc.). Save the public URL in the proxy settings.
Replace the localStorage calls in method 1 JS:
async function wishlist(action, productGid) {
const res = await fetch(`/apps/wishlist/${action}`, {
method : 'POST',
headers: {'Content-Type':'application/json'},
body : JSON.stringify({productGid})
});
return res.json(); // {ok:true, list:[…]}
}
Use product GIDs:
data-gid="{{ product.admin_graphql_api_id }}"
Button click becomes:
await wishlist('add', gid);
toggleBtn(btn,true);
Same for remove.
Metafield now stores GIDs, so fetch data in bulk:
const query = `
query ($ids:[ID!]!) {
nodes(ids:$ids) {
... on Product {
id title handle onlineStoreUrl
featuredImage { url }
priceRange { minVariantPrice { amount currencyCode } }
}
}
}`;
async function render() {
const res1 = await fetch('/apps/wishlist/list'); // optional “get” endpoint
const {list} = await res1.json(); // array of GIDs
const res2 = await fetch('/api/2024-04/graphql.json', {
method:'POST',
headers:{
'Content-Type':'application/json',
'X-Shopify-Storefront-Access-Token': STOREFRONT_TOKEN
},
body: JSON.stringify({query, variables:{ids:list}})
});
const {data:{nodes}} = await res2.json();
// build HTML grid from nodes, use creative liberty…
}
Keep the method 1 localStorage list for guests.
On login/signup (window.customerData becomes available or check Shopify.customerId) run:
const local = JSON.parse(localStorage.getItem('shopifyWishlist') || '[]');
if (local.length) {
await fetch('/apps/wishlist/merge', {method:'POST', body:JSON.stringify({local})});
localStorage.removeItem('shopifyWishlist');
}
Back-end merges and de-dupes. Shoppers keep what they saved before login.
If you like the idea of a Metafield-powered wishlist but not the upkeep, try Froonze.
It fires Javascript events every time the wishlist app loads or mutates. You can hook into those events to run analytics, marketing automations, or custom UI updates without having to touch Shopify’s Admin API. So, essentially:
Bottom line: install Froonze VIP Loyalty & Wishlist, listen to three events, and you’ve got an instant, code-light Wishlist API that plays nicely with the rest of your stack.
Pick the ladder rung that matches today’s resources, and know you can always climb higher later. Good luck!