テクニック

Shopifyお気に入り(ウィッシュリスト)をアプリなしで実装する方法

この記事の対象: Shopify テーマをカスタマイズする制作者・フロントエンドエンジニア
読了時間: 約12分

Shopify でウィッシュリスト(お気に入り)機能を入れたいとき、最初に候補に挙がるのはアプリの導入だろう。ただ、月額費用を増やすほどでもない規模のストアや、外部スクリプトの読み込みを減らして表示速度を保ちたい案件では、自前で実装する選択肢がある。

この記事では、ブラウザの localStorage を使った「ログイン不要のお気に入り機能」を扱う。商品カードへのボタン設置、保存の仕組み、一覧ページの作成まで、コードを通しで掲載した。


前提条件と2つの保存方式

前提条件と2つの保存方式

この記事のコードが動く条件

  • Shopify の Online Store 2.0 対応テーマ(Dawn など)を使っていること
  • テーマの「コードを編集」権限があること(管理画面 →「オンラインストア」→「テーマ」→「コードを編集」)
  • JavaScript と Liquid の基本的な読み書きができること

Dawn 以外の OS 2.0 テーマでも、ファイル構成は共通なのでそのまま使える。Vintage テーマ(OS 1.0)では JSON テンプレートの仕組みが異なるため、この記事の手順では動かない。自分のテーマが OS 2.0 かどうかは、templates/ フォルダ内に .json ファイルがあるかどうかで判別できる。

localStorage 方式と顧客メタフィールド方式

お気に入りの保存先は大きく2つある。

localStorage 方式 顧客メタフィールド方式
ログイン 不要 必要
デバイス間同期 できない できる
実装の手間 小さい 大きい(Storefront API 連携が必要)
保存の上限 ブラウザごとに約5MB メタフィールドの上限に依存
向いているストア ゲスト購入が中心 会員制・リピーター重視

この記事では localStorage 方式を扱う。顧客メタフィールド方式は Storefront API の認証やミューテーション実装が加わるため、会員機能を本格的に使うストアでなければ、まず localStorage で十分に機能する。


商品カードにお気に入りボタンを設置する

商品カードにお気に入りボタンを設置する

完成形のファイル構成は次のとおり。

ファイルパス 役割
assets/wishlist.js お気に入りの追加・削除・状態管理
assets/wishlist.css ハートボタンのスタイル
snippets/wishlist-button.liquid 商品カードに埋め込むボタン

Step 1: JavaScript ファイルを作る

ファイルパス: assets/wishlist.js

テーマの「コードを編集」画面で、assets/ フォルダに wishlist.js を新規作成し、以下を貼り付ける。

const Wishlist = {
  STORAGE_KEY: 'shopify_wishlist',

  get() {
    const data = localStorage.getItem(this.STORAGE_KEY);
    return data ? JSON.parse(data) : [];
  },

  save(list) {
    localStorage.setItem(this.STORAGE_KEY, JSON.stringify(list));
    this.updateHeaderCount();
  },

  add(handle) {
    const list = this.get();
    if (!list.includes(handle)) {
      list.push(handle);
      this.save(list);
    }
    this.updateButtons(handle, true);
  },

  remove(handle) {
    const list = this.get().filter(item => item !== handle);
    this.save(list);
    this.updateButtons(handle, false);
  },

  toggle(handle) {
    if (this.contains(handle)) {
      this.remove(handle);
    } else {
      this.add(handle);
    }
  },

  contains(handle) {
    return this.get().includes(handle);
  },

  updateButtons(handle, isActive) {
    document.querySelectorAll('[data-wishlist-handle="' + handle + '"]').forEach(function(btn) {
      btn.classList.toggle('is-wishlisted', isActive);
      btn.setAttribute('aria-pressed', String(isActive));
    });
  },

  updateHeaderCount() {
    var countEl = document.getElementById('wishlist-count');
    if (!countEl) return;
    var count = this.get().length;
    countEl.textContent = count;
    countEl.classList.toggle('is-visible', count > 0);
  },

  initButtons() {
    var self = this;
    document.querySelectorAll('[data-wishlist-handle]').forEach(function(btn) {
      var handle = btn.dataset.wishlistHandle;
      if (self.contains(handle)) {
        btn.classList.add('is-wishlisted');
        btn.setAttribute('aria-pressed', 'true');
      }
      btn.addEventListener('click', function(e) {
        e.preventDefault();
        e.stopPropagation();
        self.toggle(handle);
      });
    });
    self.updateHeaderCount();
  }
};

