自分のブランドを持つ個人クリエイターや小規模事業者にとって、「Shopify でウェブを作ったはいいが、モバイルアプリが欲しい」という場面は想像以上に多いはずです。ウェブのコンバージョン率が2〜3%のところ、専用アプリでは5〜8%を超えることも珍しくありません。
Rork を使えば、Shopify のバックエンドをそのまま活かしながら、モバイルアプリを個人で開発できます。ただし、Shopify Storefront API は独特の GraphQL 構造を持ち、カート管理や決済フロー、在庫のリアルタイム更新など、ハマりポイントが散在しています。
私が実際にブランドアプリを Rork で構築した際に詰まったポイントを正直に共有しながら、商品一覧からプッシュ通知まで一気通貫の実装を順を追って整理していきます。
Shopify Storefront API の基礎と認証の設計
Shopify には複数の API がありますが、モバイルアプリで使うのは Storefront API です。Admin API(商品登録・注文管理の管理者側)とは別物で、顧客向けの商品閲覧・カート操作・決済に特化しています。
アクセストークンの取得
Shopify パートナーダッシュボード、もしくはストアの管理画面から「カスタムアプリ」を作成し、Storefront API のアクセストークンを取得します。このトークンは 公開可能(Publicly accessible) な設計です。Admin API のキーと違い、クライアントサイドから呼び出せます。
# 必要な Storefront API スコープ(Rork のプロジェクト設定に記載)
unauthenticated_read_product_listings
unauthenticated_read_product_inventory
unauthenticated_write_checkouts
unauthenticated_read_checkouts
unauthenticated_write_customers
unauthenticated_read_customer_tagsRork のプロジェクトに環境変数として設定します:
EXPO_PUBLIC_SHOPIFY_STORE_DOMAIN=your-store.myshopify.com
EXPO_PUBLIC_SHOPIFY_STOREFRONT_TOKEN=your_storefront_access_token
GraphQL クライアントの初期化
Shopify の Storefront API は REST ではなく GraphQL です。Rork が生成するコードに fetch でそのまま組み込めます。
// lib/shopify.ts
const SHOPIFY_DOMAIN = process.env.EXPO_PUBLIC_SHOPIFY_STORE_DOMAIN;
const STOREFRONT_TOKEN = process.env.EXPO_PUBLIC_SHOPIFY_STOREFRONT_TOKEN;
const API_VERSION = "2024-01";
export async function shopifyFetch<T>({
query,
variables,
}: {
query: string;
variables?: Record<string, unknown>;
}): Promise<{ data: T; errors?: { message: string }[] }> {
const response = await fetch(
`https://${SHOPIFY_DOMAIN}/api/${API_VERSION}/graphql.json`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Storefront-Access-Token": STOREFRONT_TOKEN\!,
},
body: JSON.stringify({ query, variables }),
}
);
if (\!response.ok) {
throw new Error(`Shopify API error: ${response.status} ${response.statusText}`);
}
const json = await response.json();
// GraphQL エラーが返ってきた場合の処理
if (json.errors && json.errors.length > 0) {
throw new Error(`GraphQL errors: ${json.errors.map((e: { message: string }) => e.message).join(", ")}`);
}
return json;
}なぜこう書くか: Shopify は HTTP レベルでは 200 OK を返しつつ、レスポンスボディに errors フィールドを含めることがあります。response.ok のチェックだけでは不十分で、GraphQL エラーの確認も必須です。この2段階チェックを忘れると、「API が動いているのに商品が表示されない」という沼にはまります。
商品一覧・詳細ページの実装
商品一覧の取得
Shopify の商品は「コレクション(Collection)」という単位でグルーピングされています。アプリのトップページはコレクション別に商品を表示するのがユーザー体験的に自然です。
// hooks/useProducts.ts
import { useState, useEffect } from "react";
import { shopifyFetch } from "../lib/shopify";
interface Product {
id: string;
title: string;
handle: string;
priceRange: {
minVariantPrice: { amount: string; currencyCode: string };
};
images: {
edges: { node: { url: string; altText: string | null } }[];
};
availableForSale: boolean;
}
const PRODUCTS_QUERY = `
query GetProducts($first: Int\!, $after: String) {
products(first: $first, after: $after, sortKey: UPDATED_AT, reverse: true) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
title
handle
availableForSale
priceRange {
minVariantPrice {
amount
currencyCode
}
}
images(first: 1) {
edges {
node {
url
altText
}
}
}
}
}
}
}
`;
export function useProducts() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [cursor, setCursor] = useState<string | null>(null);
const fetchProducts = async (after?: string) => {
try {
setLoading(true);
const { data } = await shopifyFetch<{ products: { pageInfo: { hasNextPage: boolean; endCursor: string }; edges: { node: Product }[] } }>({
query: PRODUCTS_QUERY,
variables: { first: 20, after: after ?? null },
});
const newProducts = data.products.edges.map((edge) => edge.node);
setProducts((prev) =>
after ? [...prev, ...newProducts] : newProducts
);
setHasNextPage(data.products.pageInfo.hasNextPage);
setCursor(data.products.pageInfo.endCursor);
} catch (err) {
setError(err instanceof Error ? err.message : "商品の取得に失敗しました");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchProducts();
}, []);
const loadMore = () => {
if (hasNextPage && cursor) {
fetchProducts(cursor);
}
};
return { products, loading, error, hasNextPage, loadMore };
}期待する動作: 最初の20件を取得後、loadMore() を呼ぶと次の20件が追加されます。FlatList の onEndReached に loadMore を渡すと無限スクロールが完成します。
よくある間違い①:在庫切れ商品の扱い
availableForSale: false の商品をそのままリストに表示すると、カートに追加した瞬間エラーになります。フィルタリングするかグレーアウト表示にするかは、ブランドのポリシーによりますが、いずれかの対応が必要です。
// ❌ 在庫切れをそのまま表示
const displayProducts = products;
// ✅ 在庫切れをフィルタリング(または表示しつつボタンを無効化)
const displayProducts = products.filter((p) => p.availableForSale);
// または
const isDisabled = \!product.availableForSale;カート管理の実装と楽観的更新の設計
Shopify のカートは、Storefront API の cartCreate / cartLinesAdd / cartLinesUpdate / cartLinesRemove という Mutation で管理します。カート ID をローカルストレージ(AsyncStorage)に永続化するのがポイントです。
カート Context の設計
// context/CartContext.tsx
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { shopifyFetch } from "../lib/shopify";
interface CartLine {
id: string;
quantity: number;
merchandise: {
id: string;
title: string;
price: { amount: string; currencyCode: string };
product: { title: string; images: { edges: { node: { url: string } }[] } };
};
}
interface Cart {
id: string;
checkoutUrl: string;
lines: { edges: { node: CartLine }[] };
cost: {
totalAmount: { amount: string; currencyCode: string };
subtotalAmount: { amount: string; currencyCode: string };
};
}
interface CartContextType {
cart: Cart | null;
loading: boolean;
addToCart: (merchandiseId: string, quantity: number) => Promise<void>;
updateQuantity: (lineId: string, quantity: number) => Promise<void>;
removeFromCart: (lineId: string) => Promise<void>;
itemCount: number;
}
const CartContext = createContext<CartContextType | null>(null);
const CART_ID_KEY = "@shopify_cart_id";
const CREATE_CART_MUTATION = `
mutation CreateCart($lines: [CartLineInput\!]) {
cartCreate(input: { lines: $lines }) {
cart {
id
checkoutUrl
lines(first: 50) { edges { node { id quantity merchandise { id title price { amount currencyCode } product { title images(first: 1) { edges { node { url } } } } } } } }
cost { totalAmount { amount currencyCode } subtotalAmount { amount currencyCode } }
}
userErrors { field message }
}
}
`;
const ADD_LINES_MUTATION = `
mutation AddCartLines($cartId: ID\!, $lines: [CartLineInput\!]\!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
id
checkoutUrl
lines(first: 50) { edges { node { id quantity merchandise { id title price { amount currencyCode } product { title images(first: 1) { edges { node { url } } } } } } } }
cost { totalAmount { amount currencyCode } subtotalAmount { amount currencyCode } }
}
userErrors { field message }
}
}
`;
export function CartProvider({ children }: { children: React.ReactNode }) {
const [cart, setCart] = useState<Cart | null>(null);
const [loading, setLoading] = useState(false);
const addToCart = useCallback(async (merchandiseId: string, quantity: number) => {
setLoading(true);
try {
const savedCartId = await AsyncStorage.getItem(CART_ID_KEY);
if (savedCartId) {
// 既存カートに追加
const { data } = await shopifyFetch<{ cartLinesAdd: { cart: Cart; userErrors: { field: string; message: string }[] } }>({
query: ADD_LINES_MUTATION,
variables: { cartId: savedCartId, lines: [{ merchandiseId, quantity }] },
});
if (data.cartLinesAdd.userErrors.length > 0) {
throw new Error(data.cartLinesAdd.userErrors[0].message);
}
setCart(data.cartLinesAdd.cart);
} else {
// 新規カート作成
const { data } = await shopifyFetch<{ cartCreate: { cart: Cart; userErrors: { field: string; message: string }[] } }>({
query: CREATE_CART_MUTATION,
variables: { lines: [{ merchandiseId, quantity }] },
});
if (data.cartCreate.userErrors.length > 0) {
throw new Error(data.cartCreate.userErrors[0].message);
}
const newCart = data.cartCreate.cart;
await AsyncStorage.setItem(CART_ID_KEY, newCart.id);
setCart(newCart);
}
} catch (err) {
console.error("addToCart error:", err);
throw err;
} finally {
setLoading(false);
}
}, []);
const removeFromCart = useCallback(async (lineId: string) => {
if (\!cart) return;
setLoading(true);
try {
const REMOVE_MUTATION = `
mutation RemoveCartLines($cartId: ID\!, $lineIds: [ID\!]\!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
id checkoutUrl
lines(first: 50) { edges { node { id quantity merchandise { id title price { amount currencyCode } product { title images(first: 1) { edges { node { url } } } } } } } }
cost { totalAmount { amount currencyCode } subtotalAmount { amount currencyCode } }
}
userErrors { field message }
}
}
`;
const { data } = await shopifyFetch<{ cartLinesRemove: { cart: Cart } }>({
query: REMOVE_MUTATION,
variables: { cartId: cart.id, lineIds: [lineId] },
});
setCart(data.cartLinesRemove.cart);
} finally {
setLoading(false);
}
}, [cart]);
const updateQuantity = useCallback(async (lineId: string, quantity: number) => {
if (\!cart) return;
if (quantity <= 0) {
await removeFromCart(lineId);
return;
}
setLoading(true);
try {
const UPDATE_MUTATION = `
mutation UpdateCartLines($cartId: ID\!, $lines: [CartLineUpdateInput\!]\!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
id checkoutUrl
lines(first: 50) { edges { node { id quantity merchandise { id title price { amount currencyCode } product { title images(first: 1) { edges { node { url } } } } } } } }
cost { totalAmount { amount currencyCode } subtotalAmount { amount currencyCode } }
}
userErrors { field message }
}
}
`;
const { data } = await shopifyFetch<{ cartLinesUpdate: { cart: Cart } }>({
query: UPDATE_MUTATION,
variables: { cartId: cart.id, lines: [{ id: lineId, quantity }] },
});
setCart(data.cartLinesUpdate.cart);
} finally {
setLoading(false);
}
}, [cart, removeFromCart]);
const itemCount = cart?.lines.edges.reduce((sum, edge) => sum + edge.node.quantity, 0) ?? 0;
return (
<CartContext.Provider value={{ cart, loading, addToCart, updateQuantity, removeFromCart, itemCount }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (\!context) throw new Error("useCart must be used within CartProvider");
return context;
}よくある間違い②:カート ID の失効
Shopify のカートは作成から10日間で失効します。永続化したカート ID が無効になった場合、API から Cart not found エラーが返ります。このエラーをキャッチして AsyncStorage のカート ID を削除し、新規カートを作成するリカバリ処理が必要です。
// カート ID 失効時のリカバリ
async function fetchOrCreateCart(savedCartId: string) {
try {
const cart = await getCart(savedCartId);
return cart;
} catch (err) {
if (err instanceof Error && err.message.includes("Cart not found")) {
// カートが失効していたら削除して新規作成
await AsyncStorage.removeItem(CART_ID_KEY);
return null;
}
throw err;
}
}Shopify の決済フロー — Web View vs ネイティブ
Shopify の決済は2つのアプローチがあります。
アプローチ A: checkoutUrl を WebView で開く(推奨・シンプル)
cart.checkoutUrl は Shopify がホストする決済ページへの URL です。expo-web-browser または WebView で開くだけで、クレジットカード・Apple Pay・Shop Pay が使えます。
import * as WebBrowser from "expo-web-browser";
import { Alert } from "react-native";
export async function openCheckout(checkoutUrl: string) {
try {
const result = await WebBrowser.openAuthSessionAsync(
checkoutUrl,
"yourapp://checkout-complete" // Deep Link でアプリに戻る
);
if (result.type === "success") {
// 決済完了 → カートをリセット
await AsyncStorage.removeItem(CART_ID_KEY);
return { success: true };
}
return { success: false, cancelled: true };
} catch (err) {
Alert.alert("エラー", "決済ページを開けませんでした。もう一度お試しください。");
throw err;
}
}アプローチ B: Shopify Payments + ネイティブ決済(上級者向け)
@shopify/checkout-sheet-kit という公式ライブラリを使うと、アプリ内にネイティブな決済シートを表示できます。ただし Expo の managed workflow では追加設定が必要です。本番アプリでは A のアプローチから始め、コンバージョン率が伸びてきたら B への移行を検討するのが現実的です。
なぜ A を推奨するか: Apple のガイドラインでは、アプリ内で完結するデジタルコンテンツの購入には StoreKit(IAP)を使うよう求めています。ただし物理商品の販売は例外で、外部決済が認められています。Shopify のような物理商品 EC であれば WebView 決済で審査を通過できます。
注文管理とプッシュ通知の実装
顧客認証と注文履歴
Shopify Storefront API で顧客ログインするには、customerAccessTokenCreate Mutation を使います。
// 顧客ログイン
const LOGIN_MUTATION = `
mutation CustomerLogin($email: String\!, $password: String\!) {
customerAccessTokenCreate(input: { email: $email, password: $password }) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`;
export async function loginCustomer(email: string, password: string) {
const { data } = await shopifyFetch<{
customerAccessTokenCreate: {
customerAccessToken: { accessToken: string; expiresAt: string } | null;
customerUserErrors: { code: string; field: string; message: string }[];
};
}>({
query: LOGIN_MUTATION,
variables: { email, password },
});
const result = data.customerAccessTokenCreate;
if (result.customerUserErrors.length > 0) {
const error = result.customerUserErrors[0];
// エラーコードで分岐して日本語メッセージを返す
if (error.code === "UNIDENTIFIED_CUSTOMER") {
throw new Error("メールアドレスまたはパスワードが正しくありません");
}
if (error.code === "TOO_MANY_FAILED_ATTEMPTS") {
throw new Error("ログイン試行が多すぎます。しばらくしてからお試しください");
}
throw new Error(error.message);
}
if (\!result.customerAccessToken) {
throw new Error("ログインに失敗しました");
}
return result.customerAccessToken;
}取得した accessToken を Keychain(expo-secure-store)に保存し、注文履歴取得時に X-Shopify-Customer-Access-Token ヘッダーとして送ります。
プッシュ通知:発送通知の自動化
Shopify には Webhook 機能があり、注文ステータスの変更(orders/fulfilled, orders/paid 等)をサーバーに通知できます。これを Supabase Edge Functions または Cloudflare Workers で受け取り(詳細はSupabase を使ったリアルタイム認証ガイドも参照ください)、Expo Push Notification Service 経由でプッシュ通知を送ります。
// Supabase Edge Function / Cloudflare Workers
// POST /shopify-webhook
export default {
async fetch(request: Request): Promise<Response> {
const body = await request.json() as {
id: number;
email: string;
fulfillment_status: string;
shipping_address?: { name: string };
line_items: { title: string; quantity: number }[];
};
// Shopify Webhook の署名検証(必須)
const hmacHeader = request.headers.get("X-Shopify-Hmac-Sha256");
const isValid = await verifyShopifyWebhook(request, hmacHeader ?? "");
if (\!isValid) {
return new Response("Unauthorized", { status: 401 });
}
// 発送済みステータスの場合にプッシュ通知を送信
if (body.fulfillment_status === "fulfilled") {
const customerEmail = body.email;
const customerName = body.shipping_address?.name ?? "お客様";
// DB から Expo Push Token を取得
const pushToken = await getPushTokenByEmail(customerEmail);
if (pushToken) {
await sendExpoNotification({
to: pushToken,
title: "🎁 ご注文の商品を発送しました",
body: `${customerName}、ご注文の商品を発送いたしました。お届けまでしばらくお待ちください。`,
data: { orderId: body.id.toString(), screen: "OrderDetail" },
});
}
}
return new Response("OK", { status: 200 });
},
};よくある間違い③:Webhook の署名検証を省略する
開発中に「とりあえず動かす」ために署名検証を省くと、本番で偽の Webhook を受け取るリスクがあります。Shopify は HMAC-SHA256 で署名しており、数行で検証できます。省略しないことを強くお勧めします。
async function verifyShopifyWebhook(request: Request, hmacHeader: string): Promise<boolean> {
const rawBody = await request.text();
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(process.env.SHOPIFY_WEBHOOK_SECRET\!),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(rawBody));
const computedHmac = btoa(String.fromCharCode(...new Uint8Array(signature)));
return computedHmac === hmacHeader;
}プッシュトークンの管理と顧客データの紐付け
Expo のプッシュトークンと Shopify の顧客メールを紐付けるには、独自のデータベースが必要です。Supabase を使った場合のスキーマ例:
-- Supabase でのテーブル設計
create table push_tokens (
id uuid primary key default gen_random_uuid(),
customer_email text not null,
push_token text not null,
platform text not null, -- 'ios' | 'android'
created_at timestamptz default now(),
updated_at timestamptz default now(),
unique(customer_email, platform)
);アプリ側では、ログイン後にプッシュトークンを登録します:
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
export async function registerPushToken(customerEmail: string) {
// 通知許可を要求
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus \!== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus \!== "granted") {
console.log("プッシュ通知の許可が得られませんでした");
return;
}
const tokenData = await Notifications.getExpoPushTokenAsync({
projectId: process.env.EXPO_PUBLIC_PROJECT_ID,
});
// Supabase に保存(upsert で重複を防ぐ)
await supabase.from("push_tokens").upsert(
{
customer_email: customerEmail,
push_token: tokenData.data,
platform: Platform.OS,
updated_at: new Date().toISOString(),
},
{ onConflict: "customer_email,platform" }
);
}よくある実装ミスと落とし穴まとめ
実際に構築してみて遭遇したトラブルを整理します。
落とし穴①:Shopify のカートは Storefront API 専用の ID 形式
Shopify のカート ID は gid://shopify/Cart/xxxxxxxx という GraphQL グローバル ID 形式です。AsyncStorage に保存する際、この文字列をそのまま保存・使用しないと Invalid cart ID エラーが出ます。エンコードしたり変換したりは一切不要です。
落とし穴②:商品バリアント ID とメルカンダイズ ID の混同
cartLinesAdd で使う merchandiseId は、商品(Product)ではなくバリアント(ProductVariant)の ID です。商品の詳細を取得するクエリで variants { edges { node { id } } } を必ず含め、バリアントを選択してからカートに追加する設計にしてください。
落とし穴③:在庫数のリアルタイム性
Storefront API の在庫情報はリアルタイムではなく、短期キャッシュが乗っています。チェックアウト時に「在庫なし」エラーになることがあります。カートに追加する前に quantityAvailable を確認するのは有効ですが、完全な保証にはなりません。UI 上は「カートに追加しました」の後、在庫切れを checkoutUrl で知らせる設計が現実的です。
落とし穴④:checkoutUrl は一度しか使えない
checkoutUrl は作成した時点の URL であり、カートの中身が変わっても URL は変わりません。カートを更新するたびに最新のカート情報を取得し直す必要はありませんが、チェックアウト後に古い URL を再利用しようとすると Checkout is already completed エラーが返ります。決済完了後はローカルのカート ID を必ずリセットしてください。
App Store 審査対策 — EC アプリが引っかかるポイント
物理商品を扱う EC アプリは、Apple のガイドラインを正しく理解していないとリジェクトされます。以下のポイントを事前に確認してください。
審査ポイント①:外部決済の明記
Apple は物理商品の外部決済を認めていますが、「この決済はアプリ内購入ではなく外部サービスを使用します」という明示をアプリ内に求める場合があります。決済ボタンの近くに一言添えておくと安心です。
審査ポイント②:プライバシーポリシーと個人情報の扱い
メールアドレス、注文履歴、位置情報(配送先住所)を扱う場合、プライバシーポリシーへのリンクが必須です。App Store Connect のプライバシーの質問(データの種類と使用目的)にも正確に回答してください。
審査ポイント③:デモアカウントの用意
審査担当者がアプリの全機能を確認できるよう、実際に注文できるデモアカウントを用意します。test_ プレフィックスのテスト商品と専用の決済カードを使い、審査担当者がスムーズに操作できる状態にしておきましょう。
審査ポイント④:アカウント削除機能
2022年以降、アカウント作成機能があるアプリには必ずアカウント削除機能が求められます。Shopify の customerDelete Mutation または顧客サポートへの誘導画面を用意しておく必要があります。
全体を振り返って — 今日から始めるべき一歩
Shopify + Rork の組み合わせは、個人ブランド・小規模 EC にとってコスト効率の高い選択肢です。ウェブの Shopify 管理画面はそのまま使いながら、モバイルアプリという追加チャネルを個人で開発できます。
まず試してほしいのは、Shopify のパートナーダッシュボードで開発用ストアを無料作成し、Storefront API トークンを取得するところです。実際のストアがなくてもテスト商品を登録してアプリに接続できます。カートへの追加まで動かせれば、残りの決済・通知フローは本記事のコードをベースに組み立てられます。
Shopify の API ドキュメントは充実していますが、モバイルアプリ向けの情報は少なめです。この記事が、あなたのブランドアプリ開発の出発点になれば嬉しいです。
参考として、Shopify の API 設計や GraphQL の考え方を深く