shopify-apps について
Remix、Shopify App framework、Checkout UI extensions を使う Shopify アプリ開発者向けのスキルです。初期設定から認証、Admin API、テスト、デプロイまでの手順を扱います。
主な機能
- 01Remix アプリの構築と設定
- 02Shopify OAuth と Webhook
- 03GraphQL Admin API 操作
- 04Polaris UI と Checkout 拡張
- 05Prisma データベースとデプロイ
- 06テストとレート制限への対応
活用シーン
- 01Shopify Remix アプリの雛形作成
- 02Admin API のクエリと変更の実装
- 03チェックアウトとアカウントの UI 拡張
- 04GDPR Webhook とアプリ申請の準備
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ alinaqi/ maggy --skill shopify-appsインストール後、Claude Code で/shopify-appsを入力して読み込まれたか確認します。
詳細
Shopify アプリ開発スキル
Remix、Shopify アプリフレームワーク、チェックアウト UI 拡張機能を使用して Shopify アプリを構築します。
ソース: Shopify 開発ドキュメント | Shopify CLI | Admin API
前提条件
必要なアカウントとツール
# 1. Shopify Partner Account (free)
# Sign up at: https://partners.shopify.com
# 2. Development Store
# Create in Partner Dashboard → Stores → Add store → Development store
# 3. Shopify CLI
npm install -g @shopify/cli
# 4. Node.js 18.20+ or 20.10+
node --version
Partner Dashboard の設定
- partners.shopify.com で Partner アカウントを作成します
- テスト用の開発ストアを作成します
- Partner Dashboard → Apps → Create app でアプリを作成します
- API キーと API シークレットを控えます
クイックスタート
新しいアプリのスキャフォールド
# Create new Shopify app with Remix
shopify app init
# Answer prompts:
# - App name
# - Template: Remix (recommended)
# - Language: JavaScript or TypeScript
# Start development
cd your-app-name
shopify app dev
プロジェクト構成
shopify-app/
├── app/
│ ├── routes/
│ │ ├── app._index/ # Main app page
│ │ │ └── route.jsx
│ │ ├── app.jsx # App layout with Polaris
│ │ ├── auth.$.jsx # Auth catch-all
│ │ ├── auth.login/ # Login page
│ │ │ └── route.jsx
│ │ ├── webhooks.app.uninstalled.jsx
│ │ ├── webhooks.app.scopes_update.jsx
│ │ └── webhooks.gdpr.jsx # GDPR compliance (REQUIRED)
│ ├── shopify.server.js # Shopify app config
│ ├── db.server.js # Prisma client
│ └── entry.server.jsx
├── extensions/ # Checkout/theme extensions
│ └── my-extension/
│ ├── src/
│ │ └── index.tsx
│ ├── shopify.extension.toml
│ └── package.json
├── prisma/
│ └── schema.prisma # Session storage
├── shopify.app.toml # App configuration
├── package.json
└── vite.config.js
アプリの設定
shopify.app.toml
# App configuration - managed by Shopify CLI
client_id = "your-api-key"
name = "Your App Name"
handle = "your-app-handle"
application_url = "https://your-app.onrender.com"
embedded = true
[webhooks]
api_version = "2025-01"
# Required: App lifecycle webhooks
[[webhooks.subscriptions]]
topics = ["app/uninstalled"]
uri = "/webhooks/app/uninstalled"
[[webhooks.subscriptions]]
topics = ["app/scopes_update"]
uri = "/webhooks/app/scopes_update"
# Required: GDPR compliance webhooks
[[webhooks.subscriptions]]
compliance_topics = [
"customers/data_request",
"customers/redact",
"shop/redact",
]
uri = "/webhooks/gdpr"
[access_scopes]
scopes = "read_products,write_products"
[auth]
redirect_urls = [
"https://your-app.onrender.com/auth/callback",
"https://your-app.onrender.com/auth/shopify/callback",
]
[pos]
embedded = false
[build]
dev_store_url = "your-dev-store.myshopify.com"
automatically_update_urls_on_dev = true
shopify.server.js
import "@shopify/shopify-app-remix/adapters/node";
import {
ApiVersion,
AppDistribution,
shopifyApp,
} from "@shopify/shopify-app-remix/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import { prisma } from "./db.server";
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
apiVersion: ApiVersion.January25,
scopes: process.env.SCOPES?.split(","),
appUrl: process.env.SHOPIFY_APP_URL || "",
authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(prisma),
distribution: AppDistribution.AppStore,
future: {
unstable_newEmbeddedAuthStrategy: true,
removeRest: true, // Use GraphQL only
},
});
export default shopify;
export const apiVersion = ApiVersion.January25;
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
export const authenticate = shopify.authenticate;
export const unauthenticated = shopify.unauthenticated;
export const login = shopify.login;
export const registerWebhooks = shopify.registerWebhooks;
export const sessionStorage = shopify.sessionStorage;
認証
ルートの保護
// app/routes/app._index/route.jsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { authenticate } from "../../shopify.server";
export const loader = async ({ request }) => {
// This authenticates the request and redirects to login if needed
const { admin, session } = await authenticate.admin(request);
// Now you have access to admin API and session
const shop = session.shop;
return json({ shop });
};
export default function Index() {
const { shop } = useLoaderData();
return <div>Connected to: {shop}</div>;
}
Webhook 認証
// app/routes/webhooks.app.uninstalled.jsx
import { authenticate } from "../shopify.server";
import { prisma } from "../db.server";
export const action = async ({ request }) => {
const { shop, topic } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
// Clean up shop data on uninstall
await prisma.session.deleteMany({ where: { shop } });
return new Response(null, { status: 200 });
};
GraphQL Admin API
基本的なクエリパターン
// app/shopify/adminApi.server.js
export async function getShopId(admin) {
const response = await admin.graphql(`
query getShopId {
shop {
id
name
email
myshopifyDomain
}
}
`);
const data = await response.json();
return data.data?.shop;
}
変数を使ったクエリ
export async function getProducts(admin, first = 10) {
const response = await admin.graphql(`
query getProducts($first: Int!) {
products(first: $first) {
edges {
node {
id
title
status
variants(first: 5) {
edges {
node {
id
price
inventoryQuantity
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`, {
variables: { first }
});
const data = await response.json();
return data.data?.products?.edges.map(e => e.node);
}
ミューテーション
export async function createProduct(admin, input) {
const response = await admin.graphql(`
mutation createProduct($input: ProductInput!) {
productCreate(input: $input) {
product {
id
title
}
userErrors {
field
message
}
}
}
`, {
variables: {
input: {
title: input.title,
descriptionHtml: input.description,
status: "DRAFT"
}
}
});
const data = await response.json();
const result = data.data?.productCreate;
if (result?.userErrors?.length > 0) {
throw new Error(result.userErrors.map(e => e.message).join(", "));
}
return result?.product;
}
メタフィールド(アプリ設定の保存)
// Get metafield
export async function getMetafield(admin, namespace, key) {
const response = await admin.graphql(`
query getShopMetafield($namespace: String!, $key: String!) {
shop {
id
metafield(namespace: $namespace, key: $key) {
id
value
}
}
}
`, {
variables: { namespace, key }
});
const data = await response.json();
const metafield = data.data?.shop?.metafield;
return {
shopId: data.data?.shop?.id,
value: metafield?.value ? JSON.parse(metafield.value) : null,
};
}
// Set metafield
export async function setMetafield(admin, namespace, key, value, shopId) {
const response = await admin.graphql(`
mutation CreateMetafield($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields {
id
namespace
key
value
}
userErrors {
field
message
}
}
}
`, {
variables: {
metafields: [{
namespace,
key,
type: "json",
value: JSON.stringify(value),
ownerId: shopId,
}]
}
});
const data = await response.json();
const errors = data.data?.metafieldsSet?.userErrors;
if (errors?.length > 0) {
throw new Error(errors.map(e => e.message).join(", "));
}
return data.data?.metafieldsSet?.metafields?.[0];
}
GDPR への準拠(必須)
すべての Shopify アプリは GDPR Webhook を処理する必要があります。 これは App Store の審査に必要です。
// app/routes/webhooks.gdpr.jsx
import { authenticate } from "../shopify.server";
export const action = async ({ request }) => {
const { topic, shop, session } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
switch (topic) {
case "customers/data_request":
// Return any customer data you store
// If you don't store customer data, return empty
return json({ customer_data: null });
case "customers/redact":
// Delete customer data
// Example: await deleteCustomerData(payload.customer.id);
return json({ success: true });
case "shop/redact":
// Delete all shop data (48 hours after uninstall)
// Clean up metafields, database records, etc.
if (session) {
const { admin } = await authenticate.admin(request);
await admin.graphql(`
mutation metafieldDelete($input: MetafieldsDeleteInput!) {
metafieldsDelete(input: $input) {
deletedId
}
}
`, {
variables: {
input: {
namespace: "your_app",
key: "settings",
ownerType: "SHOP"
}
}
});
}
return json({ success: true });
default:
return json({ error: "Unhandled topic" }, { status: 400 });
}
};
Polaris を使った UI
アプリのレイアウト
// app/routes/app.jsx
import { Outlet } from "@remix-run/react";
import { AppProvider } from "@shopify/polaris";
import "@shopify/polaris/build/esm/styles.css";
import polarisTranslations from "@shopify/polaris/locales/en.json";
export default function App() {
return (
<AppProvider i18n={polarisTranslations}>
<Outlet />
</AppProvider>
);
}
設定ページのパターン
// app/routes/app._index/route.jsx
import { useState } from "react";
import { json } from "@remix-run/node";
import { useActionData, useLoaderData, useSubmit } from "@remix-run/react";
import {
Page,
Layout,
Card,
FormLayout,
TextField,
Select,
Banner,
Button,
} from "@shopify/polaris";
import { authenticate } from "../../shopify.server";
import { getMetafield, setMetafield, getShopId } from "../../shopify/adminApi.server";
export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const { shopId, value } = await getMetafield(admin, "your_app", "settings");
return json({ shopId, settings: value });
};
export const action = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const formData = await request.formData();
const settings = {
apiKey: formData.get("apiKey"),
enabled: formData.get("enabled") === "true",
};
try {
const shopId = await getShopId(admin);
await setMetafield(admin, "your_app", "settings", settings, shopId.id);
return json({ success: true, message: "Settings saved!" });
} catch (error) {
return json({ error: error.message }, { status: 500 });
}
};
export default function Settings() {
const { settings } = useLoaderData();
const actionData = useActionData();
const submit = useSubmit();
const [formState, setFormState] = useState({
apiKey: settings?.apiKey || "",
enabled: settings?.enabled ?? true,
});
const handleSubmit = () => {
const formData = new FormData();
formData.append("apiKey", formState.apiKey);
formData.append("enabled", String(formState.enabled));
submit(formData, { method: "post" });
};
return (
<Page
title="App Settings"
primaryAction={{
content: "Save",
onAction: handleSubmit,
}}
>
<Layout>
{actionData?.message && (
<Layout.Section>
<Banner tone="success">{actionData.message}</Banner>
</Layout.Section>
)}
{actionData?.error && (
<Layout.Section>
<Banner tone="critical">{actionData.error}</Banner>
</Layout.Section>
)}
<Layout.Section>
<Card>
<FormLayout>
<TextField
label="API Key"
value={formState.apiKey}
onChange={(value) => setFormState({ ...formState, apiKey: value })}
autoComplete="off"
/>
<Select
label="Enable Integration"
options={[
{ label: "Enabled", value: "true" },
{ label: "Disabled", value: "false" },
]}
value={String(formState.enabled)}
onChange={(value) =>
setFormState({ ...formState, enabled: value === "true" })
}
/>
</FormLayout>
</Card>
</Layout.Section>
</Layout>
</Page>
);
}
チェックアウト UI 拡張機能
拡張機能の設定
# extensions/my-extension/shopify.extension.toml
api_version = "2025-01"
[[extensions]]
name = "My Checkout Extension"
handle = "my-checkout-extension"
type = "ui_extension"
[[extensions.targeting]]
module = "./src/index.tsx"
target = "purchase.thank-you.block.render"
[extensions.capabilities]
api_access = true
network_access = true
# Access app metafields in extension
[[extensions.metafields]]
namespace = "your_app"
key = "settings"
拡張機能のターゲット位置
| ターゲット | 位置 |
|---|---|
purchase.thank-you.block.render | サンクスページ |
purchase.checkout.block.render | チェックアウトページ |
customer-account.order-status.block.render | 注文状況 |
customer-account.page.render | 顧客アカウントページ |
admin.product-details.block.render | 管理画面の商品ページ |
拡張機能コンポーネント
// extensions/my-extension/src/index.tsx
import {
reactExtension,
useShop,
useAppMetafields,
useApi,
View,
BlockStack,
Heading,
Text,
Button,
Spinner,
} from "@shopify/ui-extensions-react/checkout";
export default reactExtension("purchase.thank-you.block.render", () => (
<Extension />
));
function Extension() {
const shop = useShop();
const { orderConfirmation } = useApi();
const order = orderConfirmation.current.order;
// Access app metafields
const metafields = useAppMetafields({
namespace: "your_app",
key: "settings"
});
const settings = metafields[0]?.metafield?.value
? JSON.parse(metafields[0].metafield.value)
: null;
if (!settings?.enabled) {
return null;
}
return (
<View border="base" padding="base">
<BlockStack>
<Heading level={2}>Thank You!</Heading>
<Text>Order #{order.id} confirmed</Text>
<Text appearance="subdued">
Shop: {shop.myshopifyDomain}
</Text>
</BlockStack>
</View>
);
}
外部 API を使った拡張機能
// extensions/my-extension/src/hooks/useExternalApi.ts
import { useState, useEffect } from "react";
export function useExternalApi(surveyId: string) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!surveyId) {
setLoading(false);
return;
}
fetch(`https://api.example.com/surveys/${surveyId}`)
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [surveyId]);
return { data, loading, error };
}
データベース(Prisma)
セッションストレージのスキーマ
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // or "sqlite" for dev
url = env("DATABASE_URL")
}
// Required for Shopify session storage
model Session {
id String @id
shop String
state String
isOnline Boolean @default(false)
scope String?
expires DateTime?
accessToken String
userId BigInt?
firstName String?
lastName String?
email String?
accountOwner Boolean @default(false)
locale String?
collaborator Boolean? @default(false)
emailVerified Boolean? @default(false)
@@index([shop])
}
// Your app's custom models
model AppSettings {
id String @id @default(uuid())
shop String @unique
settings Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
データベースクライアント
// app/db.server.js
import { PrismaClient } from "@prisma/client";
let prisma;
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
} else {
// Prevent multiple instances in development
if (!global.__prisma) {
global.__prisma = new PrismaClient();
}
prisma = global.__prisma;
}
export { prisma };
デプロイ
環境変数
# .env (DO NOT COMMIT)
SHOPIFY_API_KEY=your_api_key
SHOPIFY_API_SECRET=your_api_secret
SCOPES=read_products,write_products
SHOPIFY_APP_URL=https://your-app.onrender.com
DATABASE_URL=postgresql://...
Render へのデプロイ
# render.yaml
services:
- type: web
name: shopify-app
runtime: node
plan: starter
buildCommand: npm install && npm run setup && npm run build
startCommand: npm run start
envVars:
- key: NODE_ENV
value: production
- key: DATABASE_URL
fromDatabase:
name: shopify-db
property: connectionString
- key: SHOPIFY_API_KEY
sync: false
- key: SHOPIFY_API_SECRET
sync: false
- key: SCOPES
sync: false
- key: SHOPIFY_APP_URL
sync: false
databases:
- name: shopify-db
plan: starter
デプロイコマンド
# Deploy app to Shopify
shopify app deploy
# This:
# 1. Builds extensions
# 2. Uploads to Shopify
# 3. Creates new app version
一般的なスコープ
| スコープ | アクセス権 |
|---|---|
read_products | 商品の表示 |
write_products | 商品の作成・編集 |
read_orders | 注文の表示 |
write_orders | 注文の作成・編集 |
read_customers | 顧客の表示 |
write_customers | 顧客の作成・編集 |
read_checkouts | チェックアウトデータの表示 |
write_checkouts | チェックアウトの変更 |
read_themes | テーマの表示 |
write_themes | テーマの変更 |
read_content | メタフィールド/ファイルの表示 |
write_content | メタフィールド/ファイルの変更 |
CLI コマンド
# Development
shopify app dev # Start dev server with tunnel
shopify app dev --reset # Reset app config
# Configuration
shopify app config link # Link to existing app
shopify app config use # Switch config
shopify app env show # Show env vars
# Extensions
shopify app generate extension # Create new extension
shopify app build # Build all extensions
# Deployment
shopify app deploy # Deploy to Shopify
shopify app versions list # List app versions
# Store
shopify app open # Open app in dev store
テスト
単体テスト
// __tests__/adminApi.test.js
import { describe, it, expect, vi } from 'vitest';
import { getShopId, setMetafield } from '../app/shopify/adminApi.server';
describe('Admin API', () => {
it('gets shop ID', async () => {
const mockAdmin = {
graphql: vi.fn().mockResolvedValue({
json: () => Promise.resolve({
data: { shop: { id: 'gid://shopify/Shop/123' } }
})
})
};
const result = await getShopId(mockAdmin);
expect(result.id).toBe('gid://shopify/Shop/123');
});
});
Playwright による E2E テスト
// e2e/app.spec.ts
import { test, expect } from '@playwright/test';
test('app settings page loads', async ({ page }) => {
// Note: Requires authenticated session
await page.goto('/app');
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
await expect(page.getByLabel('API Key')).toBeVisible();
});
test('saves settings successfully', async ({ page }) => {
await page.goto('/app');
await page.fill('[name="apiKey"]', 'test-key-123');
await page.click('button:has-text("Save")');
await expect(page.getByText('Settings saved')).toBeVisible();
});
レート制限
GraphQL のコストベースの制限
// Check rate limit status in response
const response = await admin.graphql(`
query {
shop { name }
}
`);
const data = await response.json();
// Rate limit info in extensions
const throttleStatus = data.extensions?.cost?.throttleStatus;
// {
// maximumAvailable: 1000,
// currentlyAvailable: 950,
// restoreRate: 50 // points per second
// }
スロットリングへの対処
async function graphqlWithRetry(admin, query, variables, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await admin.graphql(query, { variables });
const data = await response.json();
if (data.errors?.some(e => e.extensions?.code === 'THROTTLED')) {
const waitTime = Math.pow(2, attempt) * 1000; // Exponential backoff
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
return data;
}
throw new Error('Max retries exceeded');
}
チェックリスト
開発前
- Partner アカウントを作成済み
- 開発ストアを作成済み
- Partner Dashboard でアプリを作成済み
- Shopify CLI をインストール済み
- Remix テンプレートでアプリをスキャフォールド済み
提出前
- GDPR Webhook を実装済み(customers/data_request、customers/redact、shop/redact)
- アプリのアンインストール用 Webhook でデータをクリーンアップ
- API キーをハードコードしていない
- すべての API 呼び出しにエラー処理がある
- レート制限に対応している
- レスポンシブ UI(モバイルの管理画面で動作する)
- Polaris コンポーネントを一貫して使用している
- 拡張機能のターゲットが適切なサーフェスに設定されている
- プライバシーポリシーの URL を設定済み
- アプリの掲載情報を完成済み
セキュリティ
- セッショントークンを検証済み
- Webhook の HMAC 検証(SDK が処理)
- クライアント側のコードに機密データが含まれていない
- すべてのシークレットに環境変数を使用
- HTTPS を強制
アンチパターン
- REST API の使用 - GraphQL Admin API を使用してください(REST は非推奨です)
- シークレットのメタフィールドへの保存 - 環境変数を使用してください
- レート制限の無視 - 指数バックオフを実装してください
- GDPR Webhook の省略 - App Store で必須です
- 大きな GraphQL クエリ - ページネーションを使用し、必要なフィールドだけをクエリしてください
- 更新のポーリング - 代わりに Webhook を使用してください
- カスタム認証フロー - SDK を介して Shopify の OAuth フローを使用してください
コメント
Markdown 対応