Customer Account UI Extensionsで過去注文のデータを読む・書く:signal・GraphQL・メタフィールドの使い分け【実践チュートリアル】

この記事の内容は、2026年8月にShopify CLI 3.90とapi_version 2026-04で、New Customer Accountsを有効にして認可を済ませた検証用ストアで確認したものです。公式ドキュメントにある仕様はリンクを添え、公式に記載のない挙動は「2026年8月時点で検証ストアで確認」と項目ごとに区別して書いています。

はじめに:データアクセスの三層

Customer Account UI Extensions(以下UIE)で過去注文の情報をマイページに表示するとき、最初に決めるのはデータの取得経路です。表示中の注文をその場で読めればよいのか、Shopifyに問い合わせるのか、アプリ独自の値を保存して読み書きするのかで、使う仕組みが変わります。

取得経路は、signal、Customer Account API、メタフィールドの三層に分けて考えます。

向く用途 特徴
signal(shopify.*グローバル) 表示中の注文、明細、属性をその場で読む プリロード済みで、追加の通信が要りません
Customer Account API(GraphQL) signalに無いShopifyデータの問い合わせ 認証は自動で、スキーマ検証ができ、必要な項目だけを選べます
メタフィールド 型を持つ独自データの永続化 読み取りは事前の設定が前提で、書き込みにはscopeと承認が要ります

三層のどれか一つを調べても、ほかの層から取れるデータまでは判断できません。GraphQLのスキーマに無いデータがsignalからは読める例があり、逆に型定義に載っているフィールドがランタイムで届かない例もあります。どちらも検証ストアで確かめた挙動で、前者は公式のスキーマからも裏が取れます。スキーマの記載だけで値が返るかどうかは判断できないため、signal、GraphQL、メタフィールドごとに実値を確かめます。スキーマに無いデータの例は2節、型定義にあるのに届かない例は5節で扱います。

題材は、テーマ時代のマイページで保存していた過去注文のラインアイテムプロパティです。これを新しいマイページで読み、必要なら顧客の入力をメタフィールドへ書き戻すところまでを確かめていきます。

1. ラインアイテムプロパティの読み取り経路

テーマで作るマイページでは、注文ごとのカスタマイズ情報、たとえば顧客が選んだオプションや入力した値を、ラインアイテムプロパティに保存する作りが定番でした。新しいお客様アカウントへ移行するとき、最初に気になるのがこの過去データを新マイページから読めるかどうかです。

検証ストアで試すと、過去注文のラインアイテムプロパティはsignalから読めました。表示のためにデータを移し替える必要はありません。注文詳細系のtargetでは明細一覧のsignalであるshopify.linesから、明細単位のtargetではshopify.targetから明細を取り出せ、各明細のattributesにプロパティが入っています。signalの所在は公式の記載どおりです。

shopify.linesを読んで、明細ごとにプロパティのキーと値をそのまま並べると、最小の形は次のようになります。

import '@shopify/ui-extensions/preact';
import {render} from 'preact';

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

function App() {
  const lines = shopify.lines.value;

  return (
    <s-stack>
      {lines.map((line) => (
        <s-stack key={line.id}>
          {line.attributes.map(({key, value}) => (
            <s-text key={key}>{key}: {value}</s-text>
          ))}
        </s-stack>
      ))}
    </s-stack>
  );
}

アンダースコア始まりのキーと取得に要るもの

storefrontには、_で始まるラインアイテムプロパティを購入者向けの表示から隠す慣習があります。しかし、UIEのsignalでは隠されません。検証では、_で始まるキーを含む全項目を読めました。追加のaccess scopeの申請もGraphQLクエリも不要で、プリロード済みのsignalを読むだけです。

内部用のキーをそのまま画面に出しても購入者には意味が伝わらないので、表示するときは、見せたいキーと表示名を明示した対応表を通します。

保存した経路が複数あるストアでは、キーの体系が併存していることがあります。たとえば、注文時のフォームからは_option_CODE123のようなコード付きのキー、別の経路からは_Optionのような名前のキーで保存されていて、両者の対応が単純な小文字化では吸収できない、といった状態です。表示側で分岐を書いて吸収するより、逆引き表として明示しておくほうが、後から読む人にもキーが分かれた経緯が伝わります。

propertyLabelsに載っているキーだけを表示用のラベルへ置き換え、載っていないキーは表示対象から外します。内部用のキーは対応表に載せないので、画面には出ません。

const propertyLabels = {
  _Option_Color: '色',
  _Option_Size: 'サイズ',
  _Custom_Text: '入力したテキスト',
};

function visibleProperties(attributes) {
  return attributes
    .filter(({key}) => propertyLabels[key])
    .map(({key, value}) => ({label: propertyLabels[key], value}));
}

