shopify-app-development
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 devThis 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-remixwhich handles the OAuth dance. Inapp/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 onClick={handleSelectProducts}>Select 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.locationnavigation 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_VERSIONin development only — pin to a specific version (e.g.,2025-01) in production to avoid breaking changes - Handle
payment_requirederrors — 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