app-shopify-app-api-patterns
SKILL.md
Shopify App API Patterns
Use this skill when building frontend features that communicate with your app's backend in a Shopify Remix app.
When to Use
- Adding new pages that fetch data from Shopify or your database
- Creating forms that submit data (mutations)
- Using
useFetcherfor client-side data operations - Handling authenticated sessions in routes
- Building APIs for app extensions or external services
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Shopify Admin │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Your App (iframe) │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ Frontend │ ───── │ Remix Backend │ │ │
│ │ │ (React) │ │ (loaders/actions) │ │ │
│ │ └──────────────┘ └──────────────────────┘ │ │
│ │ │ │ │
│ └────────────────────────────────────│────────────────┘ │
│ │ │
└───────────────────────────────────────│──────────────────────┘
│
┌────────────┴────────────┐
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Prisma │ │ Shopify │
│ (your DB) │ │ Admin API │
└─────────────┘ └─────────────┘
Data Fetching with Loaders
Basic Loader Pattern
// 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>
);
}
Loader with URL Parameters
// 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 });
};
Data Mutations with Actions
Form Submission Pattern
// 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>
);
}
Client-Side Fetching with useFetcher
For operations that shouldn't cause navigation (inline updates, toggles, etc.):
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 Routes for Extensions/External Services
Authenticated API Endpoint
// 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);
};
Public API (Webhooks, Callbacks)
// 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 Handling
Getting Session in Any Route
// 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
Checking 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...
};
File Structure Convention
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
Best Practices
- Always authenticate - Use
authenticate.admin(request)for all app routes - Validate ownership - When fetching by ID, always filter by
session.shop - Use actions for mutations - Don't mutate in loaders
- Handle loading states - Use
navigation.stateorfetcher.state - Return proper HTTP codes - 404 for not found, 400 for bad requests
- Type your data - Use
useLoaderData<typeof loader>()for type safety - Validate and sanitize input - Never trust user input; validate format and whitelist allowed fields
- Avoid mass assignment - Never pass raw request body directly to database; explicitly select allowed fields
References
Weekly Installs
12
Repository
niccos-shopify-…r-skillsGitHub Stars
2
First Seen
Feb 9, 2026
Security Audits
Installed on
opencode11
gemini-cli11
github-copilot11
codex11
kimi-cli11
amp11