Skip to main content
Innovatrix Infotech — home
How to Build a Custom Shopify App: Step-by-Step for Developers in 2026 cover
Shopify

How to Build a Custom Shopify App: Step-by-Step for Developers in 2026

Shopify's app ecosystem is a billion-dollar opportunity, but the official documentation buries the practical gotchas under layers of abstraction. This is the tutorial I wish existed when we built our first custom Shopify app.

Photo of Rishabh SethiaRishabh SethiaFounder & CEO23 October 2025Updated 28 March 202616 min read1.8k words
#shopify#app-development#tutorial#react#nodejs

Shopify has over 4.4 million merchants worldwide, and the average store uses at least 6 apps. If you're a developer who knows JavaScript and Node.js, building Shopify apps is one of the most practical ways to either solve your own store's unique problems or build a SaaS product for the broader merchant base.

I've been building software professionally for over a decade — as an SSE, as Head of Engineering, and now as the founder of a Shopify Partner agency with a 12-person development team. We've built custom Shopify apps for inventory management, dynamic pricing, vendor coordination, and checkout customization. This tutorial distills what we've learned into a practical, step-by-step guide.

This isn't a rewrite of Shopify's official docs. This is the stuff that takes you hours to figure out on your own — the authentication edge cases, the GraphQL quirks, the deployment gotchas.

What You'll Build

A custom Shopify app that:

  • Embeds in the Shopify admin using App Bridge
  • Authenticates via OAuth 2.0
  • Reads product data via the GraphQL Admin API
  • Displays a custom UI using Polaris components
  • Persists data using Prisma + SQLite (or PostgreSQL for production)

Prerequisites

  • Node.js v18+ and npm installed
  • A Shopify Partner account (free at partners.shopify.com)
  • A development store (create one from your Partner Dashboard)
  • Basic knowledge of React and Node.js
  • Shopify CLI installed: npm install -g @shopify/cli @shopify/app

Step 1: Scaffold Your App with Shopify CLI

Shopify CLI is the fastest way to get a working app. As of 2026, the default template uses React Router (Shopify migrated from Remix in late 2025).

shopify app init

The CLI will prompt you for:

  • App name
  • Template (choose React Router for this tutorial)
  • Package manager (npm or yarn)

This generates your project structure:

your-app/
├── app/              # React Router frontend
│   ├── routes/       # Page components
│   └── shopify.server.js  # Server-side Shopify auth
├── prisma/           # Database schema
├── extensions/       # Theme/checkout extensions
├── shopify.app.toml  # App configuration
└── package.json

The gotcha nobody mentions: The shopify.app.toml file is your app's config. If you change your app's scopes (permissions) after initial setup, you need to update this file AND re-authenticate the app in your development store. Missing this step is the #1 cause of "permission denied" errors we see.

Step 2: Configure Authentication

Shopify uses OAuth 2.0 for app authentication. The CLI template handles most of this automatically, but understanding the flow is critical when debugging:

Merchant installs app → Redirected to your /auth endpoint
→ Your app redirects to Shopify's OAuth consent screen
→ Merchant approves → Shopify redirects back with auth code
→ Your app exchanges code for access token
→ Token stored, app loads in Shopify admin iframe

In the React Router template, authentication is handled by shopify.server.js:

// shopify.server.js - the auth backbone
import "@shopify/shopify-app-remix/adapters/node";
import { AppDistribution, shopifyApp } from "@shopify/shopify-app-remix/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import prisma from "./db.server";

const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecretKey: process.env.SHOPIFY_API_SECRET,
  appUrl: process.env.SHOPIFY_APP_URL,
  scopes: process.env.SCOPES?.split(","),
  authPathPrefix: "/auth",
  sessionStorage: new PrismaSessionStorage(prisma),
  distribution: AppDistribution.AppStore,
  future: {
    unstable_newEmbeddedAuthStrategy: true,
  },
});

export default shopify;

Critical: Always use PrismaSessionStorage or a database-backed session store in production. The in-memory session storage works for development but loses all sessions on server restart. We learned this the hard way on a client project where every merchant had to re-authenticate after every deployment.

Step 3: Define Your Data Model

Update your Prisma schema for your app's data needs. The template includes session storage — add your custom models alongside it:

