Shopifyのマイページに「この商品だけ再注文」ボタンを作る:cart permalinkと遷移制約の実装【実践チュートリアル】

2026年8月時点、Shopify CLI 3.90、api_version 2026-04の環境で検証した内容です。公式ドキュメントで確認できる仕様にはリンクを添え、公式に記載がなく検証ストアで確認した挙動とは区別して書いています。

はじめに:標準のBuy againと明細単位の再注文の差

Shopifyの新しいお客様アカウントには、過去の注文をもう一度買う「Buy again(再購入)」が標準で付いています(ヘルプセンター)。この標準機能で要件が足りるかを検証ストアで試したところ、期待していた動きと2点で違いました。

Buy againが複製するのは注文全体です。公式のorder actionsはReorderを「Add all items from a previous order back to the cart」と定義し、ヘルプセンターの説明も「過去の注文を複製して再注文(duplicating a past order)」で、単位はどちらも注文になります。3商品が入った注文から1商品だけを選んでカートへ戻す導線は、標準にはありません。

もう一つ、ラインアイテムプロパティも引き継がれません。顧客が選んだオプションや入力値をラインアイテムプロパティに保存しているストアでは、Buy againで作られたカートが「カスタマイズ情報が空の注文」になり、当時の仕様が抜け落ちたまま購入へ進む形です。明細ごとの導線がないことと、プロパティが引き継がれないことは、どちらも2026年8月に検証ストアの実機で確認した挙動で、後者は公式ドキュメントに記載がありません。

標準の注文詳細画面。「再購入」ボタンは注文全体に1つだけで、明細ごとには押せない(2026年8月、検証ストア)

「この商品だけ、当時の仕様のまま再注文したい」という要望は、マイページに独自機能を組み込む動機として代表的なものです。公式開発者フォーラムでも繰り返し聞かれている質問ですが、まとまった答えはこれまでありませんでした。Customer Account UI Extensions(以下UIE)で、経路の比較からそのまま動くコードの全文までを扱います。

1. UI Extensionsからカートに入れる3つの経路

UIEのサンドボックスからカートへ商品を入れる経路として、検証ストアでは3つの方法を試しました。評価したのは、カートに入るか、公式に仕様を確認できるか、カートページに着地できるか、の3点です。

経路 公式仕様の裏付け カートページへの着地 判断
Storefront APIのcartCreate UIEからのカート投入用途としては明記なし 不可(チェックアウト直行) 不採用
/cart/addのGET 公式ドキュメントに記載なし 可能とされるが非公式 不採用
cart permalink 公式ドキュメントあり storefront=trueで可能 採用

Storefront APIのcartCreate:返ってくるcheckoutUrlの行き先

UIEにはStorefront APIを呼ぶcapability(api_access)があり、cartCreate mutationを使えばカートの作成自体はできます。ただし、UIEからこのmutationをカート投入に使う方法は、参照した公式資料に明記されていません。

実際にcartCreateを試すと、戻り値のcheckoutUrlを開いた先は仕様どおりチェックアウトで、return_toを付けても商品入りのカートページには戻れませんでした。「カートで内容を確認してから購入へ進む」という要件に合わないため、この経路は採用しませんでした。

/cart/addのGET:公式ドキュメントにない経路

テーマ開発の経験があれば、/cart/addをGETで呼ぶ方法が先に思い浮かびます。この経路は公式ドキュメントに記載がありません。現在は動作しても将来同じ挙動が続く根拠を公式資料で確認できないため、この経路は見送りました。

cart permalink:公式に仕様を確認できる経路

検討した経路のうち、公式に仕様を確認でき、要件も満たすのはcart permalinkだけでした(公式ドキュメント)。URLを組み立てるだけで成立し、API呼び出しも認証も要りません。5章で扱う遷移制約でもstorefrontへ移るにはhrefを使うため、cart permalinkなら同じリンクに商品情報と遷移先をまとめられます。

標準のBuy againと同じ経路の再現も試しましたが、Buy againはcart_link_idという公開されていない内部機構で動いており、アプリからは再現できません。チェックアウト用extensionのapplyCartLinesChangeに相当するカート操作APIも、2026-04のcustomer-account系API一覧では確認できませんでした。コミュニティでも同趣旨の報告が上がっています。