空値プロパティの欠落と正規化

ラインアイテムプロパティに空値を保存しても、signalではその項目自体が欠落します。28項目のプロパティを持つ注文で試すと、空値の9項目が落ち、19項目だけが返りました。この挙動は公式ドキュメントに記載がなく、2026年8月に検証ストアで確認したものです。

空値が欠落する以上、attributesの配列長や配列位置をフォーム定義と対応づける実装は使えません。空の項目が1つあるだけで、それ以降の項目が前へずれるためです。代わりに、必要なキーの一覧を正本として持ち、見つからないキーを空として扱う正規化を挟みます。

const expectedKeys = ['_Option_Color', '_Option_Size', '_Custom_Text'];

function normalizeAttributes(attributes) {
  const values = new Map(attributes.map(({key, value}) => [key, value]));
  return Object.fromEntries(
    expectedKeys.map((key) => [key, values.get(key) ?? '']),
  );
}

expectedKeysを正本にして、signalのattributesをMapに変換し、見つからないキーには空文字を入れたオブジェクトを返します。signalから受け取ったattributesは、表示、入力の初期値、再注文用データの生成に使う前に一度だけ正規化します。読む場所ごとに欠落処理を書くと、同じ注文が場所によって違う形になるためです。

ただし、この正規化には限界があります。「キーが無ければ空」と決めた時点で、「明示的に空で保存された」ことと「そもそも未入力だった」ことの区別が失われます。signalの時点で空値の項目が落ちているので、届いた配列だけでは両者が同じ「キー無し」に見えるためです。その区別が要るデータなら、ラインアイテムプロパティよりメタフィールドのようにスキーマを持つ置き場が向いています。メタフィールドの読み書きは3節と4節で扱います。

注文レベルのattributesとの違い

注文レベルの属性はshopify.attributesから読みます。テーマ時代のnote_attributesに相当するもので、所在は公式の記載どおりです。こちらは明細のプロパティと違い、空値でもそのまま返ってきます。

明細のプロパティも注文の属性も、どちらも「attributes」という名前で届くのに、空値の扱いは非対称です。この非対称は公式ドキュメントに記載がなく、2026年8月に検証ストアで確認しました。ラインアイテム側の「欠落する」というルールを注文側に流用すると、空文字が入った項目を欠落として扱えず、読み方がずれます。明細と注文のどちらも、キーで検索して既定値を補う読み方にしておけば、同じ形の関数で済みます。

テスト用の注文には、「空値を保存した注文」と「キー自体が無い注文」の両方を含めておきます。片方だけだと、欠落と空値のどちらを見ているのか区別がつかないためです。

2. signalとGraphQLで取得できるデータの差

Customer Account APIのOrderに無いフィールド

Customer Account APIのOrderオブジェクトには、customAttributesattributesに相当するフィールドもスキーマ上存在しません。Orderにあるのはmetafieldmetafieldsnoteです。一方、Admin APIのOrderにはcustomAttributesがあります。

Admin APIと同じ感覚でクエリを組むと、存在しないフィールドを指定してスキーマ検証で弾かれます。検証ストアでも同じエラーになりました。ところが同じ注文レベルの属性は、signalのshopify.attributesからは取れます。

UIEで扱うデータは、GraphQLで取れるもの、signalでだけ取れるもの、どちらでも取れるものが混在しています。GraphQLのスキーマだけを見て「取れない」と判断すると、signalにあるデータを見落とすことになります。

スキーマの有無と実値を確かめる順序

確認は2段に分けます。実装前には使うAPIのスキーマを調べ、存在しないフィールドを候補から外し、必要なscopeを確認します。実装後は実際の注文を使い、signalとGraphQLから届く値を突き合わせます。

確認を2段階に分けるのは、スキーマの記載と実際に届く値が一致しないことがあるためです。この節のcustomAttributesのようにスキーマに無くてもsignalから取れるものがあり、反対に型定義にあってもsignalへ届かなかった場面は5節で扱います。

GraphQLは、signalに無いShopifyデータを問い合わせる層として使います。signalで足りる属性をGraphQLで取り直す必要はなく、先にtargetのsignalを観察しておけば、通信もscopeもエラー処理も増やさずに済みます。

3. メタフィールドを読む:toml宣言とaccess設定

会員ランク、注文の進捗ステータス、お手入れ情報のようなアプリ独自のデータは、メタフィールドに置くのが公式ドキュメントの推奨です。UIEから読むには、extension側の宣言とメタフィールド定義側のaccess設定が両方必要で、片方だけでは値が読めません。

1つ目はextension側の設定で、shopify.extension.tomlに読むメタフィールドを宣言します。次の宣言で、custom.customization_profileが読み取りの対象に加わります。