document.addEventListener('DOMContentLoaded', function() {
  Wishlist.initButtons();
});

商品の識別には handle(URL スラッグ)を使う。product.id でも動くが、handle のほうが一覧ページで /products/{handle}.js から商品情報を取得するときにそのまま使えて都合がいい。

Step 2: CSS ファイルを作る

ファイルパス: assets/wishlist.css

同じく assets/ フォルダに wishlist.css を新規作成する。

.wishlist-btn {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 2;
  width: 36px;
  height: 36px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.85);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background 0.15s;
}

.wishlist-btn:hover {
  background: rgba(255, 255, 255, 1);
}

.wishlist-btn svg {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: #333;
  stroke-width: 2;
  transition: fill 0.15s, stroke 0.15s;
}

.wishlist-btn.is-wishlisted svg {
  fill: #e25555;
  stroke: #e25555;
}

読者が変更すべき箇所:

  • ボタンサイズを変えたいとき → .wishlist-btn の width / height と .wishlist-btn svg の width / height をセットで変更
  • ハートの色を変えたいとき → .is-wishlisted svg の fill と stroke を変更
  • ボタンの位置を変えたいとき → .wishlist-btn の top / right を調整

Step 3: ボタンのスニペットを作る

ファイルパス: snippets/wishlist-button.liquid

snippets/ フォルダに wishlist-button.liquid を新規作成する。

<button
  class="wishlist-btn"
  data-wishlist-handle="{{ product_handle }}"
  aria-label="お気に入りに追加"
  aria-pressed="false"
  type="button"
>
  <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
    <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
  </svg>
</button>

SVG をインラインで書いているのは、外部ファイルにするとリクエストが増えるのと、CSS で fill / stroke を直接操作できるため。アイコンフォントも不要になる。

Step 4: 商品カードにスニペットを組み込む

商品カードを描画しているファイルを開く。Dawn の場合は snippets/card-product.liquid。他のテーマでは「product-card」「product-grid-item」のような名前のスニペットやセクションが該当する。

商品画像を囲んでいる要素の中に、次の1行を追加する。

{% render 'wishlist-button', product_handle: product.handle %}

Dawn での挿入位置: <div class="card__inner"> の中、画像要素の直後あたり。Dawn の .card__inner にはすでに position: relative が付いているので、ボタンが右上に配置される。

他のテーマで親要素に position: relative がない場合は、CSS で追加する必要がある。

Step 5: JS と CSS をテーマに読み込む

ファイルパス: layout/theme.liquid

</head> の直前に次の2行を追加する。

{{ 'wishlist.css' | asset_url | stylesheet_tag }}
<script src="{{ 'wishlist.js' | asset_url }}" defer></script>

defer を付けることで、HTML の解析をブロックせずに読み込まれる。

ここまでの作業で、商品一覧ページや商品詳細ページのカードにハートボタンが表示される。クリックするとハートが赤くなり、もう一度クリックすると元に戻る。ブラウザをリロードしても状態は保持される。

対応ブラウザ: localStorage と querySelectorAll、classList を使用。Chrome・Firefox・Safari・Edge の現行バージョンすべてで動作する。IE は非対応。


お気に入り一覧ページを作る

お気に入り一覧ページを作る

保存しただけでは使いにくい。一覧ページを作って、保存した商品をまとめて見られるようにする。

一覧ページの表示フロー
ページ読み込みlocalStorageからhandle一覧を取得
API取得各handleの商品情報をAJAXで取得
描画商品カードを組み立ててDOMに追加

localStorage から handle の配列を読み取り、Shopify の Product AJAX API(/products/{handle}.js)で商品情報を取得して描画する流れになる。

Step 1: 一覧ページのセクションを作る

ファイルパス: sections/wishlist-page.liquid

sections/ フォルダに wishlist-page.liquid を新規作成する。

<div class="wishlist-page page-width" id="wishlist-container">
  <h1>お気に入り一覧</h1>
  <p class="wishlist-empty" id="wishlist-empty" style="display:none;">
    お気に入りに追加された商品はありません。
  </p>
  <div class="wishlist-grid" id="wishlist-grid"></div>