2. cart permalinkの仕様:properties、storefront=true、25個の上限

cart permalinkは、パスにバリアントIDと数量、クエリにプロパティと着地先を持つ次の形式のURLです(公式ドキュメント)。

https://{ストアのドメイン}/cart/{variantId}:{数量}?properties={Base64URLエンコードしたJSON}&storefront=true

クエリのうちstorefront=trueは、チェックアウトへ直行させずにカートページへ着地させる公式のパラメータで、購入者は投入された商品とプロパティをカートで確認してから先へ進めます。propertiesには、ラインアイテムプロパティをJSONにしてBase64 URLエンコードした文字列を渡せますが、指定できるプロパティは25個までです。

公式に明記された制約は、ほかに2つあります。selling plan(販売プラン)には対応しておらず、サブスクリプション商品には使えません。storefrontのパスワード保護も越えられず、公開前のストアで検証するときに引っかかる点です。実際に何が起きるかは4章で扱います。

パスのvariantIdは数値で、signalが返すgid://shopify/ProductVariant/...形式のIDから末尾の数値部分を取り出して使います。この変換は完成形のコードにあります。

3. 日本語プロパティのエンコード:TextEncoderでUTF-8にしてからBase64URLへ

日本語を含むpropertiesをそのままエンコードすると、例外になります。ブラウザ標準のbtoa()が扱えるのがLatin-1の範囲の文字だけで、これはShopifyではなくWeb標準側の制約です。

対処は、TextEncoderで先にUTF-8のバイト列にしてからエンコードすることです。この手順にすると、検証ストアでは日本語のプロパティも通りました。次のコードは、その変換をencodeBase64Urlという関数にまとめ、色とメッセージの2項目をエンコードする例です。

/* 日本語対応の Base64 URL エンコード */
function encodeBase64Url(value) {
  const bytes = new TextEncoder().encode(value);   // UTF-8 バイト列に
  let binary = '';
  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');   // URL セーフ化と padding の除去
}

const properties = {
  '_color': 'ネイビー',
  '_message': '母の日ギフト、のし付き',
};

const encoded = encodeBase64Url(JSON.stringify(properties));

Base64URLは暗号化ではないため、ここで運ぶ値は過去注文の表示として購入者自身にすでに見えている値だけに限り、秘密にすべき情報はこの経路に載せない前提で設計します。

4. 販売プラン(サブスクリプション)商品の扱い

selling planへの非対応は、公式ドキュメントに明記された制約です。販売プランが必須のバリアントにpermalinkでアクセスすると、HTTP 410の「カートのエラー」画面が返ります。2026年8月にストアの実機で見た画面のメッセージは次のとおりです。

カートのエラー: バリエーションは販売プランでのみ購入できます。

サブスクリプション商品はcart permalinkでカートに追加できないため、同じ再注文ボタンでは扱えません。判定を入れずにボタンを出すと購入者がこのエラー画面に着地して終わるため、対象商品が販売プランを持つかを判定し、サブスクリプション商品は定期購入の管理画面への誘導など別の導線に分けます。

5. 遷移の制約を設計に織り込む

再注文ボタンを押した先はstorefrontのカートページで、UIEの外にあります。アカウント内とその外とで使える遷移の手段が違うため、先にその制約を確かめます。

Navigation APIの行き先とstorefrontへ渡る手段

アカウント内のページへ移るにはNavigation API(shopify.navigation.navigate)を使え、block系のtargetからも呼べます(公式ドキュメント)。ただし行き先はアカウント内に限られ、使えるプロトコルはshopify:customer-account/...(組み込みページ)とextension://...(拡張のルート)の2つだけで、公式ドキュメントも「It can't redirect to external URLs or the storefront.」と明記しています。

storefront(カートページ)へ渡る手段は、リンクのhrefです。ボタンらしい見た目が要るなら<s-button href="...">で実リンクにします。onClickのハンドラの中でカートへ遷移する形は、Navigation APIがstorefrontへ行けない以上、組めません。

hrefで遷移するときのURLの決め方

