Developing a Custom Shopify App
A Shopify App is a web application integrated into the Shopify ecosystem via OAuth 2.0 and API. Custom apps are needed when App Store functionality is missing, doesn't scale for your needs, or requires deep integration with internal company systems.
Types of Shopify Apps
Public App — published to the App Store, installable by any shop. Requires Shopify review. Monetized via subscription.
Custom App — created for a specific shop within its Admin. Not published, no review required. Installed via direct link or API token. Optimal for corporate automation.
Embedded App — displayed within Shopify Admin via iframe. Uses Shopify App Bridge for host interface communication.
Technology Stack
The officially recommended Shopify stack:
- Backend: Node.js (Express) or Ruby on Rails, rarely PHP
- Frontend: React + Shopify Polaris (design system for Admin UI)
-
App Bridge:
@shopify/app-bridge-react— communication with Shopify Admin -
API client:
@shopify/shopify-api(Node) /shopify_api(Ruby gem) - Database: PostgreSQL / MySQL for application data
- Hosting: Heroku, Railway, Fly.io, own VPS with nginx
Scaffold via Shopify CLI:
shopify app init my-custom-app
# Select: Node.js + React
cd my-custom-app
shopify app dev
OAuth Flow and Authentication
// web/index.js — Express + @shopify/shopify-api
import { shopifyApp } from '@shopify/shopify-app-express';
import { PostgreSQLSessionStorage } from '@shopify/shopify-app-session-storage-postgresql';
const shopify = shopifyApp({
api: {
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET,
scopes: ['read_products', 'write_products', 'read_orders', 'write_orders'],
hostName: process.env.HOST.replace(/https?:\/\//, ''),
apiVersion: ApiVersion.January25,
},
auth: {
path: '/api/auth',
callbackPath: '/api/auth/callback',
},
webhooks: {
path: '/api/webhooks',
},
sessionStorage: new PostgreSQLSessionStorage(process.env.DATABASE_URL),
});
app.get(shopify.config.auth.path, shopify.auth.begin());
app.get(shopify.config.auth.callbackPath, shopify.auth.callback(), shopify.redirectToShopifyOrAppRoot());
After OAuth, Shopify returns an offline token (long-lived) and online token (session). The offline token is stored in the app's database and used for background tasks.
Admin API: Working with Store Data
// Fetch orders via GraphQL Admin API
const client = new shopify.api.clients.Graphql({ session });
const response = await client.query({
data: `{
orders(first: 50, query: "financial_status:paid created_at:>2025-01-01") {
edges {
node {
id
name
totalPriceSet {
shopMoney { amount currencyCode }
}
lineItems(first: 20) {
edges {
node {
title
quantity
variant {
sku
inventoryQuantity
}
}
}
}
shippingAddress {
city
countryCode
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}`
});
Pagination uses cursor-based (not offset). For >250 objects, iterate with endCursor.
Webhooks
Webhooks asynchronously receive events from Shopify without polling:
// Register webhook
shopify.webhooks.addHandlers({
ORDERS_PAID: [{
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/api/webhooks/orders-paid',
callback: async (topic, shop, body, webhookId) => {
const order = JSON.parse(body);
// Sync with ERP, send to CRM, create warehouse task
await syncOrderToERP(shop, order);
}
}],
PRODUCTS_UPDATE: [{
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/api/webhooks/products-update',
callback: async (topic, shop, body) => {
const product = JSON.parse(body);
await invalidateProductCache(shop, product.id);
}
}]
});
Shopify requires a 200 OK response within 5 seconds — move heavy processing to a queue (Bull, BullMQ, Sidekiq).
Polaris UI in Embedded App
// web/frontend/pages/Dashboard.jsx
import {
Page, Layout, Card, DataTable, Badge, Button, Toast
} from '@shopify/polaris';
import { useAppBridge } from '@shopify/app-bridge-react';
import { Redirect } from '@shopify/app-bridge/actions';
export default function Dashboard() {
const app = useAppBridge();
const rows = orders.map(order => [
order.name,
<Badge status={order.financial_status === 'paid' ? 'success' : 'warning'}>
{order.financial_status}
</Badge>,
order.total_price,
<Button onClick={() => {
const redirect = Redirect.create(app);
redirect.dispatch(Redirect.Action.ADMIN_PATH, `/orders/${order.id}`);
}}>Open</Button>
]);
return (
<Page title="Order Management" primaryAction={{ content: 'Export', onAction: handleExport }}>
<Layout>
<Layout.Section>
<Card>
<DataTable
columnContentTypes={['text', 'text', 'numeric', 'text']}
headings={['Order', 'Payment Status', 'Amount', 'Action']}
rows={rows}
/>
</Card>
</Layout.Section>
</Layout>
</Page>
);
}
App Extensions
A custom app can add extensions to various parts of Shopify:
- Theme App Extension — blocks in the theme (widgets, buttons, banners)
- Checkout UI Extension — custom UI in checkout (Plus only or via Function)
- Admin UI Extension — additional blocks on Admin pages
- Shopify Functions — server-side business logic (discounts, shipping, validation)
# Add Theme App Extension to your app
shopify app generate extension --template theme_app_extension --name my-widget
Background Tasks and Queues
// BullMQ queue handler
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';
const connection = new Redis(process.env.REDIS_URL);
export const syncQueue = new Queue('erp-sync', { connection });
const worker = new Worker('erp-sync', async (job) => {
const { shopDomain, orderId } = job.data;
const session = await loadSessionFromDB(shopDomain);
const client = new shopify.api.clients.Rest({ session });
const order = await client.get({ path: `orders/${orderId}` });
await postToERP(order.body.order);
}, { connection, concurrency: 3 });
worker.on('failed', (job, err) => {
console.error(`Job ${job.id} failed:`, err.message);
});
Deployment and Infrastructure
Minimal production configuration:
- App server: 1 instance (Node.js / Puma), auto-restart via PM2 or systemd
- Worker: separate process for queue
- PostgreSQL: for storing sessions and app data
- Redis: for queues and cache
- Nginx: reverse proxy + SSL termination
- Webhook endpoint: must be publicly accessible (Shopify makes POST)
Timeline
Simple custom app (CRUD over API, basic Polaris UI): 1–2 weeks. App with Theme Extension, Webhooks, and external system integration: 3–5 weeks. Full-featured app with Shopify Functions, checkout UI, background sync, and multi-shop architecture: 2–3 months.







