关于 shopify-app-api-patterns
为 Shopify Remix 应用开发前端与后端交互功能的技能,涵盖认证数据加载、修改操作、会话处理和 API 路由。
主要功能
- 01使用 Remix loader 获取数据
- 02通过 action 提交表单修改
- 03用 useFetcher 执行客户端更新
- 04处理认证路由与会话检查
- 05为扩展和 webhook 创建 API 路由
适用场景
- 01添加获取 Shopify 或数据库数据的页面
- 02构建提交后端修改的表单
- 03处理认证会话和权限范围检查
- 04为扩展或外部服务提供路由
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ tamiror6/ shopify-app-skills --skill shopify-app-api-patterns安装后在 Claude Code 中输入/shopify-app-api-patterns确认已加载。
详细介绍
Shopify 应用 API 模式
在 Shopify Remix 应用中构建与应用后端通信的前端功能时,请使用此技能。
适用场景
- 添加从 Shopify 或数据库获取数据的新页面
- 创建提交数据的表单(变更操作)
- 使用
useFetcher执行客户端数据操作 - 在路由中处理经过身份验证的会话
- 为应用扩展或外部服务构建 API
架构概览
┌─────────────────────────────────────────────────────────────┐
│ Shopify Admin │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Your App (iframe) │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ Frontend │ ───── │ Remix Backend │ │ │
│ │ │ (React) │ │ (loaders/actions) │ │ │
│ │ └──────────────┘ └──────────────────────┘ │ │
│ │ │ │ │
│ └────────────────────────────────────│────────────────┘ │
│ │ │
└───────────────────────────────────────│──────────────────────┘
│
┌────────────┴────────────┐
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Prisma │ │ Shopify │
│ (your DB) │ │ Admin API │
└─────────────┘ └─────────────┘
使用 Loader 获取数据
基本 Loader 模式
// app/routes/app.dashboard.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
// Authenticate and get admin API access
const { session, admin } = await authenticate.admin(request);
// Fetch from Shopify
const shopResponse = await admin.graphql(`
query { shop { name myshopifyDomain } }
`);
const { data: shopData } = await shopResponse.json();
// Fetch from your database
const settings = await db.appSettings.findUnique({
where: { shop: session.shop }
});
return json({
shop: shopData.shop,
settings
});
};
export default function Dashboard() {
const { shop, settings } = useLoaderData<typeof loader>();
return (
<Page title={`Dashboard - ${shop.name}`}>
{/* Your UI */}
</Page>
);
}
带 URL 参数的 Loader
// app/routes/app.campaigns.$id.tsx
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const { id } = params;
const campaign = await db.campaign.findFirst({
where: {
id,
shop: session.shop
}
});
if (!campaign) {
throw new Response("Not found", { status: 404 });
}
return json({ campaign });
};
使用 Action 变更数据
表单提交模式
// app/routes/app.settings.tsx
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { authenticate } from "../shopify.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "updateSettings") {
const enabled = formData.get("enabled") === "true";
const message = formData.get("message") as string;
await db.appSettings.upsert({
where: { shop: session.shop },
create: { shop: session.shop, enabled, message },
update: { enabled, message }
});
return json({ success: true });
}
return json({ error: "Unknown action" }, { status: 400 });
};
export default function Settings() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<input type="hidden" name="intent" value="updateSettings" />
{/* Form fields */}
<Button submit loading={isSubmitting}>
Save
</Button>
</Form>
);
}
使用 useFetcher 在客户端获取数据
对于不应触发页面导航的操作(行内更新、切换等):
import { useFetcher } from "@remix-run/react";
function CampaignRow({ campaign }) {
const fetcher = useFetcher();
const isUpdating = fetcher.state !== "idle";
const toggleStatus = () => {
fetcher.submit(
{
intent: "toggleStatus",
campaignId: campaign.id,
enabled: String(!campaign.enabled)
},
{ method: "post", action: "/app/campaigns" }
);
};
return (
<ResourceItem id={campaign.id}>
<Text>{campaign.name}</Text>
<Button
onClick={toggleStatus}
loading={isUpdating}
>
{campaign.enabled ? "Disable" : "Enable"}
</Button>
</ResourceItem>
);
}
面向扩展/外部服务的 API 路由
经过身份验证的 API 端点
// app/routes/api.widget-config.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
// For app proxy requests or authenticated API calls
const { session } = await authenticate.admin(request);
const config = await db.widgetConfig.findUnique({
where: { shop: session.shop }
});
return json(config);
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const body = await request.json();
// Whitelist allowed fields - never pass raw body to database
const { theme, position, welcomeMessage } = body;
const updated = await db.widgetConfig.update({
where: { shop: session.shop },
data: { theme, position, welcomeMessage }
});
return json(updated);
};
公共 API(Webhook、回调)
// app/routes/webhooks.tsx
import { type ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { topic, shop, payload } = await authenticate.webhook(request);
switch (topic) {
case "ORDERS_CREATE":
await handleOrderCreated(shop, payload);
break;
case "APP_UNINSTALLED":
await handleAppUninstalled(shop);
break;
}
return new Response();
};
会话处理
在任意路由中获取会话
// Session is available after authenticate.admin()
const { session, admin } = await authenticate.admin(request);
// session contains:
// - session.shop: "store.myshopify.com"
// - session.accessToken: OAuth token
// - session.scope: granted scopes
检查作用域
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const hasOrdersScope = session.scope?.includes("read_orders");
if (!hasOrdersScope) {
// Redirect to re-auth or show error
throw new Response("Missing required scope", { status: 403 });
}
// Continue...
};
文件结构约定
app/
├── routes/
│ ├── app._index.tsx # /app (dashboard)
│ ├── app.settings.tsx # /app/settings
│ ├── app.campaigns._index.tsx # /app/campaigns (list)
│ ├── app.campaigns.$id.tsx # /app/campaigns/:id (detail)
│ ├── app.campaigns.new.tsx # /app/campaigns/new (create)
│ ├── api.widget-config.tsx # /api/widget-config
│ └── webhooks.tsx # /webhooks
├── components/
│ └── ...
├── shopify.server.ts # Shopify app config
└── db.server.ts # Prisma client
最佳实践
- 始终进行身份验证 - 对所有应用路由使用
authenticate.admin(request) - 验证所有权 - 按 ID 获取数据时,始终使用
session.shop进行筛选 - 使用 Action 执行变更操作 - 不要在 Loader 中变更数据
- 处理加载状态 - 使用
navigation.state或fetcher.state - 返回正确的 HTTP 状态码 - 未找到时返回 404,请求错误时返回 400
- 为数据添加类型 - 使用
useLoaderData<typeof loader>()确保类型安全 - 验证并清理输入 - 切勿信任用户输入;验证格式并将允许的字段列入白名单
- 避免批量赋值 - 切勿将原始请求正文直接传递给数据库;明确选择允许的字段
参考资料
评论
支持 Markdown