shopify-apps

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Shopify App Development Skill

Shopify应用开发技能

Load with: base.md + typescript.md + react-web.md
For building Shopify apps using Remix, the Shopify App framework, and checkout UI extensions.

加载依赖:base.md + typescript.md + react-web.md
本技能用于指导使用Remix、Shopify应用框架和结账UI扩展开发Shopify应用。

Prerequisites

前置条件

Required Accounts & Tools

所需账户与工具

bash
undefined
bash
undefined

1. Shopify Partner Account (free)

1. Shopify合作伙伴账户(免费)

2. Development Store

2. 开发店铺

Create in Partner Dashboard → Stores → Add store → Development store

在合作伙伴后台创建:店铺 → 添加店铺 → 开发店铺

3. Shopify CLI

3. Shopify CLI

npm install -g @shopify/cli
npm install -g @shopify/cli

4. Node.js 18.20+ or 20.10+

4. Node.js 18.20+ 或 20.10+

node --version
undefined
node --version
undefined

Partner Dashboard Setup

合作伙伴后台设置

  1. Create Partner account at partners.shopify.com
  2. Create a development store for testing
  3. Create an app in Partner Dashboard → Apps → Create app
  4. Note your API key and API secret

  1. 在partners.shopify.com创建合作伙伴账户
  2. 创建用于测试的开发店铺
  3. 在合作伙伴后台创建应用:应用 → 创建应用
  4. 记录你的API密钥和API密钥密码

Quick Start

快速开始

Scaffold New App

搭建新应用

bash
undefined
bash
undefined

Create new Shopify app with Remix

使用Remix创建新的Shopify应用

shopify app init
shopify app init

Answer prompts:

回答提示问题:

- App name

- 应用名称

- Template: Remix (recommended)

- 模板:Remix(推荐)

- Language: JavaScript or TypeScript

- 语言:JavaScript或TypeScript

Start development

启动开发服务

cd your-app-name shopify app dev
undefined
cd your-app-name shopify app dev
undefined

Project Structure

项目结构

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/
├── app/
│   ├── routes/
│   │   ├── app._index/          # 应用主页
│   │   │   └── route.jsx
│   │   ├── app.jsx              # 包含Polaris的应用布局
│   │   ├── auth.$.jsx           # 身份验证兜底路由
│   │   ├── auth.login/          # 登录页面
│   │   │   └── route.jsx
│   │   ├── webhooks.app.uninstalled.jsx
│   │   ├── webhooks.app.scopes_update.jsx
│   │   └── webhooks.gdpr.jsx    # GDPR合规路由(必须实现)
│   ├── shopify.server.js        # Shopify应用配置
│   ├── db.server.js             # Prisma客户端
│   └── entry.server.jsx
├── extensions/                   # 结账/主题扩展
│   └── my-extension/
│       ├── src/
│       │   └── index.tsx
│       ├── shopify.extension.toml
│       └── package.json
├── prisma/
│   └── schema.prisma            # 会话存储Schema
├── shopify.app.toml             # 应用配置文件
├── package.json
└── vite.config.js

App Configuration

应用配置

shopify.app.toml

shopify.app.toml

toml
undefined
toml
undefined

App configuration - managed by Shopify CLI

应用配置 - 由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"
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

必须实现:应用生命周期webhook

[[webhooks.subscriptions]] topics = ["app/uninstalled"] uri = "/webhooks/app/uninstalled"
[[webhooks.subscriptions]] topics = ["app/scopes_update"] uri = "/webhooks/app/scopes_update"
[[webhooks.subscriptions]] topics = ["app/uninstalled"] uri = "/webhooks/app/uninstalled"
[[webhooks.subscriptions]] topics = ["app/scopes_update"] uri = "/webhooks/app/scopes_update"

Required: GDPR compliance webhooks

必须实现:GDPR合规webhook