hrefで遷移する以上、リンクが押せる状態になった時点で遷移先のURLが確定していなければなりません。ボタンを押してからAPIを呼び、返ってきたURLへ飛ぶ、という直列の処理は組めないということです。

検証では2つの設計パターンを使いました。一つは事前構築で、描画の時点でsignalからプロパティを読み、permalinkを組み立ててhrefに設定しておきます。もう一つはモーダルで裏準備するパターンで、最初の操作でモーダルを開き、ユーザーが内容を確認している間に裏でURLを組み立て、モーダル内のリンクに設定します。後者を使うのは、外部APIの判定を挟む必要があるときです。

明細単位の再注文ボタンでは外部APIを使わず、プリロード済みのsignalから同期的にcart permalinkを組み立てられます。描画時にhrefを確定できるため、バックエンドも要らず、遷移制約にも収まります。

6. マイページとstorefrontのオリジン:URLパラメータでデータを渡す

テーマ側にあった再注文機能をUIEへ移すと、sessionStorageを使った引き継ぎが切れることがあります。マイページはcustomer accountのドメインで動き、storefrontとは別オリジンのため、sessionStorageを共有できないことは検証ストアで確認済みです。テーマで「再注文情報をsessionStorage経由で商品ページへ引き継ぐ」形に作っていた実装は、そのままでは動きません。

状態を持たず、URLパラメータで渡すと、次の3段の流れになります。

UIE 側: /collections/...?payload={Base64URL(JSON)} を href に設定
  ↓
storefront 側: 着地ページのスクリプトが payload を読み、
              従来と同じ sessionStorage キーへ載せ替える
  ↓
テーマ側: 既存の復元ロジックが同じキーから読む(無改修)

このパターンは、内容を選び直して注文したい購入者を商品ページへ戻す導線に使うもので、同じ内容のままcart permalinkでカートへ入れる導線とは別です。

URLに載せる量は、20項目のJSONでもBase64後で800文字程度です。URL長の制限内に収まるので、サーバー側に状態を持たずに済みます。受け口を既存実装と同じstorageキーに合わせておくと、テーマ側の改修は着地ページの1箇所に閉じます。

検証では、受け口のスクリプトを置くテンプレートを一度取り違えました。URLの見た目から推測して置いたところ、コレクションのhandleとテンプレートのsuffixが交差しているケースに当たったためです。受け口のスクリプトを置く前に、対象コレクションのtemplateSuffixをAPIで確認します。

7. 完成形:明細ごとの再注文ボタン(コード全文)

完成形は、注文詳細ページの各明細に「この商品だけ再注文」ボタンを出すextensionです。signalからプロパティを読み、permalinkを事前に組み立ててhrefに設定します。バリデーションに失敗したときは、誤った仕様のまま注文が入るのを避けるため、hrefを生成せずボタンも表示しないfail-closedの設計です。

設定ファイルでは、targetにcustomer-account.order-status.cart-line-item.render-afterを指定し、各明細の後に./src/ReorderButton.jsxを描画します。

# shopify.extension.toml
api_version = "2026-04"

[[extensions]]
type = "ui_extension"
name = "Reorder line item"
handle = "reorder-line-item"

  [[extensions.targeting]]
  target = "customer-account.order-status.cart-line-item.render-after"
  module = "./src/ReorderButton.jsx"

本体は@shopify/ui-extensions/preactとpreactのrenderを読み込み、MAX_PROPERTIES = 25を上限に、明細のsignalからpermalinkを組み立ててhrefか出せない理由のどちらかを返す構成です。

// src/ReorderButton.jsx
import '@shopify/ui-extensions/preact';
import {render} from 'preact';

const MAX_PROPERTIES = 25;

function encodeBase64Url(value) {
  const bytes = new TextEncoder().encode(value);
  let binary = '';
  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

function numericVariantId(gid) {
  if (typeof gid !== 'string') return null;
  const match = gid.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)$/);
  return match?.[1] ?? null;
}

function collectProperties(attributes) {
  // 空値プロパティは signal から欠落する(実測)。届いた値だけを引き継ぐ
  return (attributes ?? [])
    .filter(({key, value}) => key && value !== '' && value != null)
    .map(({key, value}) => [key, String(value)]);
}