</div>

<style>
  .wishlist-page {
    padding: 40px 0 60px;
  }
  .wishlist-page h1 {
    margin-bottom: 24px;
  }
  .wishlist-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: 20px;
  }
  .wishlist-card {
    border: 1px solid #e5e5e5;
    border-radius: 8px;
    overflow: hidden;
    position: relative;
    transition: box-shadow 0.2s;
  }
  .wishlist-card:hover {
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
  }
  .wishlist-card img {
    width: 100%;
    aspect-ratio: 1 / 1;
    object-fit: cover;
    display: block;
  }
  .wishlist-card-body {
    padding: 12px 16px 16px;
  }
  .wishlist-card-title {
    font-size: 14px;
    margin: 0 0 8px;
    line-height: 1.4;
  }
  .wishlist-card-title a {
    color: inherit;
    text-decoration: none;
  }
  .wishlist-card-title a:hover {
    text-decoration: underline;
  }
  .wishlist-card-price {
    font-size: 14px;
    font-weight: 600;
    margin: 0;
  }
  .wishlist-remove {
    position: absolute;
    top: 8px;
    right: 8px;
    width: 32px;
    height: 32px;
    border: none;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.9);
    cursor: pointer;
    font-size: 18px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #666;
    line-height: 1;
  }
  .wishlist-remove:hover {
    background: #fff;
    color: #e25555;
  }
</style>

<script>
document.addEventListener('DOMContentLoaded', function() {
  var grid = document.getElementById('wishlist-grid');
  var emptyMsg = document.getElementById('wishlist-empty');
  var handles = JSON.parse(localStorage.getItem('shopify_wishlist') || '[]');

  if (handles.length === 0) {
    emptyMsg.style.display = 'block';
    return;
  }

  handles.forEach(function(handle) {
    fetch('/products/' + encodeURIComponent(handle) + '.js')
      .then(function(res) {
        if (!res.ok) throw new Error('not found');
        return res.json();
      })
      .then(function(product) {
        var price = (product.price / 100).toLocaleString();
        var image = product.featured_image
          ? product.featured_image.replace(/(\.[^.]+)$/, '_480x$1')
          : '';

        var card = document.createElement('div');
        card.className = 'wishlist-card';
        card.setAttribute('data-handle', handle);

        var imgTag = image
          ? '<img src="' + image + '" alt="" loading="lazy" width="480" height="480">'
          : '';

        card.innerHTML =
          imgTag +
          '<button class="wishlist-remove" data-handle="' + handle + '" aria-label="お気に入りから削除">&times;</button>' +
          '<div class="wishlist-card-body">' +
            '<p class="wishlist-card-title"><a href="/products/' + handle + '">' + product.title + '</a></p>' +
            '<p class="wishlist-card-price">&yen;' + price + '</p>' +
          '</div>';

        grid.appendChild(card);

        card.querySelector('.wishlist-remove').addEventListener('click', function() {
          var h = this.dataset.handle;
          var list = JSON.parse(localStorage.getItem('shopify_wishlist') || '[]');
          list = list.filter(function(item) { return item !== h; });
          localStorage.setItem('shopify_wishlist', JSON.stringify(list));
          card.remove();
          if (typeof Wishlist !== 'undefined') {
            Wishlist.updateButtons(h, false);
            Wishlist.updateHeaderCount();
          }
          if (grid.children.length === 0) {
            emptyMsg.style.display = 'block';
          }
        });
      })
      .catch(function() {
        // 商品が削除済み・非公開の場合はスキップ
      });
  });
});
</script>

{% schema %}
{
  "name": "お気に入り一覧",
  "settings": [],
  "presets": [
    {
      "name": "お気に入り一覧"
    }
  ]
}
{% endschema %}

読者が変更すべき箇所:

  • カードの最小幅 → .wishlist-grid の minmax(220px, 1fr) を調整
  • サムネイルサイズ → _480x を _300x や _600x に変更
  • 通貨記号 → &yen; を他の通貨に変更する場合

価格表示の注意点: Shopify の Product AJAX API は価格を「表示価格 × 100」の整数で返す。product.price / 100 で人間が読める金額になる。ただし、ストアの通貨設定や Shopify Markets の構成によって挙動が変わる可能性がある。価格がおかしい場合は、ブラウザの開発者ツールで /products/{handle}.js のレスポンスを直接確認し、price フィールドの値を確かめること。

