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
useFetcher
for 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.state or fetcher.state Return proper HTTP codes - 404 for not found, 400 for bad requests Type your data - Use useLoaderData
() 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 Remix Data Loading Remix Data Mutations @shopify/shopify-app-remix Authentication Shopify App Proxy