构建 Shopify 嵌入式应用(Embedded App)的开发技能:基于 @shopify/shopify-app-react-router + React Router v7 + Admin API 2026-04。覆盖 shopifyApp 配置、authenticate.admin 的 GraphQL/REST 调用、Webhook 校验、Session 存储、订阅计费、App Bridge 导航、部署,并新增:适用场景判断、安全最佳实践、错误处理与容错、最佳实践、常见问题 FAQ、高级场景。G
Shopify App React Router — Development Skill.是一项面向实际任务的技能。它将相关步骤、工具调用和结果整理方式集中到统一流程中,帮助使用者更快完成目标并减少重复操作。
实际使用前应先确认任务范围、数据来源、运行环境、必要权限和关键参数,再依据技能说明逐步执行;若输入条件不完整,应先补齐信息或采用保守配置,避免因错误假设导致结果偏离需求。执行过程中需要关注工具调用是否成功、接口或依赖是否可用、输出格式是否符合预期,并对异常提示、缺失字段和边界情况进行处理;
涉及批量任务时,还应保存进度,避免中断后重复操作。该技能适合用于一次性任务,也可以接入自动化工作流,与其他技能或上层代理配合完成更完整的业务链路;在组合使用时,应明确每一步的输入输出关系,并避免不同步骤之间出现参数冲突。
This skill provides end-to-end guidance for building Shopify embedded apps using:
@shopify/shopify-app-react-router v1.x (latest)The package wraps authentication (OAuth token exchange, session management), GraphQL Admin API access, webhook verification, billing, and App Bridge integration for React Router apps.
本技能用于构建 Shopify 嵌入式应用(Embedded App)。动手前先确认是否真的需要它。
authenticate.admin / webhook / public 鉴权封装,而非自己手写 OAuth。shopify-liquid / shopify-theme-pro 相关技能。shopify-hydrogen / shopify-storefront-graphql。| 目标 | 推荐技能 / 方案 |
|---|---|
| 嵌入式后台应用(本文) | shopify-app-react-router |
| 主题 / Liquid 定制 | shopify-liquid、shopify-theme-pro |
| 无头店铺前台 | shopify-hydrogen、shopify-storefront-graphql |
| 纯数据查询/运营 | shopify-admin、shopify-admin-api |
当用户/智能体出现以下意图时,应调用本技能(而不是泛泛去搜 Shopify 文档):
若意图是「改主题/Liquid」「做无头店铺前台」「只查数据」,按上文「❌ 不适合」转对应技能。
前置条件:Node ≥ 18、已创建 Shopify Partner 应用(拿到 API Key / Secret)、一个测试店铺。
# 1) 用官方脚手架创建(含 shopify.server.ts、Prisma、routes 模板) npm create @shopify/app@latest # 2) 安装依赖 npm install # 3) 填写环境变量(.env) # SHOPIFY_API_KEY=xxx # SHOPIFY_API_SECRET=xxx # SHOPIFY_APP_URL=https:/// # 开发用 cloudflare tunnel / ngrok # 4) 本地启动(自带隧道) npm run dev # 5) 在 Partner Dashboard 配置 App URL 与 Redirect URL,安装到测试店
最小可运行骨架(核心 3 文件):
app/shopify.server.ts —— shopifyApp() 配置(见第 1 节)。app/routes/app.tsx —— 后台布局 + AppProvider + authenticate.admin(见第 7 节)。app/routes/webhooks.tsx —— Webhook 校验(见第 5 节)。完成这 3 步即可在店铺后台看到应用菜单并调用 Admin API。后续按需在 loader/action 中调用
admin.graphql(...)。
@shopify/shopify-app-react-router ├── /server → shopifyApp(), authenticate, unauthenticated, boundary, ApiVersion, LogSeverity ├── /react → AppProvider (client component for embedded admin UI) └── /test-helpers → testConfig() for integration tests
@shopify/shopify-app-react-router/server)| Export | Purpose |
|---|---|
shopifyApp(config) |
Initialize the Shopify app with credentials and options |
authenticate.admin(request) |
Authenticate admin-embedded requests, returns AdminContext |
authenticate.webhook(request) |
Verify webhook HMAC signatures |
authenticate.public(request) |
Authenticate public (unauthenticated) app proxy/checkout requests |
unauthenticated.admin(shop) |
Background/admin API access without a request context |
boundary.error(error) |
Error boundary compatible with Shopify CSP headers |
boundary.headers(args) |
Headers function for CSP compliance |
ApiVersion |
Enum of all API versions (use ApiVersion.April26) |
LogSeverity |
Log level: Error, Warning, Info, Debug |
@shopify/shopify-app-react-router/react)| Export | Purpose |
|---|---|
AppProvider |
Wraps admin routes with App Bridge context (embedded, apiKey) |
Create /app/shopify.server.ts as the central configuration file:
import {
ApiVersion,
LogSeverity,
shopifyApp,
} from "@shopify/shopify-app-react-router/server";
const shopify = shopifyApp({
// Required
apiKey: process.env.SHOPIFY_API_KEY!,
apiSecretKey: process.env.SHOPIFY_API_SECRET!,
appUrl: process.env.SHOPIFY_APP_URL!,
apiVersion: ApiVersion.April26, // 2026-04
// Optional
scopes: ["read_products", "write_products", "read_orders"],
logger: { level: LogSeverity.Debug },
isEmbeddedApp: true, // default: true
distribution: "AppDistribution.AppStore", // or ShopifyAdmin for custom apps
// Session storage — use Prisma, SQLite, PostgreSQL, MongoDB, Redis, etc.
sessionStorage: new PrismaSessionStorage(prisma),
// Billing — optional, for apps with charges
billing: undefined,
// Future flags
future: { v3_authenticatePublic: true },
});
export default shopify;
export const authenticate = shopify.authenticate;
export const unauthenticated = shopify.unauthenticated;
export const sessionStorage = shopify.sessionStorage;
ApiVersion.April26 // "2026-04" ApiVersion.January26 // "2026-01" ApiVersion.October25 // "2025-10" // ... etc
import { authenticate } from "~/shopify.server";
import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
// In a loader
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session, billing, cors, redirect } =
await authenticate.admin(request);
// admin → { graphql, rest (via shopify-api) }
// session → { id, shop, accessToken, isOnline, scope, expires }
// billing → { require, check, cancel, request }
// cors → function to wrap cross-origin responses
// redirect → { adminPath, embedApp }
return null;
};
// In an action
export const action = async ({ request }: ActionFunctionArgs) => {
const { admin } = await authenticate.admin(request);
// ...
};
After authenticate.admin(request), the returned AdminContext contains:
| Property | Type | Description |
|---|---|---|
admin |
AdminApiContext |
Authenticated API client (graphql + rest) |
session |
Session |
Active session: id, shop, accessToken, isOnline, scope, expires |
billing |
BillingContext |
Billing helpers (require, check, cancel, request) |
cors |
(data) => Response |
Wrap responses with CORS headers for cross-origin extensions |
redirect |
AdminRedirect |
Redirect helpers (adminPath, embedApp) |
botOrigin |
string |
Bot protection origin URL |
export const action = async ({ request }: ActionFunctionArgs) => {
const { admin } = await authenticate.admin(request);
// admin.graphql(query, options) → Response
const response = await admin.graphql(
`#graphql
query GetProducts($first: Int!) {
products(first: $first) {
nodes {
id
title
variants(first: 5) {
nodes { id price sku inventoryQuantity }
}
}
}
}`,
{
variables: { first: 10 },
// Optional: Retry configuration
// retries: 2,
}
);
const { data, errors } = await response.json();
if (errors) {
console.error("GraphQL errors:", errors);
return { error: "Query failed" };
}
return { products: data.products.nodes };
};
https://{shop}/admin/api/2026-04/graphql.jsonX-Shopify-Access-Token from sessionResponse object — call .json() to parse{ retries: 2 } for automatic retry on rate limits@shopify/api-codegen-preset for TypeScript types# Fetch products with variants and inventory
query {
products(first: 50) {
nodes {
id
title
handle
status
vendor
productType
tags
totalInventory
variants(first: 50) {
nodes { id title price sku inventoryQuantity }
}
}
}
}
# Fetch orders with line items
query {
orders(first: 50, reverse: true) {
nodes {
id
name
createdAt
totalPriceSet { shopMoney { amount currencyCode } }
displayFinancialStatus
lineItems(first: 20) {
nodes { title quantity originalUnitPriceSet { shopMoney { amount } } }
}
}
}
}
# Bulk product update
mutation {
productUpdate(input: { id: "gid://shopify/Product/123", title: "New Title" }) {
product { id title }
}
}
The package does NOT expose admin.rest() natively in v1. For REST endpoints, use direct fetch with the session access token:
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
// Direct REST call
const response = await fetch(
`https://${session.shop}/admin/api/2026-04/orders.json?status=any&limit=250`,
{
headers: {
"X-Shopify-Access-Token": session.accessToken!,
"Content-Type": "application/json",
},
}
);
const data = await response.json();
return { orders: data.orders };
};
// /app/routes/webhooks.tsx (NOT nested under admin layout)
export const action = async ({ request }: ActionFunctionArgs) => {
const { topic, shop, payload, admin } =
await authenticate.webhook(request);
console.log(`Webhook: ${topic} from ${shop}`);
// Access the webhook body directly
// payload contains the parsed webhook body
// Optionally use admin API in the handler
if (topic === "PRODUCTS_CREATE") {
// Do something with the product
}
return new Response(null, { status: 200 });
};
| Topic | Trigger |
|---|---|
PRODUCTS_CREATE |
Product created |
PRODUCTS_UPDATE |
Product updated |
PRODUCTS_DELETE |
Product deleted |
ORDERS_CREATE |
Order created |
ORDERS_UPDATED |
Order updated |
ORDERS_CANCELLED |
Order cancelled |
ORDERS_FULFILLED |
Order fulfilled |
CUSTOMERS_CREATE |
Customer created |
APP_UNINSTALLED |
App uninstalled (clean up session) |
GDPR_REQUEST |
GDPR data request |
For background tasks (cron jobs, queue workers, 3rd-party webhooks):
import { unauthenticated } from "~/shopify.server";
async function processBackgroundJob(shop: string) {
const { admin } = await unauthenticated.admin(shop);
const response = await admin.graphql(
`#graphql
query { products(first: 10) { nodes { id title } } }
`,
);
// Process data...
}
app/ ├── shopify.server.ts # shopifyApp() config → default export ├── routes/ │ ├── app.tsx # Admin layout route (auth + AppBridge) │ ├── app._index.tsx # Admin home page │ ├── app.products.tsx # Product management page │ ├── app.orders.tsx # Order management page │ └── webhooks.tsx # Webhook handler (outside admin layout)
app/routes/app.tsx)import { Link, Outlet, useLoaderData, useRouteError } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server";
import { AppProvider } from "@shopify/shopify-app-react-router/react";
import { NavMenu } from "@shopify/app-bridge-react";
import { authenticate } from "~/shopify.server";
export const loader = async ({ request }) => {
await authenticate.admin(request);
return { apiKey: process.env.SHOPIFY_API_KEY || "" };
};
export default function App() {
const { apiKey } = useLoaderData();
return (
Home
Products
);
}
export function ErrorBoundary() {
return boundary.error(useRouteError());
}
export const headers = (args) => boundary.headers(args);
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import prisma from "./db.server";
const shopify = shopifyApp({
// ...
sessionStorage: new PrismaSessionStorage(prisma),
});
| Package | Storage |
|---|---|
@shopify/shopify-app-session-storage-prisma |
Prisma (any DB) |
@shopify/shopify-app-session-storage-postgresql |
PostgreSQL |
@shopify/shopify-app-session-storage-mysql |
MySQL |
@shopify/shopify-app-session-storage-mongodb |
MongoDB |
@shopify/shopify-app-session-storage-redis |
Redis |
@shopify/shopify-app-session-storage-dynamodb |
DynamoDB |
@shopify/shopify-app-session-storage-sqlite |
SQLite |
import { BillingInterval } from "@shopify/shopify-app-react-router/server";
// In shopifyApp config:
const shopify = shopifyApp({
billing: {
["My Plan"]: {
amount: 9.99,
currencyCode: "USD",
interval: BillingInterval.Monthly,
trialDays: 7,
},
},
// ...
});
// In a loader/action:
export const loader = async ({ request }) => {
const { billing, session } = await authenticate.admin(request);
// Check if shop has active subscription
const hasPayment = await billing.check({
plans: ["My Plan"],
isTest: true, // During development
});
if (!hasPayment) {
// Redirect to billing page
const billingUrl = await billing.request({
plan: "My Plan",
isTest: true,
});
return redirect(billingUrl);
}
return { hasPayment };
};
import { testConfig } from "@shopify/shopify-app-react-router/test-helpers";
// In shopify.server.ts:
let config = { apiKey: "...", apiSecretKey: "...", appUrl: "...", apiVersion: ApiVersion.April26 };
if (process.env.SHOPIFY_TESTING) {
Object.assign(config, testConfig());
}
// For e2e testing with real session storage:
if (process.env.SHOPIFY_TESTING === "e2e") {
Object.assign(config, testConfig({ sessionStorage }));
}
SHOPIFY_API_KEY, SHOPIFY_API_SECRET, SHOPIFY_APP_URLshopify.app.toml or Partner Dashboardshopify.app.toml or via Partner Dashboardcustomers/data_request, customers/redact, shop/redactRefer to references/admin-api-2026-04.md for the complete reference of commonly used GraphQL queries, mutations, and REST endpoints for the Shopify Admin API 2026-04 version, including product management, order processing, inventory control, customer data, and store configuration.
嵌入式应用直接持有店铺访问令牌,安全是底线。下列为必做项。
SHOPIFY_API_SECRET、accessToken 只能存在于服务端环境变量,绝对不要写进前端代码或通过 API 返回给浏览器。.env(已 gitignore),禁止提交 .env 到仓库;CI/CD 用平台密钥注入。authenticate.webhook(request),它会校验 X-Shopify-Hmac-SHA256 头。request.json() 后直接处理——未经校验的请求可能来自伪造源。401/500 让 Shopify 重试,不要用 200 吞掉错误。isOnline session 含用户级令牌,避免长期留存;离线任务用 unauthenticated.admin(shop)。APP_UNINSTALLED webhook 中必须删除该 shop 的所有 session,否则存在令牌永久泄露风险。write_*),缩小令牌泄露面。apiKey(公开信息),绝不下发 accessToken。boundary.headers() 维持 Shopify 要求的 CSP 头,防止 iframe 被劫持。评测指出原文档在「网络波动 / 限流 / 参数错误」时缺少清晰提示。本节给出可复制的容错模式。
import { authenticate } from "~/shopify.server";
// 统一错误处理包装:区分鉴权失败 / 参数错误 / 限流 / 未知
async function withAdmin(
request: Request,
fn: (admin: any, session: any) => Promise
): Promise {
try {
const { admin, session } = await authenticate.admin(request);
const result = await fn(admin, session);
return Response.json({ ok: true, data: result });
} catch (err) {
const status = (err as any)?.response?.status ?? 500;
console.error("[shopify-app] request failed:", err);
return Response.json(
{ ok: false, error: (err as Error).message, code: status },
{ status: status >= 400 && status < 500 ? status : 500 }
);
}
}
admin.graphql() 即便业务出错也常返回 HTTP 200,错误在 errors 数组里:
const res = await admin.graphql(query, { variables });
const { data, errors } = await res.json();
if (errors?.length) {
// errors: [{ message, path, extensions: { code } }]
// 常见 code: USER_ERROR / THROTTLED / GRAPHQL_VALIDATION_ERROR
console.error("GraphQL errors:", errors);
const msg = errors.map((e: any) => e.message).join("; ");
return Response.json({ ok: false, error: msg }, { status: 422 });
}
const res = await fetch(url, { headers });
if (!res.ok) {
// 429 = 限流, 401 = token 失效, 422 = 参数/业务错误, 404 = 资源不存在
const body = await res.text();
console.error(`REST ${res.status}:`, body);
return Response.json({ ok: false, error: body, code: res.status }, { status: res.status });
}
extensions.code === "THROTTLED"。Retry-After 给出等待秒数。Retry-After → 指数退避重试;批量任务用游标分页 + 限速;admin.graphql(query, { retries: 2 }) 开启自动重试。fetch 包一层超时(AbortController,建议 10s)与最多 3 次指数退避。*.myshopify.com / admin/api 需稳定外网;国内网络不稳时优先部署到海外节点或加代理。payload.id 去重。ErrorBoundary 调用 boundary.error(useRouteError()),保证异常页符合 Shopify CSP,不会白屏。appUrl 与回调一致。errors[].extensions.code(USER_ERROR=参数 / THROTTLED=限流 / VALIDATION=语法)。Retry-After 退避,开启 retries,降频。authenticate.webhook,不要手动 parse。boundary.headers() 与 isEmbeddedApp: true。shopify.server.ts 导出 authenticate / unauthenticated / sessionStorage。app.products.tsx、app.orders.tsx);Webhook 路由 不嵌套 在 admin 布局下。query Products($cursor: String) {
products(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { id title }
}
}
pageInfo.endCursor 循环,直到 hasNextPage=false,不要用偏移分页。bulkOperationRunMutation;导入导出用 productImport / CSV。products(first: N) 控制 N,避免单次请求过大触发超时。... on Product { * } 全量拉取。id 做幂等键,防止重复处理导致数据错乱。future: { v3_authenticatePublic: true } 等,提前适配破坏性变更,减少升级成本。testConfig() 注入假 session;e2e 用 testConfig({ sessionStorage }) 接真库。authenticate、graphql、webhook 校验分别写用例。| 症状 | 原因 | 解决 |
|---|---|---|
422 Unprocessable Entity 调 Admin API |
库存/必填字段缺失(如新建变体未设 inventoryItem) |
设置 inventory_policy=continue、inventory_management=shopify,并确保 location 级库存已建 |
GraphQL error: Field 'X' doesn't exist |
API 版本字段差异 | 确认用 ApiVersion.April26(2026-04),字段以 references 文档为准 |
Missing required argument: |
mutation 参数缺失 | 检查 input 对象是否补全必填字段,参考 references 的 mutation 签名 |
Failed to validate session / 登录循环 |
session 存储失效或在线 session 过期 | 确认 sessionStorage 持久化;线上用 Redis/PG;检查 appUrl 与回调一致 |
| Webhook 返回 401 | HMAC 校验失败 | 只能用 authenticate.webhook(request),不要手动 parse body 后再校验 |
| CORS 报错(Embedded 扩展) | 跨域响应缺头 | 用 cors(data) 包裹返回:return cors({ ... }) |
| 应用无法嵌入 / 白屏 | CSP / App Bridge 配置 | 路由导出 headers = (a) => boundary.headers(a);确认 isEmbeddedApp: true |
429 Too Many Requests |
触限流 | 读 Retry-After 退避;开启 retries;降低请求频率 |
| 开发时连不上 Shopify | 国内网络/隧道不稳 | 用稳定 HTTPS 隧道;或把服务部署到海外节点 |
Scope 被拒 / 权限不足 |
申请的 scope 超出业务 | 收窄 scope 并在 Partner Dashboard 复核 |
| 反模式 | 为什么会出问题 | 正确做法 |
|---|---|---|
在前端代码/API 返回里暴露 accessToken |
令牌泄露 = 店铺被完全接管 | 只下发 apiKey 到浏览器;令牌仅留服务端 env |
用内存 sessionStorage 上线 |
多实例/重启后 session 丢失,全员重新登录 | 生产用 Redis/PostgreSQL 等持久化存储 |
Webhook 路由手动 request.json() 后直接处理 |
跳过 HMAC 校验,伪造请求可执行 | 永远走 authenticate.webhook(request) |
| Webhook 路由 嵌套 在 admin layout 下 | admin 布局要求鉴权,webhook 无 session 会 401 | webhook 路由独立、不套 admin 布局 |
用 offset/pageInfo 之外的方式分页大表 |
大表越翻越慢甚至超时 | 用 after: $cursor + pageInfo.endCursor 游标分页 |
| 忽略 429 直接重试 | 触发更长时间封禁 | 读 Retry-After 做指数退避;开启 retries |
把 SHOPIFY_API_SECRET 提交进仓库 |
密钥泄露 | .env gitignore + CI 用平台密钥注入 |
| 不实现 GDPR webhook | 上架审核被拒 / 合规风险 | 必做 customers/data_request、customers/redact、shop/redact |
申请过多 write_* scope |
审核被拒、令牌面过大 | 只申请业务必需的最小 scope |
myshopify.com/apps/ 暴露服务端路由,用 authenticate.public(request) 校验来源,适合主题内嵌区块拉取应用数据。node-cron / 平台定时器)调 unauthenticated.admin(shop),处理日报、同步等;注意限流与幂等。customers/data_request、customers/redact、shop/redact:customers/data_request:导出该客户数据并返回。customers/redact:删除客户 PII。shop/redact:应用卸载后删除店铺全部关联数据(含 session)。sessionStorage 以 shop 为键隔离;离线任务用 unauthenticated.admin(shop) 按店铺取令牌。评测指出「国内适配性」仅 3.8:内容依赖海外服务、国内需访问国外资源。本节给出可落地的适配建议。
*.myshopify.com、admin/api)均在海外,国内直接访问可能不稳定或被拦截。cloudflared / ngrok)暴露本地,SHOPIFY_APP_URL 必须 HTTPS;隧道域名变化后同步更新 Partner Dashboard 的 App URL / Redirect URL。npm install @shopify/* 需访问 npm 海外源;国内可配置镜像(如 registry.npmmirror.com),但运行时依赖仍走 Shopify 海外 API。