Step 2: JSON テンプレートを作る

ファイルパス: templates/page.wishlist.json

templates/ フォルダに page.wishlist.json を新規作成する。

{
  "sections": {
    "main": {
      "type": "wishlist-page",
      "settings": {}
    }
  },
  "order": ["main"]
}

Step 3: 管理画面でページを作成する

  1. Shopify 管理画面 →「オンラインストア」→「ページ」→「ページを追加」
  2. タイトルに「お気に入り」と入力
  3. 右サイドバーの「テーマテンプレート」で wishlist を選択
  4. 保存する

URL スラッグは自動で日本語になるため、必要に応じて管理画面のページ設定から wishlist や favorites に変更する。

ヘッダーにアイコンリンクを追加する

カートアイコンの隣にお気に入りアイコンを置きたい場合は、スニペットを追加する。

ファイルパス: snippets/wishlist-header-link.liquid

<a href="/pages/wishlist" class="wishlist-header-link" aria-label="お気に入り">
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
    <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
  </svg>
  <span class="wishlist-count" id="wishlist-count"></span>
</a>

<style>
  .wishlist-header-link {
    position: relative;
    display: inline-flex;
    align-items: center;
    color: inherit;
  }
  .wishlist-count {
    position: absolute;
    top: -6px;
    right: -10px;
    background: #e25555;
    color: #fff;
    font-size: 11px;
    min-width: 18px;
    height: 18px;
    border-radius: 9px;
    display: none;
    align-items: center;
    justify-content: center;
    line-height: 1;
    padding: 0 4px;
  }
  .wishlist-count.is-visible {
    display: flex;
  }
</style>

Dawn の場合、sections/header.liquid 内のカートアイコンの直前あたりに {% render 'wishlist-header-link' %} を挿入する。件数バッジの更新は、Step 1 で作った wishlist.js 内の updateHeaderCount() が自動で行う。

リンク先の /pages/wishlist は、Step 3 でページの URL スラッグを wishlist に変更した場合の値。 日本語スラッグのままにした場合は、実際の URL に合わせて変更すること。

ここまでの実装で、「商品カードのハートをクリック → localStorage に保存 → 一覧ページで表示 → 一覧から削除」という一連の流れが完成する。

テーマ選定の段階であれば、カスタマイズのしやすさも判断材料にしておくと、こうした独自機能の追加がスムーズになる。


アプリを使ったほうがいい条件

アプリを使ったほうがいい条件

localStorage 方式は手軽だが、向かないケースもある。次のどれかに当てはまるなら、ウィッシュリスト系アプリの導入を検討したほうがよい。

  • デバイスをまたいで同期したい — スマホで追加した商品をPCでも見たい場合、ログインとサーバー側の保存が必須になる。localStorage はブラウザ単位で閉じているため、同期はできない
  • お気に入り数を分析したい — 「どの商品がどれだけお気に入りされているか」を管理画面で見たい場合、localStorage ではデータを集計できない。アプリであれば管理画面にダッシュボードが付くものが多い
  • お気に入りを起点にメール配信したい — 値下げ通知や在庫復活通知を送るには、ユーザーと商品の紐づけをサーバーで保持する必要がある
  • 商品数が500点を超える — お気に入り件数が多い顧客がいると、一覧ページで大量の API リクエストが走る。アプリはサーバーサイドで処理するため、この問題が起きにくい

逆に、商品点数が少なく、ゲスト購入が中心の段階であれば、自前実装で十分機能する。ストアが成長してからアプリに乗り換えても、localStorage のキーを変えるだけなので移行コストは小さい。

Shopify のカスタマイズには「自前でやれること」と「仕組みの制約でできないこと」の境界がある。この判断基準を把握しておくと、実装方針で迷いにくくなる。

EC プラットフォーム自体の選定がまだ固まっていないなら、機能差の比較から入るのが近道になる。


うまくいかないとき

うまくいかないとき

ハートボタンが表示されない

  • snippets/wishlist-button.liquid のファイル名にタイプミスがないか確認する。Liquid の render タグではファイル名を拡張子なしで指定する
  • 商品カードの親要素に position: relative が付いているか確認する。付いていないとボタンが画面外に飛ぶ
  • layout/theme.liquid に CSS と JS の読み込み行を追加したか確認する。</head> の前に入っているかを見る