function buildCartPermalink(line, shopUrl) {
  const variantId = numericVariantId(line?.merchandise?.id);
  const entries = collectProperties(line?.attributes);

  if (!variantId || !shopUrl) {
    return {href: null, reason: 'この明細の商品情報を取得できませんでした。'};
  }
  if (entries.length > MAX_PROPERTIES) {
    // 先頭25個だけ送ると購入者が気づかないまま仕様が欠落する。塞ぐ側に倒す
    return {href: null, reason: '仕様の項目数が上限を超えるため再注文できません。'};
  }

  const query = [];
  if (entries.length > 0) {
    query.push(`properties=${encodeBase64Url(JSON.stringify(Object.fromEntries(entries)))}`);
  }
  query.push('storefront=true');

  return {
    href: `${shopUrl}/cart/${variantId}:1?${query.join('&')}`,
    reason: null,
  };
}

function ReorderButton() {
  const line = shopify.target.value;               // この明細(signal)
  const shopUrl = shopify.shop.storefrontUrl;      // storefront の絶対 URL(Shop API・signal ではない)
  const {href, reason} = buildCartPermalink(line, shopUrl);

  if (!href) {
    return <s-text>{reason}</s-text>;
  }

  return <s-button href={href}>この商品だけ再注文</s-button>;
}

export default async () => {
  render(<ReorderButton />, document.body);
};

空値のプロパティがsignalから欠落することは検証で確認しており、collectPropertiesは届いた値だけを引き継ぎます。

hrefを出さない分岐は2つあります。バリアントIDかstorefrontのURLが取れないときはこの明細の商品情報を取得できませんでした。を出します。プロパティが25個を超えるときは仕様の項目数が上限を超えるため再注文できません。で、先頭25個だけ送ると購入者が気づかないまま仕様が欠落するため、hrefを生成せずに理由だけを表示します。

URLを組み立てられたときは、${shopUrl}/cart/${variantId}:1?properties=...&storefront=trueをhrefに設定したこの商品だけ再注文ボタンを表示します。propertiesはentriesが0件なら省略し、storefront=trueは常に付けます。

URLはstorefrontの絶対URLで組み立てます。UIEはstorefrontと別オリジンのcustomer accountドメインで動くので、/cart/...のような相対パスは意図しない先に解決される恐れがあるためです。絶対URLはshopify.shop.storefrontUrlから取ります。これはShop APIの値で、signalではありません。明細のほうはsignalのshopify.target.valueから読みます。

数量は1固定。当時と同じ数量にするならline.quantityを使い、同数量にするか1点ずつにするかは商材で選ぶところです。

在庫や販売状態は、このコードでは判定していません。バリアントが販売終了している場合はカートエラー画面へ移るため、必要に応じてボタンを出す前に販売状態を確認します。判定処理を加える場合も、結果を取得できなければボタンを表示しません。外部への問い合わせを含む実装はCustomer Account UI Extensionsから外部APIを呼ぶ:network_access・CORS・実データでの検証手法で扱います。

動作確認の場は、ビルド結果ではなくストアの実機カートです。日本語を含むプロパティ、複数数量、プロパティなし、25個ちょうど、25個超、販売プラン商品の6ケースを分けて試します。ビルドが成功しても、着地先とカートの内容が正しいとは限りません。

次に読む

外部システムが持つ情報を判定に使いたくなると、UIEから外部APIを直接呼ぶことになります。Customer Account UI Extensionsから外部APIを呼ぶ:network_access・CORS・実データでの検証手法では、そのときCORSで実際に何が起きるかと、機能全体を実データで検証する方法を扱います。完成形にそうした判定を足すなら、外部APIの呼び出しと実データでの検証が前提です。

WRITTEN BY

キツネ

Shopify Developer

デザインとエンジニアリングの両輪で、Shopifyのストア構築・技術支援に取り組んでいます。2019年頃からShopifyでのEC制作を続け、これまで累計100件以上のストアに携わってきました。既存のアプリだけでは実現が難しい独自要件を、設計と開発で形にすることを大切にしています。現場で得た知見を発信しています。

話を聞いてみる
BACK TO TECH INSIGHTS