// prisma/schema.prisma
datasource db {
  provider = "sqlite"  // Use "postgresql" for production
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Session {
  id          String    @id
  shop        String
  state       String
  isOnline    Boolean   @default(false)
  scope       String?
  expires     DateTime?
  accessToken String
  userId      BigInt?
}

// Your custom models
model ProductAudit {
  id          Int      @id @default(autoincrement())
  shop        String
  productId   String
  action      String   // "created", "updated", "deleted"
  changes     String   // JSON string of changes
  createdAt   DateTime @default(now())
  
  @@index([shop, productId])
}

Run the migration:

npx prisma db push

Step 4: Query Shopify Data with GraphQL

Shopify's GraphQL Admin API is the primary way to interact with store data. REST is still available but Shopify requires new public apps to use GraphQL as of April 2025.

Here's how to fetch products in a loader:

// app/routes/app._index.jsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { authenticate } from "../shopify.server";
import { Page, Layout, Card, DataTable } from "@shopify/polaris";

export const loader = async ({ request }) => {
  const { admin } = await authenticate.admin(request);
  
  const response = await admin.graphql(`
    {
      products(first: 25, sortKey: UPDATED_AT, reverse: true) {
        edges {
          node {
            id
            title
            status
            totalInventory
            priceRangeV2 {
              minVariantPrice {
                amount
                currencyCode
              }
            }
            updatedAt
          }
        }
      }
    }
  `);
  
  const data = await response.json();
  return json({ products: data.data.products.edges });
};

Performance tip that saves API calls: Use GraphQL's field selection to request only the data you need. Each unnecessary field costs response time and counts toward your rate limit. We've seen apps cut response times by 40% just by trimming their GraphQL queries.

Step 5: Build the UI with Polaris

Shopify Polaris is the design system for embedded apps. Using Polaris ensures your app looks native within the Shopify admin:

export default function Index() {
  const { products } = useLoaderData();
  
  const rows = products.map(({ node }) => [
    node.title,
    node.status,
    node.totalInventory,
    `${node.priceRangeV2.minVariantPrice.amount} ${node.priceRangeV2.minVariantPrice.currencyCode}`,
    new Date(node.updatedAt).toLocaleDateString(),
  ]);
  
  return (
    <Page title="Product Dashboard">
      <Layout>
        <Layout.Section>
          <Card>
            <DataTable
              columnContentTypes={["text", "text", "numeric", "text", "text"]}
              headings={["Product", "Status", "Inventory", "Price", "Updated"]}
              rows={rows}
            />
          </Card>
        </Layout.Section>
      </Layout>
    </Page>
  );
}

Polaris gotcha: Don't use custom CSS that conflicts with Polaris components. Shopify's app review team will reject your app if the UI looks inconsistent with the admin. Stick to Polaris's spacing tokens and color system.

Step 6: Handle Webhooks

Webhooks are how Shopify notifies your app about store events. Register them in your shopify.app.toml:

[webhooks]
api_version = "2025-01"

  [[webhooks.subscriptions]]
  topics = ["products/update", "products/create", "products/delete"]
  uri = "/webhooks"

Handle them in your webhook route:

// app/routes/webhooks.jsx
import { authenticate } from "../shopify.server";
import prisma from "../db.server";

export const action = async ({ request }) => {
  const { topic, shop, payload } = await authenticate.webhook(request);
  
  switch (topic) {
    case "PRODUCTS_UPDATE":
      await prisma.productAudit.create({
        data: {
          shop,
          productId: String(payload.id),
          action: "updated",
          changes: JSON.stringify(payload),
        },
      });
      break;
    case "APP_UNINSTALLED":
      // Clean up shop data
      await prisma.session.deleteMany({ where: { shop } });
      break;
  }
  
  return new Response(null, { status: 200 });
};

Critical webhook lesson: Always return a 200 response within 5 seconds. If your webhook handler takes longer (e.g., heavy processing), acknowledge the webhook immediately and process asynchronously using a job queue. Shopify will retry failed webhooks and eventually disable them if they consistently fail.

Step 7: Test Locally

shopify app dev

This starts your local server and creates a Cloudflare tunnel so Shopify can reach your app. You'll be prompted to install the app on your development store.

Testing checklist we use internally:

  • OAuth flow works on fresh install
  • App loads correctly in Shopify admin iframe
  • GraphQL queries return expected data
  • Webhooks fire and process correctly
  • App handles session expiry gracefully
  • Uninstall webhook cleans up data

Step 8: Deploy to Production

For production deployment, we recommend:

  • Railway or Render for quick deploys with PostgreSQL
  • AWS (ECS/Fargate) for production-grade scaling — as an AWS Partner, we use this for client apps that need high availability
  • Fly.io for edge deployment with global latency optimization
# Deploy to production
shopify app deploy

This pushes your app configuration to Shopify. For the actual hosting, configure your deployment pipeline to:

  1. Run database migrations (npx prisma migrate deploy)
  2. Build the frontend (npm run build)
  3. Start the production server (npm start)

Common Issues & Fixes

"App not loaded in iframe" — Your app's URL in shopify.app.toml doesn't match your deployment URL. Update it and redeploy.

"Invalid OAuth callback" — The redirect URL in your Partner Dashboard doesn't match your app's callback URL. Add all possible callback URLs (localhost for dev, production URL for live).

"Rate limited on GraphQL" — Shopify allows ~40 points per second for the Admin API. Use the X-Shopify-Shop-Api-Call-Limit header to monitor usage. Implement exponential backoff.

"Webhook not received" — Verify your webhook URL is publicly accessible and returns 200 within 5 seconds. Use shopify app webhooks trigger to test locally.

When to Build vs. When to Use Existing Apps

After building custom apps for clients like Zevarly (where custom product recommendation logic drove +55% session duration and +33% repeat purchase rate), our rule of thumb:

Build custom when: Your requirement is unique to your business, no existing app solves it well, or you need deep integration with internal systems (ERP, CRM, custom fulfillment).

Use existing apps when: The functionality is standard (reviews, email marketing, SEO), multiple well-rated apps exist, and the cost of building + maintaining custom code exceeds the app subscription.

As a DPIIT-recognized startup that builds Shopify apps professionally, we've learned that the best custom apps solve narrow problems extremely well. Don't build a general-purpose tool when you need a scalpel.

Frequently Asked Questions

Written by

Photo of Rishabh Sethia
Rishabh Sethia

Founder & CEO

Rishabh Sethia is the founder and CEO of Innovatrix Infotech, a Kolkata-based digital engineering agency. He leads a team that delivers web development, mobile apps, Shopify stores, and AI automation for startups and SMBs across India and beyond.

Connect on LinkedIn
Get started

Ready to talk about your project?

Whether you have a clear brief or an idea on a napkin, we'd love to hear from you. Most projects start with a 30-minute call — no pressure, no sales pitch.

No upfront commitmentResponse within 24 hoursFixed-price quotes