クリックしても色が変わらない

  • ブラウザの開発者ツール(Console タブ)にエラーが出ていないか確認する
  • テーマ側の JavaScript が商品カード全体の click イベントを処理している場合、ハートボタンのクリックが商品ページへの遷移に使われてしまうことがある。wishlist.js の stopPropagation() で防いでいるが、テーマによってはイベントの登録順序の問題で効かない場合がある。その場合は、ボタンを商品リンク(<a> タグ)の外に配置する
  • テーマエディタのプレビューモードでは JavaScript が正常に動かないことがある。 実際のストア URL で確認すること

一覧ページに商品が表示されない

  • ページのテンプレートが wishlist になっているか管理画面で確認する
  • templates/page.wishlist.json の type の値が wishlist-page(セクションのファイル名から .liquid を除いた名前)と一致しているか確認する
  • 開発者ツール → Application タブ → Local Storage で shopify_wishlist キーにデータが入っているか確認する。空配列 [] しかなければ、まだ何もお気に入りに追加されていない

価格の桁がずれる

Shopify の Product AJAX API は価格を整数値で返す。通常は「表示価格 × 100」の値になっている(例: ¥1,000 → 100000)。コード内の product.price / 100 がこの変換を行っている。

桁がずれる場合は、開発者ツールの Network タブで /products/{handle}.js のレスポンスを確認し、price の値を直接見る。100 で割ったときに正しい金額になるか検証すること。Shopify Markets を有効にしている場合、通貨ごとに返る値が変わることがある。


よくある質問

よくある質問

お気に入りのデータはいつ消えますか?

localStorage のデータは、ユーザーがブラウザの閲覧データを削除したとき、またはシークレットモードのウィンドウを閉じたときに消える。通常のブラウジングでは、明示的に削除しない限りデータは残り続ける。有効期限はない。

商品を非公開にしたらお気に入り一覧はどうなりますか?

非公開にした商品は /products/{handle}.js が 404 を返すため、一覧ページには表示されなくなる。コード内の catch ブロックがこのケースを処理しており、エラーになった商品はスキップされる。localStorage には handle が残るが、表示上は問題ない。

ページの表示速度に影響はありますか?

追加するファイルは JavaScript が約2KB、CSS が約1KB。外部ライブラリへの依存はない。アプリを導入する場合と比べると、ページ速度への影響はごくわずか。一覧ページでは商品数ぶんの API リクエストが発生するが、各リクエストは軽量なので、50件程度までは体感できる遅延は出にくい。

テーマをアップデートすると消えますか?

assets/ や snippets/ に新規追加したファイルは、テーマの自動アップデートで削除されることは基本的にない。ただし、card-product.liquid など既存ファイルに挿入した render タグの1行は、テーマ更新時に上書きで消えることがある。テーマ更新後はこの箇所を確認すること。

顧客メタフィールド方式に後から移行できますか?

できる。localStorage に保存されている handle の配列を読み取り、ログイン時に Storefront API 経由で顧客メタフィールドに書き込む処理を追加すれば、既存のお気に入りデータを引き継げる。保存キーと商品の識別子(handle)は共通なので、移行のハードルは低い。


まとめ

Shopify のお気に入り(ウィッシュリスト)機能は、localStorage と Product AJAX API を組み合わせれば、アプリなしで実装できる。今回作成したファイルは次の5つ(ヘッダーアイコンを含めると6つ)。

ファイル 役割
assets/wishlist.js お気に入りの追加・削除・状態管理
assets/wishlist.css ハートボタンのスタイル
snippets/wishlist-button.liquid 商品カードに埋め込むボタン
sections/wishlist-page.liquid 一覧ページのセクション
templates/page.wishlist.json 一覧ページのテンプレート定義
snippets/wishlist-header-link.liquid ヘッダーのアイコン(任意)

既存ファイルへの変更は、layout/theme.liquid に CSS・JS の読み込みを2行、商品カードのスニペットに render タグを1行の、計3行だけで済む。

デバイス間同期や分析が不要な段階であれば、この構成で十分に機能する。ストアの規模が大きくなってきた段階でアプリに移行する判断は、後からでも遅くない。