[[webhooks.subscriptions]] compliance_topics = [ "customers/data_request", "customers/redact", "shop/redact", ] uri = "/webhooks/gdpr"
[access_scopes] scopes = "read_products,write_products"
[pos] embedded = false
[build] dev_store_url = "your-dev-store.myshopify.com" automatically_update_urls_on_dev = true
undefined
[[webhooks.subscriptions]] compliance_topics = [ "customers/data_request", "customers/redact", "shop/redact", ] uri = "/webhooks/gdpr"
[access_scopes] scopes = "read_products,write_products"
[pos] embedded = false
[build] dev_store_url = "your-dev-store.myshopify.com" automatically_update_urls_on_dev = true
undefined

shopify.server.js

shopify.server.js

javascript
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;

javascript
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,  // 仅使用GraphQL
  },
});

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;

Authentication

身份验证

Route Protection

路由保护

javascript
// 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>;
}
javascript
// 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 }) => {
  // 验证请求,若未登录则重定向到登录页
  const { admin, session } = await authenticate.admin(request);

  // 现在可以访问Admin API和会话信息
  const shop = session.shop;

  return json({ shop });
};

export default function Index() {
  const { shop } = useLoaderData();
  return <div>已连接到:{shop}</div>;
}

Webhook Authentication

Webhook身份验证

javascript
// 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 });
};

javascript
// 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(`收到${shop}${topic} webhook`);

  // 应用卸载时清理店铺数据
  await prisma.session.deleteMany({ where: { shop } });

  return new Response(null, { status: 200 });
};

GraphQL Admin API

GraphQL Admin API

Basic Query Pattern

基础查询模式

javascript
// 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;
}
javascript
// 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;
}

Query with Variables

带变量的查询

javascript
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);
}
javascript
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);
}

Mutations

变更操作

javascript
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;
}
javascript
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;
}

Metafields (App Settings Storage)

元字段(应用设置存储)

javascript
// 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];
}

javascript
// 获取元字段
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,
  };
}

// 设置元字段
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 Compliance (REQUIRED)

GDPR合规要求(必须实现)

All Shopify apps MUST handle GDPR webhooks. This is required for App Store approval.
javascript
// 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 });
  }
};

所有Shopify应用必须处理GDPR webhook,这是应用商店审核的必要条件。
javascript
// 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(`收到${shop}${topic} webhook`);

  switch (topic) {
    case "customers/data_request":
      // 返回你存储的所有客户数据
      // 若未存储客户数据,则返回空
      return json({ customer_data: null });

    case "customers/redact":
      // 删除客户数据
      // 示例:await deleteCustomerData(payload.customer.id);
      return json({ success: true });

    case "shop/redact":
      // 删除店铺所有数据(卸载后48小时执行)
      // 清理元字段、数据库记录等
      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: "未处理的webhook主题" }, { status: 400 });
  }
};

UI with Polaris

使用Polaris构建UI

App Layout

应用布局

javascript
// 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>
  );
}
javascript
// 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>
  );
}

Settings Page Pattern

设置页面模式

javascript
// 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>
  );
}

javascript
// 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: "设置已保存!" });
  } 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="应用设置"
      primaryAction={{
        content: "保存",
        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密钥"
                value={formState.apiKey}
                onChange={(value) => setFormState({ ...formState, apiKey: value })}
                autoComplete="off"
              />

              <Select
                label="启用集成"
                options={[
                  { label: "已启用", value: "true" },
                  { label: "已禁用", value: "false" },
                ]}
                value={String(formState.enabled)}
                onChange={(value) =>
                  setFormState({ ...formState, enabled: value === "true" })
                }
              />
            </FormLayout>
          </Card>
        </Layout.Section>
      </Layout>
    </Page>
  );
}

Checkout UI Extensions

结账UI扩展

Extension Configuration

扩展配置

toml
undefined
toml
undefined