[[extensions.metafields]]
namespace = "custom"
key = "customization_profile"

2つ目はメタフィールド定義側の設定で、定義のaccess.customerAccountREADにします。既定はNONEで、Admin APIのmetafieldDefinitionUpdateで変更できます。adminやstorefrontのアクセス設定には影響しません。

検証では、メタフィールド定義側のaccess.customerAccount設定を見落としていました。tomlに宣言してあるのに値が来ず、「APIの制約で読めない」と誤診しかけたところで定義側を確認すると、NONEのままでした。値が来ないときは、tomlの宣言、定義のnamespaceとkey、access.customerAccount、対象のownerの順で確かめると、extension側と定義側を混同せずに切り分けられます。

order-status系のtargetでは、宣言済みのメタフィールドがshopify.appMetafieldsとしてプリロードされており、ネットワークの往復なしで読めます。読むときはnamespace、key、ownerで対象を選び、配列の位置には依存しない形にします。

function CustomizationSummary() {
  const entry = shopify.appMetafields.value.find(
    ({metafield}) =>
      metafield.namespace === 'custom' &&
      metafield.key === 'customization_profile',
  );

  if (!entry) {
    return <s-text>保存済みの設定はありません。</s-text>;
  }

  const profile = JSON.parse(entry.metafield.value);
  return <s-text>保存済み項目: {Object.keys(profile).length}件</s-text>;
}

namespaceとkeyが一致する項目が無ければ未保存として表示し、あればJSONとして解釈して項目数を出す例です。

api_version 2026-04では、従来のcheckout metafieldsがorder metafields(appMetafields)に置き換わっています。古い実装を引き継ぐ場合の手順は、移行ガイドにまとまっています。

4. メタフィールドに書く:metafieldsSetで顧客の入力を保存する

書き込みもUIEから直接できます。使うのはCustomer Account APIのmetafieldsSet mutationで、対象はCustomer、Order、Company、CompanyLocationです。API 2024-07以降はすべてのtargetで利用できます。認証はUIEの実行環境が扱うため、認証情報をextensionのコードに埋め込む必要はありません。

書き込みには、2つの権限が要ります。アプリにcustomer_read_customerscustomer_write_customersのaccess scopeがあることと、protected customer data accessの承認を受けていることです。後者は顧客データを扱うアプリに共通する要件です。

顧客が入力したカスタマイズ情報を顧客メタフィールドへ保存するフォームを例にします。検証ストアではこの形で保存まで通りました。

import '@shopify/ui-extensions/preact';
import {render} from 'preact';
import {useState} from 'preact/hooks';

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

const mutation = `#graphql
  mutation SaveCustomization($metafields: [MetafieldsSetInput!]!) {
    metafieldsSet(metafields: $metafields) {
      metafields { id namespace key value }
      userErrors { field message code }
    }
  }
`;

function CustomizationForm() {
  const [size, setSize] = useState('');
  const [message, setMessage] = useState('');

  async function save() {
    setMessage('保存しています。');

    const response = await fetch(
      'shopify://customer-account/api/2026-04/graphql.json',
      {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({
          query: mutation,
          variables: {
            metafields: [{
              ownerId: shopify.customer.value.id,
              namespace: 'custom',
              key: 'customization_profile',
              type: 'json',
              value: JSON.stringify({size}),
            }],
          },
        }),
      },
    );

    const result = await response.json();
    const payload = result.data?.metafieldsSet;
    const errors = [...(result.errors ?? []), ...(payload?.userErrors ?? [])];

    setMessage(errors.length ? errors[0].message : '保存しました。');
  }

  return (
    <s-stack>
      <s-text-field
        label="サイズ"
        value={size}
        onInput={(event) => setSize(event.currentTarget.value)}
      />
      <s-button onClick={save}>保存する</s-button>
      <s-text>{message}</s-text>
    </s-stack>
  );
}

s-text-fieldで受け取ったサイズをJSONにまとめ、shopify://customer-account/api/2026-04/graphql.jsonへPOSTしてmetafieldsSetを実行します。ownerIdshopify.customer.value.idを渡しているので、書き込み先はログイン中の顧客です。

保存の成否は、HTTPの結果だけでは判定できません。HTTPが成功していても、GraphQLのトップレベルのerrorsmetafieldsSet.userErrorsにエラーが入っていることがあるため、上のコードでは両方を連結してから判定しています。送信前に単位、範囲、必須項目を検証し、保存中はボタンを無効にして二重送信を防ぎます。

注文メタフィールドへ書く場合も、mutationは同じです。ownerIdを対象のOrderのグローバルIDに変えるだけで、書き込み先が注文に切り替わります。

