Shopify App Development
Overview
Build Shopify apps using the Shopify CLI 3.x Remix template, which handles OAuth token exchange, session storage, and App Bridge initialization automatically. Embedded apps run inside the Shopify Admin iframe and use Polaris for a native-feeling UI. The modern approach uses the Remix-based @shopify/shopify-app-remix package rather than the legacy Express template.
When to Use This Skill
- When building a public or custom Shopify app that extends Admin functionality
- When creating an embedded app that merchants install from the Shopify App Store
- When implementing OAuth for the first time with session persistence across reinstalls
- When needing to access the Admin API on behalf of authenticated merchants
- When building merchant-facing tooling with Shopify's Polaris design system
- When replacing an older Express/koa-based Shopify app with the modern Remix stack
Core Instructions
Scaffold the app with Shopify CLI
npm install -g @shopify/cli @shopify/theme
shopify app init my-shopify-app
# Choose: Remix template
cd my-shopify-app
shopify app dev
This scaffolds a Remix app with OAuth, session storage (SQLite by default), and App Bridge already wired up. The dev command tunnels your local server via Cloudflare and installs the app on your Partner development store.
Understand the OAuth flow and session handling
The scaffold uses @shopify/shopify-app-remix which handles the OAuth dance. In app/shopify.server.ts:
import "@shopify/shopify-app-remix/adapters/node";
import {
AppDistribution,
DeliveryMethod,
shopifyApp,
LATEST_API_VERSION,
} from "@shopify/shopify-app-remix/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
apiVersion: LATEST_API_VERSION,
scopes: process.env.SCOPES?.split(","),
appUrl: process.env.SHOPIFY_APP_URL || "",
authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(prisma),
distribution: AppDistribution.AppStore,
webhooks: {
APP_UNINSTALLED: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: "/webhooks",
},
},
hooks: {
afterAuth: async ({ session }) => {
shopify.registerWebhooks({ session });
},
},
});
export default shopify;
export const authenticate = shopify.authenticate;
Protect routes and call the Admin API
Any loader or action that needs Admin API access calls authenticate.admin:
// app/routes/app._index.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { authenticate } from "../shopify.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
// GraphQL Admin API call
const response = await admin.graphql(`
query {
shop {
name
email
primaryDomain { url }
}
}
`);
const { data } = await response.json();
return json({ shop: data.shop });
};
export default function Index() {
const { shop } = useLoaderData<typeof loader>();
return <Page title={`Hello, ${shop.name}`} />;
}
Build UI with Polaris components
// app/routes/app.products.tsx
import {
Page,
Layout,
Card,
DataTable,
Button,
Banner,
} from "@shopify/polaris";
import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
export default function ProductsPage() {
const shopify = useAppBridge();
const handleSave = async () => {
// Use App Bridge Toast for notifications inside the iframe
shopify.toast.show("Products updated successfully");
};
return (
<Page>
<TitleBar title="Products" primaryAction={{ content: "Save", onAction: handleSave }} />
<Layout>
<Layout.Section>
<Card>
<DataTable
columnContentTypes={["text", "numeric", "numeric"]}
headings={["Product", "Price", "Inventory"]}
rows={[["Widget A", "$19.99", 42]]}
/>
</Card>
</Layout.Section>
</Layout>
</Page>
);
}
Configure scopes and handle app reinstallation
Define required scopes in shopify.app.toml:
name = "my-shopify-app"
client_id = "your_api_key"
application_url = "https://your-app.fly.dev"
embedded = true
[access_scopes]
scopes = "read_products,write_products,read_orders"
[webhooks]
api_version = "2025-01"
[[webhooks.subscriptions]]
topics = ["app/uninstalled"]
uri = "/webhooks"
Handle the GDPR mandatory webhooks (customers/data_request, customers/redact, shop/redact) even if your app does not store personal data — Shopify requires these endpoints.
Deploy to production
shopify app deploy
# Deploys to Shopify (functions/extensions)
# Deploy the Remix server separately (Fly.io, Railway, Render)
fly launch
fly deploy
Examples
Mutation via Admin GraphQL API
// Create a product via the Admin API inside a Remix action
export const action = async ({ request }: ActionFunctionArgs) => {
const { admin } = await authenticate.admin(request);
const response = await admin.graphql(
`#graphql
mutation CreateProduct($input: ProductInput!) {
productCreate(input: $input) {
product {
id
title
handle
}
userErrors {
field
message
}
}
}`,
{
variables: {
input: {
title: "New Product",
vendor: "My Store",
productType: "Widget",
tags: ["new", "featured"],
},
},
}
);
const { data } = await response.json();
if (data.productCreate.userErrors.length > 0) {
return json({ errors: data.productCreate.userErrors }, { status: 422 });
}
return json({ product: data.productCreate.product });
};
App Bridge Resource Picker (v4)
import { useAppBridge } from "@shopify/app-bridge-react";
import { useState } from "react";
import { Button } from "@shopify/polaris";
export default function ProductSelector() {
const shopify = useAppBridge();
const [selected, setSelected] = useState<string[]>([]);
const handleSelectProducts = async () => {
const selection = await shopify.resourcePicker({
type: "product",
multiple: true,
});
if (selection) {
setSelected(selection.map((p) => p.id));
}
};
return (
<>
<Button Products</Button>
<p>Selected IDs: {selected.join(", ")}</p>
</>
);
}
Best Practices
- Use the Remix CLI template — it handles session storage, CSRF, OAuth token refresh, and frame-ancestor CSP headers automatically
- Store sessions in a persistent database (Prisma + PostgreSQL in production) — the default SQLite storage is unsuitable for multi-instance deployments
- Scope creep hurts conversion — only request the minimum scopes needed; merchants see the scope list during installation
- Use App Bridge for navigation and modals — direct
window.location navigation breaks the embedded iframe context
- Validate webhook HMAC signatures — even for mandatory GDPR webhooks that you don't act on
- Test reinstall flows — merchants who uninstall and reinstall must receive fresh OAuth tokens without stale session data
- Use
LATEST_API_VERSION in development only — pin to a specific version (e.g., 2025-01) in production to avoid breaking changes
- Handle
payment_required errors — Apps on the App Store may encounter billing requirement errors if merchants exceed their plan
Common Pitfalls
| Problem |
Solution |
| "Refused to display in frame" CSP error |
Ensure your Remix server returns frame-ancestors https://*.myshopify.com https://admin.shopify.com in Content-Security-Policy |
| OAuth redirect loop after install |
Check that your app URL in shopify.app.toml matches the URL your server is reachable at — mismatch causes infinite redirects |
| Session not found on subsequent requests |
Use a persistent session storage (Prisma/PostgreSQL); SQLite doesn't work across Fly.io or Render instances |
App Bridge useAppBridge() returns null |
Wrap your Remix app root with <AppProvider> from @shopify/shopify-app-remix/react |
| Webhooks registered but not firing |
Webhooks registered during afterAuth may not persist after app update — call shopify.registerWebhooks in a separate route for verification |
| "Invalid HMAC" on webhook endpoint |
Ensure raw body is read before any JSON parsing middleware — use getRawBody before Express/Remix body parsing |
Related Skills
- @shopify-admin-api
- @shopify-webhooks
- @shopify-checkout-extensions
- @shopify-storefront-api
- @oauth-implementation
1---2name: shopify-app-development3description: Build embedded Shopify apps using the Remix framework, App Bridge for UI integration, Polaris components, and OAuth authentication flow4---56# Shopify App Development78## Overview910Build Shopify apps using the Shopify CLI 3.x Remix template, which handles OAuth token exchange, session storage, and App Bridge initialization automatically. Embedded apps run inside the Shopify Admin iframe and use Polaris for a native-feeling UI. The modern approach uses the Remix-based `@shopify/shopify-app-remix` package rather than the legacy Express template.1112## When to Use This Skill1314- When building a public or custom Shopify app that extends Admin functionality15- When creating an embedded app that merchants install from the Shopify App Store16- When implementing OAuth for the first time with session persistence across reinstalls17- When needing to access the Admin API on behalf of authenticated merchants18- When building merchant-facing tooling with Shopify's Polaris design system19- When replacing an older Express/koa-based Shopify app with the modern Remix stack2021## Core Instructions22231. **Scaffold the app with Shopify CLI**2425 ```bash26 npm install -g @shopify/cli @shopify/theme27 shopify app init my-shopify-app28 # Choose: Remix template29 cd my-shopify-app30 shopify app dev31 ```3233 This scaffolds a Remix app with OAuth, session storage (SQLite by default), and App Bridge already wired up. The dev command tunnels your local server via Cloudflare and installs the app on your Partner development store.34352. **Understand the OAuth flow and session handling**3637 The scaffold uses `@shopify/shopify-app-remix` which handles the OAuth dance. In `app/shopify.server.ts`:3839 ```typescript40 import "@shopify/shopify-app-remix/adapters/node";41 import {42 AppDistribution,43 DeliveryMethod,44 shopifyApp,45 LATEST_API_VERSION,46 } from "@shopify/shopify-app-remix/server";47 import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";48 import { PrismaClient } from "@prisma/client";4950 const prisma = new PrismaClient();5152 const shopify = shopifyApp({53 apiKey: process.env.SHOPIFY_API_KEY,54 apiSecretKey: process.env.SHOPIFY_API_SECRET || "",55 apiVersion: LATEST_API_VERSION,56 scopes: process.env.SCOPES?.split(","),57 appUrl: process.env.SHOPIFY_APP_URL || "",58 authPathPrefix: "/auth",59 sessionStorage: new PrismaSessionStorage(prisma),60 distribution: AppDistribution.AppStore,61 webhooks: {62 APP_UNINSTALLED: {63 deliveryMethod: DeliveryMethod.Http,64 callbackUrl: "/webhooks",65 },66 },67 hooks: {68 afterAuth: async ({ session }) => {69 shopify.registerWebhooks({ session });70 },71 },72 });7374 export default shopify;75 export const authenticate = shopify.authenticate;76 ```77783. **Protect routes and call the Admin API**7980 Any loader or action that needs Admin API access calls `authenticate.admin`:8182 ```typescript83 // app/routes/app._index.tsx84 import { json } from "@remix-run/node";85 import { useLoaderData } from "@remix-run/react";86 import { authenticate } from "../shopify.server";8788 export const loader = async ({ request }: LoaderFunctionArgs) => {89 const { admin, session } = await authenticate.admin(request);9091 // GraphQL Admin API call92 const response = await admin.graphql(`93 query {94 shop {95 name96 email97 primaryDomain { url }98 }99 }100 `);101 const { data } = await response.json();102 return json({ shop: data.shop });103 };104105 export default function Index() {106 const { shop } = useLoaderData<typeof loader>();107 return <Page title={`Hello, ${shop.name}`} />;108 }109 ```1101114. **Build UI with Polaris components**112113 ```typescript114 // app/routes/app.products.tsx115 import {116 Page,117 Layout,118 Card,119 DataTable,120 Button,121 Banner,122 } from "@shopify/polaris";123 import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";124125 export default function ProductsPage() {126 const shopify = useAppBridge();127128 const handleSave = async () => {129 // Use App Bridge Toast for notifications inside the iframe130 shopify.toast.show("Products updated successfully");131 };132133 return (134 <Page>135 <TitleBar title="Products" primaryAction={{ content: "Save", onAction: handleSave }} />136 <Layout>137 <Layout.Section>138 <Card>139 <DataTable140 columnContentTypes={["text", "numeric", "numeric"]}141 headings={["Product", "Price", "Inventory"]}142 rows={[["Widget A", "$19.99", 42]]}143 />144 </Card>145 </Layout.Section>146 </Layout>147 </Page>148 );149 }150 ```1511525. **Configure scopes and handle app reinstallation**153154 Define required scopes in `shopify.app.toml`:155156 ```toml157 name = "my-shopify-app"158 client_id = "your_api_key"159 application_url = "https://your-app.fly.dev"160 embedded = true161162 [access_scopes]163 scopes = "read_products,write_products,read_orders"164165 [webhooks]166 api_version = "2025-01"167168 [[webhooks.subscriptions]]169 topics = ["app/uninstalled"]170 uri = "/webhooks"171 ```172173 Handle the GDPR mandatory webhooks (`customers/data_request`, `customers/redact`, `shop/redact`) even if your app does not store personal data — Shopify requires these endpoints.1741756. **Deploy to production**176177 ```bash178 shopify app deploy179 # Deploys to Shopify (functions/extensions)180 # Deploy the Remix server separately (Fly.io, Railway, Render)181 fly launch182 fly deploy183 ```184185## Examples186187### Mutation via Admin GraphQL API188189```typescript190// Create a product via the Admin API inside a Remix action191export const action = async ({ request }: ActionFunctionArgs) => {192 const { admin } = await authenticate.admin(request);193194 const response = await admin.graphql(195 `#graphql196 mutation CreateProduct($input: ProductInput!) {197 productCreate(input: $input) {198 product {199 id200 title201 handle202 }203 userErrors {204 field205 message206 }207 }208 }`,209 {210 variables: {211 input: {212 title: "New Product",213 vendor: "My Store",214 productType: "Widget",215 tags: ["new", "featured"],216 },217 },218 }219 );220221 const { data } = await response.json();222 if (data.productCreate.userErrors.length > 0) {223 return json({ errors: data.productCreate.userErrors }, { status: 422 });224 }225 return json({ product: data.productCreate.product });226};227```228229### App Bridge Resource Picker (v4)230231```typescript232import { useAppBridge } from "@shopify/app-bridge-react";233import { useState } from "react";234import { Button } from "@shopify/polaris";235236export default function ProductSelector() {237 const shopify = useAppBridge();238 const [selected, setSelected] = useState<string[]>([]);239240 const handleSelectProducts = async () => {241 const selection = await shopify.resourcePicker({242 type: "product",243 multiple: true,244 });245246 if (selection) {247 setSelected(selection.map((p) => p.id));248 }249 };250251 return (252 <>253 <Button onClick={handleSelectProducts}>Select Products</Button>254 <p>Selected IDs: {selected.join(", ")}</p>255 </>256 );257}258```259260## Best Practices261262- **Use the Remix CLI template** — it handles session storage, CSRF, OAuth token refresh, and frame-ancestor CSP headers automatically263- **Store sessions in a persistent database** (Prisma + PostgreSQL in production) — the default SQLite storage is unsuitable for multi-instance deployments264- **Scope creep hurts conversion** — only request the minimum scopes needed; merchants see the scope list during installation265- **Use App Bridge for navigation and modals** — direct `window.location` navigation breaks the embedded iframe context266- **Validate webhook HMAC signatures** — even for mandatory GDPR webhooks that you don't act on267- **Test reinstall flows** — merchants who uninstall and reinstall must receive fresh OAuth tokens without stale session data268- **Use `LATEST_API_VERSION` in development only** — pin to a specific version (e.g., `2025-01`) in production to avoid breaking changes269- **Handle `payment_required` errors** — Apps on the App Store may encounter billing requirement errors if merchants exceed their plan270271## Common Pitfalls272273| Problem | Solution |274|---------|----------|275| "Refused to display in frame" CSP error | Ensure your Remix server returns `frame-ancestors https://*.myshopify.com https://admin.shopify.com` in Content-Security-Policy |276| OAuth redirect loop after install | Check that your app URL in `shopify.app.toml` matches the URL your server is reachable at — mismatch causes infinite redirects |277| Session not found on subsequent requests | Use a persistent session storage (Prisma/PostgreSQL); SQLite doesn't work across Fly.io or Render instances |278| App Bridge `useAppBridge()` returns null | Wrap your Remix app root with `<AppProvider>` from `@shopify/shopify-app-remix/react` |279| Webhooks registered but not firing | Webhooks registered during `afterAuth` may not persist after app update — call `shopify.registerWebhooks` in a separate route for verification |280| "Invalid HMAC" on webhook endpoint | Ensure raw body is read before any JSON parsing middleware — use `getRawBody` before Express/Remix body parsing |281282## Related Skills283284- @shopify-admin-api285- @shopify-webhooks286- @shopify-checkout-extensions287- @shopify-storefront-api288- @oauth-implementation