extensions/my-extension/shopify.extension.toml

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
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"
undefined
[[extensions.metafields]] namespace = "your_app" key = "settings"
undefined

Extension Target Locations

扩展目标位置

TargetLocation
purchase.thank-you.block.render
Thank you page
purchase.checkout.block.render
Checkout page
customer-account.order-status.block.render
Order status
customer-account.page.render
Customer account pages
admin.product-details.block.render
Admin product page
目标位置展示区域
purchase.thank-you.block.render
感谢页面
purchase.checkout.block.render
结账页面
customer-account.order-status.block.render
订单状态页
customer-account.page.render
客户账户页面
admin.product-details.block.render
后台产品页面

Extension Component

扩展组件

tsx
// 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>
  );
}
tsx
// 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;

  // 访问应用元字段
  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}>感谢您的购买!</Heading>
        <Text>订单#{order.id}已确认</Text>
        <Text appearance="subdued">
          店铺:{shop.myshopifyDomain}
        </Text>
      </BlockStack>
    </View>
  );
}

Extension with External API

调用外部API的扩展

tsx
// 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 };
}

tsx
// 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 };
}

Database (Prisma)

数据库(Prisma)

Session Storage Schema

会话存储Schema

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
}
prisma
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"  // 开发环境可使用"sqlite"
  url      = env("DATABASE_URL")
}

Database Client

Shopify会话存储所需模型

javascript
// 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 };

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]) }

Deployment

你的应用自定义模型

Environment Variables

bash
undefined
model AppSettings { id String @id @default(uuid()) shop String @unique settings Json createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
undefined

.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://...
undefined
javascript
// app/db.server.js
import { PrismaClient } from "@prisma/client";

let prisma;

if (process.env.NODE_ENV === "production") {
  prisma = new PrismaClient();
} else {
  // 开发环境避免创建多个实例
  if (!global.__prisma) {
    global.__prisma = new PrismaClient();
  }
  prisma = global.__prisma;
}

export { prisma };

Render Deployment

部署

环境变量

yaml
undefined
bash
undefined

render.yaml

.env(请勿提交到版本控制)

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
undefined
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://...
undefined

Deploy Commands

Render部署配置

bash
undefined
yaml
undefined

Deploy app to Shopify

render.yaml

shopify app deploy
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
undefined

This:

部署命令

1. Builds extensions

2. Uploads to Shopify

3. Creates new app version


---
bash
undefined

Common Scopes

将应用部署到Shopify

ScopeAccess
read_products
View products
write_products
Create/edit products
read_orders
View orders
write_orders
Create/edit orders
read_customers
View customers
write_customers
Create/edit customers
read_checkouts
View checkout data
write_checkouts
Modify checkout
read_themes
View themes
write_themes
Modify themes
read_content
View metafields/files
write_content
Modify metafields/files

shopify app deploy

CLI Commands

该命令会:

1. 构建扩展

2. 上传到Shopify

3. 创建新的应用版本

bash
undefined

---

Development

常见权限范围

shopify app dev # Start dev server with tunnel shopify app dev --reset # Reset app config
权限范围访问权限
read_products
查看产品
write_products
创建/编辑产品
read_orders
查看订单
write_orders
创建/编辑订单
read_customers
查看客户
write_customers
创建/编辑客户
read_checkouts
查看结账数据
write_checkouts
修改结账流程
read_themes
查看主题
write_themes
修改主题
read_content
查看元字段/文件
write_content
修改元字段/文件

Configuration

CLI命令

shopify app config link # Link to existing app shopify app config use # Switch config shopify app env show # Show env vars
bash
undefined

Extensions

开发相关

shopify app generate extension # Create new extension shopify app build # Build all extensions
shopify app dev # 启动开发服务器并创建隧道 shopify app dev --reset # 重置应用配置

Deployment

配置相关

shopify app deploy # Deploy to Shopify shopify app versions list # List app versions
shopify app config link # 关联到已存在的应用 shopify app config use # 切换应用配置 shopify app env show # 查看环境变量

Store

扩展相关

shopify app open # Open app in dev store

---
shopify app generate extension # 创建新扩展 shopify app build # 构建所有扩展

Testing

部署相关

Unit Tests

javascript
// __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');
  });
});
shopify app deploy # 部署到Shopify shopify app versions list # 查看应用版本列表