UIEからCustomer Account APIを呼ぶため、別のバックエンドを用意せずに顧客の入力を保存できます。ふりがな、サイズ情報、配送の好みのような顧客単位のデータなら、UIE単体で読み書きが完結します。プロフィールでニックネームを収集する公式チュートリアルが、そのまま雛形になります。

5. データまわりでつまずいた挙動と対処

型定義にあるフィールドが届かない場面

型定義に存在するフィールドを前提に分岐を書いたところ、対象の商品が通常商品として表示されました。検証で使っていたのはmerchandise.product.productTypeで、この値で種別を判定する分岐が通常商品側へ落ちていました。

実行中のtargetでsignalの実値を見ると、このフィールドが入らない場面がありました。後日、表示異常の真因は別のクラッシュだったと分かったので、型とランタイムの不一致が原因だったとは断定できません。ただし、型があるから値も来るという前提は、この時点で外しました。同じ確認をするなら、複数の過去注文で試し、常に欠けるのか特定の条件だけで欠けるのかを分けておくと、切り分けが早くなります。

productTypeが届かない場合に備え、商品種別だけに依存せず、対象の商品だけが持つ_始まりのプロパティキーでも判定します。

function isCustomizable(line) {
  const byProductType = line.merchandise.product?.productType === 'customizable';
  const byProperties = line.attributes.some(({key}) =>
    ['_Option_Color', '_Option_Size', '_Custom_Text'].includes(key),
  );
  return byProductType || byProperties;
}

productTypeによる判定と、対象商品に固有のプロパティキーを持つかどうかの判定を並べ、どちらかが成り立てばカスタマイズ対象とみなします。片方で値が来なくても、もう片方が残る形です。

複数のmodalを置いたときの描画

数量分に展開した明細ブロックのそれぞれに<s-modal>を持たせたところ、2つ目以降のブロックがDOMに描画されませんでした。ロジック自体は動いていて、fetchは明細の数だけ飛びます。エラーも警告も出ません。

fetchが飛んでいるからと描画も成功していると判断すると気づけないので、各ブロックがDOMに存在するかを直接見る必要があります。modalを一時的に外すと全ブロックが現れたので、データ取得ではなくmodalの構造が原因だと切り分けられました。1つのextensionに描画できるmodalは1つ、という制約は公式ドキュメントに記載がなく、2026年8月に検証ストアで確認したものです。

modalはextension全体で1つだけ描画し、どの明細を開いているかをstateに持って中身を切り替えます。

function OrderLines({lines}) {
  const [selected, setSelected] = useState(null);

  return (
    <>
      {lines.map((line) => (
        <s-button key={line.id} onClick={() => setSelected(line)}>
          {line.merchandise.title}の詳細
        </s-button>
      ))}
      <s-modal heading="カスタマイズの詳細">
        {selected ? <LineDetails line={selected} /> : null}
      </s-modal>
    </>
  );
}

明細ごとにはボタンだけを並べ、押された明細をselectedに入れて、一覧の外に置いた1つの<s-modal>の中身を切り替えます。fetchが実行されてもUIが描画されない場合は、データに加えてWeb Componentsの配置も確認します。

6. 設計指針:注文時点の値と現在の値の持ち方

顧客が更新していく値、たとえばカスタマイズ情報を1箇所だけに保存すると、過去注文に必要な当時の値と現在の値が混ざります。

保存先 意味 主な用途
注文メタフィールド その注文時点のスナップショット(不変) 過去注文の表示、当時の内容での再注文
顧客メタフィールド 現在の最新値 新規注文の初期値、顧客単位の活用

顧客メタフィールドだけに情報を持たせると、顧客が値を更新した後に過去注文を再注文した際、当時とは異なる内容になります。時点そのものに業務上の意味があるため、「マスタは1箇所」という原則には例外を設け、注文時点のスナップショットと現在の最新値を分けて持ちます。

既存の過去注文では、ラインアイテムプロパティがそのままスナップショットとして働きます。読み取り側に優先順位のフォールバックを持たせておけば、一括移行の完了を待たずに新しい仕組みへ寄せられます。

  1. 注文メタフィールドがあればそれを読む
  2. 無ければラインアイテムプロパティを正規化して読む
  3. どちらも無ければ空状態を表示する

次に読む

データを読めるようになったら、次はその値を操作へ渡します。標準のBuy againではできない商品単位の再注文を、ここで読んだプロパティを引き継ぎながら実装する流れは、Shopifyのマイページに「この商品だけ再注文」ボタンを作る:cart permalinkと遷移制約の実装で扱います。

WRITTEN BY

キツネ

Shopify Developer

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

話を聞いてみる
BACK TO TECH INSIGHTS