Building a Custom Shopify App: From Concept to Production
A practical guide to building embedded Shopify apps with Remix and the GraphQL Admin API — architecture, auth, billing, webhooks, and the review process.
- 01When you need a custom app
- 02App types and which to choose
- 03The Remix + Shopify CLI stack
- 04Session management and authentication
- 05GraphQL Admin API patterns
- 06Webhooks: reliability requirements
- 07App billing with the Shopify Billing API
- 08Passing app review
When you need a custom app
A custom Shopify app makes sense when no app store solution covers your requirements, you need deep integration with internal systems (ERP, CRM, fulfilment), you're paying for multiple apps that could be consolidated, or you're building a product to sell to other merchants.
A custom app does not make sense when an existing app covers 80%+ of your needs, your budget doesn't account for ongoing maintenance, or you underestimate the operational requirements (security updates, Shopify API version updates, webhook reliability).
Before committing to a custom build, document the specific gaps in existing apps and the business case for closing them. This conversation helps scope the project accurately.
App types and which to choose
Shopify apps fall into three categories:
**Custom apps** (also called private apps) — installed on a single store, no app review required, no Shopify billing API. Best for internal tools, store-specific automations, and integrations that don't need to be sold to other merchants.
**Public apps** — listed in the App Store (or distributed unlisted), must pass Shopify's app review, must use Shopify's Billing API for subscriptions. Best when you want to sell or distribute the app to multiple merchants.
**Embedded apps** — apps that run inside the Shopify admin using App Bridge. The merchant doesn't leave the admin to use the app. This is the standard for most admin-facing Shopify apps and is a requirement for public apps that have an admin UI.
The Remix + Shopify CLI stack
Shopify's officially supported app stack uses Remix as the framework, with Shopify CLI handling scaffolding, dev tooling, and local development. Scaffold a new app:
```bash npm create @shopify/app@latest ```
This gives you a Remix app pre-configured with: - OAuth session management (shopify.server.ts) - App Bridge 4 for embedded admin UI - GraphQL Admin API client - Prisma + SQLite by default (swap to PostgreSQL for production) - Shopify Polaris for UI components
The Shopify CLI dev command starts a local tunnel (ngrok) and configures your app's redirect URLs automatically — you don't need to manage this manually.
Session management and authentication
Shopify uses OAuth for app installation and session tokens for subsequent requests. The Remix template handles this via the shopify.authenticate.admin(request) helper — call this at the start of every loader and action that needs admin access.
The helper validates the session token (JWT from App Bridge in the frontend), exchanges it for an access token, and returns a GraphQL client pre-authenticated for that shop. You don't manage tokens manually.
For production, switch from SQLite to PostgreSQL for session storage. The default SQLite setup is single-instance only — it won't work with a horizontally scaled deployment.
GraphQL Admin API patterns
All Admin API interactions in the Remix template use GraphQL. The client returned by shopify.authenticate.admin(request) has a graphql method that handles auth, rate limiting, and error types.
Key patterns: - Always check userErrors in mutation responses — a 200 response doesn't mean success - Use cursor-based pagination with pageInfo.hasNextPage and endCursor for list queries - Use Bulk Operations for queries that need to fetch thousands of records - Implement exponential backoff when you hit rate limit errors (throttled status)
Rate limits use a points-based system: 1,000 point bucket, 50 points restored per second. Complex queries with nested connections cost more points — check extensions.cost in your responses.
Webhooks: reliability requirements
Shopify delivers webhooks at-least-once — your endpoint can receive the same webhook multiple times. Every webhook handler must be idempotent: processing the same event twice must produce the same result as processing it once.
Implement idempotency by storing processed webhook IDs (the X-Shopify-Webhook-Id header) in your database before processing. Check for the ID before handling — if it exists, return 200 immediately without processing.
Always verify the webhook signature using the X-Shopify-Hmac-SHA256 header before processing. Shopify's Remix template provides authenticate.webhook(request) which handles this verification automatically.
Return a 200 response within 5 seconds. If your processing takes longer, acknowledge immediately and process asynchronously via a queue (BullMQ, Trigger.dev, etc.).
App billing with the Shopify Billing API
Public apps that charge merchants must use Shopify's Billing API — you can't process payments outside of it. The Billing API supports recurring application charges (subscriptions), one-time charges, and usage-based charges.
For a recurring subscription: create an AppSubscription with AppRecurringPricing, redirect the merchant to the confirmationUrl (Shopify's billing confirmation page), and handle the app/subscriptions/update webhook to track active/cancelled status.
Billing is in USD only. The merchant pays Shopify, Shopify pays you (minus the revenue share). Revenue share is 15% for the first $1M/year per app, 20% above that.
Passing app review
For public apps, Shopify's review process checks: correct API scopes (only request what you need), GDPR webhook compliance (shop/redact, customers/redact, customers/data_request), rate limit handling, session token validation on every request, Polaris design system usage in the admin UI, and functional testing of your core flows.
Common rejection reasons: requesting admin write scopes that you don't use, missing GDPR webhook endpoints, not handling the case where a merchant reinstalls after uninstalling, and missing loading states in the UI during async operations.
Submit for review only after testing all flows — installation, core functionality, billing if applicable, and uninstall/reinstall. Shopify's review team tests these manually.
Need help implementing this?
I build what I write about. If you need this implemented on your Shopify store, get in touch.
Discuss your projectReady to build this on your store?
Let's scope it.
I reply personally within 24 hours.