E2E with Playwright

店铺相关

typescript
// 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();
});

shopify app open # 在开发店铺中打开应用

---

Rate Limits

测试

GraphQL Cost-Based Limits

单元测试

javascript
// 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
// }
javascript
// __tests__/adminApi.test.js
import { describe, it, expect, vi } from 'vitest';
import { getShopId, setMetafield } from '../app/shopify/adminApi.server';

describe('Admin API', () => {
  it('获取店铺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');
  });
});

Handling Throttling

使用Playwright进行端到端测试

javascript
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');
}

typescript
// e2e/app.spec.ts
import { test, expect } from '@playwright/test';

test('应用设置页面正常加载', async ({ page }) => {
  // 注意:需要已验证的会话
  await page.goto('/app');

  await expect(page.getByRole('heading', { name: /设置/i })).toBeVisible();
  await expect(page.getByLabel('API密钥')).toBeVisible();
});

test('成功保存设置', async ({ page }) => {
  await page.goto('/app');

  await page.fill('[name="apiKey"]', 'test-key-123');
  await page.click('button:has-text("保存")');

  await expect(page.getByText('设置已保存')).toBeVisible();
});

Checklist

速率限制

Before Development

基于成本的GraphQL限制

  • Partner account created
  • Development store created
  • App created in Partner Dashboard
  • Shopify CLI installed
  • App scaffolded with Remix template
javascript
// 在响应中查看速率限制状态
const response = await admin.graphql(`
  query {
    shop { name }
  }
`);

const data = await response.json();

// 速率限制信息在extensions字段中
const throttleStatus = data.extensions?.cost?.throttleStatus;
// {
//   maximumAvailable: 1000,
//   currentlyAvailable: 950,
//   restoreRate: 50  // 每秒恢复的点数
// }

Before Submission

处理限流

  • GDPR webhooks implemented (customers/data_request, customers/redact, shop/redact)
  • App uninstall webhook cleans up data
  • No hardcoded API keys
  • Error handling for all API calls
  • Rate limit handling
  • Responsive UI (works on mobile admin)
  • Polaris components used consistently
  • Extension targets correct surfaces
  • Privacy policy URL configured
  • App listing completed
javascript
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; // 指数退避
      await new Promise(resolve => setTimeout(resolve, waitTime));
      continue;
    }

    return data;
  }
  throw new Error('已达到最大重试次数');
}

Security

检查清单

开发前

  • Session tokens validated
  • Webhook HMAC verification (handled by SDK)
  • No sensitive data in client-side code
  • Environment variables for all secrets
  • HTTPS enforced

  • 已创建合作伙伴账户
  • 已创建开发店铺
  • 已在合作伙伴后台创建应用
  • 已安装Shopify CLI
  • 已使用Remix模板搭建应用

Anti-Patterns

提交审核前

  • REST API usage - Use GraphQL Admin API (REST is deprecated)
  • Storing secrets in metafields - Use environment variables
  • Ignoring rate limits - Implement exponential backoff
  • Skipping GDPR webhooks - Required for App Store
  • Large GraphQL queries - Paginate, query only needed fields
  • Polling for updates - Use webhooks instead
  • Custom auth flow - Use Shopify's OAuth flow via SDK
  • 已实现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 - 这是应用商店审核的必要条件
  • 过大的GraphQL查询 - 应分页查询,仅获取所需字段
  • 轮询更新 - 应使用webhook替代
  • 自定义身份验证流程 - 应使用SDK提供的Shopify OAuth流程