Creating a Shopify application means integrating with one of the most mature commerce platforms on the web. It requires adhering to strict architectural patterns, managing OAuth securely, processing high volumes of incoming webhooks, implementing compliant UI systems, handling complex billing logic, and navigating an app store review process that takes no prisoners. I have been building apps in this ecosystem for years, and the barrier to entry has steadily increased as Shopify tightens their security and performance requirements.
In this guide, I will detail the process of building a public Shopify app from project generation through to successful submission. We will examine the Remix template architecture, handle OAuth gracefully, dive deeply into webhook processing with strict idempotency, and discuss why the review process catches so many developers off guard. You will see how session storage scales, how the Admin GraphQL API manages heavy payloads, and exactly what Shopify expects before they stamp your application with their approval. The technical rigor required is substantial, but mastering it unlocks distribution to millions of merchants.
1. Architecture and App Types Recap
Before writing a single line of code, you must specify the distribution strategy of the app. This entirely dictates the authentication flow, the hosting architecture, and the level of scrutiny Shopify will apply to your codebase. If you make the wrong choice early on, pivoting requires effectively rewriting your entire authentication layer.
Public vs. Custom vs. Admin-Created
A Public App is designed to be installed by multiple merchants. It is distributed via the Shopify App Store or through direct install links. It requires a rigorous OAuth implementation, full adherence to Shopify's billing API, and compliance with strict review guidelines. If you are building a SaaS product for merchants, this is the path you must take. You are responsible for all infrastructure and multi-tenancy logic.
A Custom App is built for a single merchant but uses the exact same OAuth architecture as a public app. The primary difference is that it does not undergo App Store review and is only installable on stores where the developer provides the specific install link. I frequently use this model for high-volume enterprise clients who need bespoke integrations but want the safety and modularity of standard OAuth authentication.
An Admin-Created App (formerly known as a Private App) bypasses OAuth entirely. It is created directly in the merchant's Shopify admin dashboard, generating static API credentials (a token and a secret). There is no installation flow, no UI in the admin panel, and no app review. This is strictly for headless architectures, ERP synchronisation scripts, or internal backend worker processes that do not require a merchant-facing interface.
For the remainder of this guide, we are building a Public App, as it requires the highest level of technical compliance and encompasses all the challenges you will face in the modern Shopify ecosystem.
2. Remix Project Structure Deep-Dive
Shopify provides a CLI and a template based on Remix and Node.js. When Shopify abandoned their Next.js template in favour of Remix, it caused significant friction in the development community. However, having built production apps on both frameworks, I can confirm Remix is the superior choice for embedded Shopify apps. Remix’s reliance on standard Web Fetch APIs, its server-side rendering defaults, nested routing, and progressive enhancement map perfectly onto Shopify's requirement for fast, resilient iframe loading.
Generating the Scaffold
Initialise the project via the Shopify CLI. This command fetches the latest official Remix template and scaffolds the required directory structure. You will be prompted to link the project to an app record in your Partner Dashboard.
npm install -g @shopify/cli
shopify app create node
The resulting directory structure provides a Remix application pre-wrapped with Shopify's App Bridge and Polaris components. Let us examine the critical files and directories generated by this command, as understanding their purpose is crucial for maintaining the application over time.
app/routes/: This contains your Remix routes. The convention in Shopify apps is to prefix merchant-facing UI routes withapp.(e.g.,app._index.tsx,app.settings.tsx) and backend webhooks as standalone API routes (e.g.,webhooks.tsx). Nested routes allow you to share layouts—like the PolarisFrameand navigation context—across all pages without re-rendering the outer shell, which drastically reduces visual flicker.app/shopify.server.ts: The central nervous system of your app. This file configures the Shopify App CLI instance, defines your session storage mechanism, sets up billing models, and handles OAuth validation. It is the bridge between Remix's incoming web requests and the internal Shopify Node API library.shopify.app.toml: The declarative configuration file. This file dictates your app's requested scopes, webhook subscriptions, and application URLs. It completely replaces the old pattern of manually clicking through the Partner Dashboard to configure settings. When you deploy, the CLI reads this file and synchronises your Partner Dashboard automatically.extensions/: This directory will house all your App Extensions, such as Theme App Extensions (TAE), Checkout UI Extensions, or Shopify Functions. The CLI manages these as distinct sub-projects that deploy alongside your main web application. Each extension has its own `shopify.extension.toml` file.prisma/schema.prisma: The database schema definition. By default, Shopify provides a SQLite database for rapid prototyping. I will explain in the next section why this must be migrated immediately for any production workload.vite.config.ts: Since Remix shifted to Vite as its bundler, this file handles the build process, compiling your server and client assets. It ensures hot-module replacement works fluidly when testing inside the restricted Shopify admin iframe environment, circumventing cross-origin resource sharing (CORS) blocks during local development.
Why Remix for Embedded Apps?
Remix was chosen by Shopify because of its unique data loading paradigm. In an embedded app running inside an iframe, initial load speed is critical. If your app takes longer than a few seconds to render, merchants perceive it as broken and Shopify will penalise it during review. Remix loads data in parallel on the server and delivers a fully formed HTML document to the browser, significantly reducing the perceived latency of the iframe. Furthermore, Remix's built-in form handling via Actions means you do not need complex state management libraries like Redux or React Query to handle basic CRUD operations like updating merchant configuration settings.
3. Session Storage in Production
The default Remix template ships with Prisma and SQLite. This is brilliant for local development, as it requires zero setup and no background database processes. However, attempting to run SQLite in a serverless or multi-instance production environment is a guaranteed disaster. SQLite locks the entire database on write, and ephemeral serverless environments (like Vercel or AWS Lambda) will wipe your SQLite file entirely on every cold start, destroying all active merchant sessions.
Migrating to PostgreSQL
For production, you must switch to a robust relational database. I strongly recommend PostgreSQL due to its reliability and widespread support in the Node.js ecosystem. To achieve this, you need the official Shopify session storage adapter for Postgres to handle the complex parsing of OAuth session structures.
npm install @shopify/shopify-app-session-storage-postgresql
npm install pg
In your prisma/schema.prisma, you must update the provider block to utilize Postgres instead of SQLite. You will also need to generate a new migration to apply this to your production database.
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
You must then configure your shopify.server.ts to use the Prisma adapter pointing to your Postgres instance. The standard implementation requires passing your Prisma client to the adapter constructor so it can query the Session table.
import { 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 || "",
apiVersion: "2024-04",
scopes: process.env.SCOPES?.split(","),
appUrl: process.env.SHOPIFY_APP_URL || "",
authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(prisma),
distribution: AppDistribution.AppStore,
isEmbeddedApp: true,
});
export default shopify;
export const authenticate = shopify.authenticate;
The Connection Pooling Issue
When running a Remix app on a serverless platform, every incoming webhook and every loader request from a merchant can potentially spawn a new serverless function execution. This means a brand new database connection is opened for every request. PostgreSQL handles connection limits strictly—typically maxing out at 100 concurrent connections on lower-tier managed databases.
If you have 500 webhooks arriving simultaneously following a major flash sale, you will exhaust your Postgres connection limit instantly. This results in 500 internal server errors and failed webhooks, triggering Shopify's webhook retry backoff mechanism and potentially leading to endpoint suspension.
To solve this, you must use a connection pooler. If you are using Prisma, you should configure a direct connection to PgBouncer, or utilize a proxy service like Prisma Accelerate or Supabase's built-in Supavisor. Your DATABASE_URL in production must point to the pooler port (typically 6543) rather than the direct database port (5432). I have seen applications reduce their timeout errors from a severe 4% to 0.01% simply by implementing proper connection pooling via PgBouncer. You append ?pgbouncer=true&connection_limit=1 to your Prisma connection string when operating in serverless environments.
4. Admin GraphQL API Patterns — Production Quality
The legacy REST API is largely deprecated for new features and operates on a strict request-bucket rate limit that is difficult to scale. All new implementations must rely on the Admin GraphQL API. Operating the GraphQL API at scale requires specific patterns to avoid rate limits, handle nested data, and parse complex error structures correctly.
Parameterized Queries
Never, under any circumstances, use string interpolation to insert variables into a GraphQL query string. This is the GraphQL equivalent of SQL injection and will result in parsing errors when a merchant's product title contains unescaped quotes or special characters. Always use the variables object provided by the GraphQL client.
const response = await admin.graphql(
`#graphql
query getProducts($first: Int!, $query: String) {
products(first: $first, query: $query) {
edges {
node {
id
title
handle
status
}
}
}
}`,
{
variables: {
first: 50,
query: "tag:wholesale AND status:active"
},
}
);
Handling Cursor-Based Pagination at Scale
When retrieving large datasets—such as synchronising a merchant's entire catalogue of 5,000 products for an ERP integration—you cannot fetch them in one massive request. The query cost would exceed the maximum allowable budget, resulting in an immediate failure. Shopify uses cursor-based pagination, which is far more efficient than offset-based pagination on large datasets.
You must utilize the pageInfo object to determine if more records exist, and pass the endCursor to the after parameter of the exact same query in a sequential loop.
Here is a complete, production-ready pattern for paginating through thousands of products using a resilient do...while loop. Notice the manual delay implementation to avoid exhausting the query cost bucket prematurely.
async function fetchAllProducts(admin) {
let hasNextPage = true;
let cursor = null;
const allProducts = [];
while (hasNextPage) {
const response = await admin.graphql(
`#graphql
query getProducts($first: Int!, $after: String) {
products(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
title
variants(first: 10) {
edges {
node {
id
price
sku
}
}
}
}
}
}
}`,
{
variables: {
first: 100, // Reduced from 250 to accommodate the nested variants query cost
after: cursor,
},
}
);
const { data, extensions } = await response.json();
// Append current batch of nodes to the master array
data.products.edges.forEach(edge => allProducts.push(edge.node));
// Update pagination variables for the next iteration
hasNextPage = data.products.pageInfo.hasNextPage;
cursor = data.products.pageInfo.endCursor;
// Inspect query cost and implement dynamic backoff if approaching limits
const remainingPoints = extensions.cost.throttleStatus.currentlyAvailable;
if (remainingPoints < 500) {
console.log(`Approaching cost limit. Remaining: ${remainingPoints}. Pausing for 2 seconds.`);
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
return allProducts;
}
Managing Errors: Network vs. GraphQL vs. User Errors
Error handling in Shopify's GraphQL API is tripartite, and failing to understand this distinction is a major source of silent bugs in production apps.
First, network errors (e.g., 502 Bad Gateway, ENOTFOUND) mean the request never successfully reached the GraphQL resolver. These throw standard HTTP exceptions.
Second, GraphQL errors (returned in the errors array at the root of the JSON response) indicate syntax issues, missing required query parameters, or unauthorized field access. The response status may still be 200 OK, but the query failed to execute entirely.
Third, and most importantly, are User Errors. When executing a mutation, business logic violations do not return standard HTTP or root GraphQL errors. Instead, the mutation executes successfully, returns a 200 OK, but the payload will contain a userErrors array specific to that mutation. For example, if you attempt to apply a discount code that violates a merchant's configuration, the payload will contain userErrors: [{ field: ["discountCode"], message: "Discount code is invalid." }]. You must always check this nested array manually in your application logic before assuming a mutation was successful and updating your local database.
5. Shopify Functions Deep-Dive
Shopify Functions represent a fundamental shift in how apps customize backend commerce logic. They replace Shopify Scripts (which were historically restricted to Shopify Plus merchants, written in Ruby, and highly opaque) and will entirely replace them by late 2025.
A Shopify Function is a piece of custom logic written in Rust, TypeScript, or AssemblyScript that is compiled down into a WebAssembly (Wasm) module. Shopify executes this Wasm binary directly on their infrastructure securely within the checkout calculation phase. This means your custom logic runs with zero external network latency, ensuring checkout speed remains high.
Implementing a Discount Function
To create a Function, you use the CLI to generate an extension within your project.
shopify app generate extension --type order_discounts
A Function has two core components: the GraphQL input schema (which defines exactly what data your Function needs Shopify to provide from the active checkout session) and the actual logic itself.
Here is an example input schema (run.graphql). We are asking Shopify to pass the active cart lines and a specific custom metafield that stores our dynamic discount configuration to our Function.
query Input {
cart {
lines {
quantity
merchandise {
__typename
...on ProductVariant {
id
}
}
}
}
discountNode {
metafield(namespace: "b2b_app", key: "discount_config") {
value
}
}
}
The logic (written here in TypeScript for simplicity) then parses this input structure and returns a rigorously structured output defining the specific discount to apply to the cart.
import { FunctionResult, DiscountApplicationStrategy } from "../generated/api";
export function run(input: InputQuery): FunctionResult {
// Parse the configuration safely injected via metafield
const configStr = input.discountNode?.metafield?.value;
const config = configStr ? JSON.parse(configStr) : {};
const discountPercentage = config.percentage || 0;
// Early return if no discount is applicable to save execution time
if (discountPercentage === 0) {
return {
discountApplicationStrategy: DiscountApplicationStrategy.First,
discounts: []
};
}
// Construct the resulting discount operation
return {
discountApplicationStrategy: DiscountApplicationStrategy.First,
discounts: [
{
message: "Wholesale Tier Applied",
targets: input.cart.lines.map(line => ({
productVariant: {
id: line.merchandise.id
}
})),
value: {
percentage: {
value: discountPercentage.toString()
}
}
}
]
};
}
The 5ms Execution Budget
Shopify enforces a strict 5-millisecond execution budget for Functions to ensure the global checkout system remains lightning fast. Furthermore, the compiled Wasm binary size is heavily restricted. This architectural constraint means you absolutely cannot make external network requests (fetch) from inside a Function, nor can you implement complex, heavy cryptographic logic or large data parsing operations.
All complex business logic—such as determining wholesale tiers based on external CRM data, or calculating complex volumetric shipping rates across thousands of zip codes—must be pre-calculated asynchronously by your backend worker servers. Your workers then push these pre-calculated rules into the Shopify store via the Metafields API. The Shopify Function then simply reads the fast, local metafield data and applies the rules. You test functions locally using shopify app function run, which executes the Wasm binary against a mock JSON input file you provide, outputting performance metrics alongside the result.
6. Theme App Extensions (TAE)
Historically, Shopify apps modified the storefront by injecting script tags via the legacy ScriptTag API. These scripts would load asynchronously, wait for the DOM, and then brutally manipulate the HTML layout via jQuery or vanilla JS. This caused terrible performance issues, massive cumulative layout shifts (CLS), and conflicted endlessly with modern themes. Theme App Extensions (TAE) are the modern, mandatory replacement for storefront modifications.
Directory Structure and Architecture
A Theme App Extension allows your app to provide pre-built Liquid blocks that merchants can visually drag and drop into their store via the native Shopify Theme Editor. The extension directory sits alongside your main app codebase.
extensions/
theme-extension/
assets/
app.css
app.js
blocks/
wholesale_pricing.liquid
snippets/
price_formatter.liquid
shopify.extension.toml
App Blocks vs. App Embeds
There are two primary types of injections available in a TAE, serving entirely different purposes.
An App Block is a localized component. The merchant decides exactly where it renders on the page relative to other theme sections. It requires a schema definition in the Liquid file to expose settings (like colors, text overrides, or margins) to the editor interface.
{% if customer.tags contains 'wholesale' %}
<div class="wholesale-banner" style="color: {{ block.settings.text_color }}; background: {{ block.settings.bg_color }};">
Wholesale pricing active for your account.
</div>
{% endif %}
{% schema %}
{
"name": "Wholesale Banner",
"target": "section",
"stylesheet": "app.css",
"javascript": "app.js",
"settings": [
{
"type": "color",
"id": "text_color",
"label": "Text Color",
"default": "#ffffff"
},
{
"type": "color",
"id": "bg_color",
"label": "Background Color",
"default": "#000000"
}
]
}
{% endschema %}
An App Embed is an invisible or floating element that is injected into the global theme.liquid layout file. It runs on every page. This is ideal for analytics scripts, persistent chat widgets, or global state managers. App Embeds are toggled on or off by the merchant globally in the Theme Settings panel, rather than placed in specific visual sections on a template.
7. Billing API Implementation
Unless your app is entirely free (which is rare outside of bespoke custom apps), you must implement billing via Shopify's Billing API. Attempting to route merchants to Stripe, PayPal, or any third-party gateway to collect subscription fees is an immediate violation of the Shopify Partner terms of service and guarantees immediate rejection during the review process.
The Billing Flow
The standard pattern uses the GraphQL AppSubscriptionCreate mutation to prompt the merchant to approve a recurring charge. Here is the step-by-step technical flow you must orchestrate:
- The merchant navigates to your app's pricing page within the embedded admin interface.
- They select a plan. Your app's action function executes the
AppSubscriptionCreatemutation, defining the price, interval, and name. - Shopify returns a
confirmationUrlpayload. You must issue a full-page redirect routing the merchant to this URL (which lives securely on Shopify's domain). - The merchant reviews and approves the charge.
- Shopify redirects the merchant back to your app via the
returnUrlyou specified in the initial mutation. - Your app receives the request, verifies the charge status via the
appSubscriptionquery, and stores the active subscription GID in your database.
The Loader Check Pattern
The most resilient practice is to check the billing state on every request in your Remix root loader or heavily gated routes. If an active subscription is not found, you redirect the merchant to the pricing plan selection page, enforcing a hard gate.
import { authenticate } from "../shopify.server";
export async function loader({ request }) {
const { billing } = await authenticate.admin(request);
// This automatically verifies the subscription and redirects to billing if no active plan is found
await billing.require({
plans: ['Pro Plan - $29.99/mo'],
isTest: true,
onFailure: async () => {
// Logic for what happens when billing fails or is missing
throw redirect("/app/pricing");
},
});
return json({ success: true });
}
When setting isTest: true, Shopify processes the entire billing UI flow without actually charging the merchant's credit card. Test charges do not result in payouts and are essential for local development and testing on development stores. Remember to dynamically set this boolean based on your environment variables before pushing to production to ensure you actually get paid by live merchants.
Furthermore, you should implement a grace period pattern for edge cases. If a recurring charge fails due to an expired credit card or insufficient funds, Shopify allows the app to remain accessible for a few days while they retry the charge on their end. Do not instantly hard-lock the merchant out of your app; gracefully degrade the UI and show a warning banner indicating their payment has failed and will be retried, preserving their workflow while prompting action.
8. Webhook Processing Architecture
Webhooks are critical for keeping your app state synchronized with Shopify. They are the backbone of any app that responds to orders, customer creations, or product updates. They are delivered with a strict requirement: your app must respond with a 200 OK status code within exactly 5 seconds. If you process data synchronously and the operation takes 6 seconds (due to a slow database query or external API call), Shopify considers it a failure, will retry the delivery incrementally, and if failures persist over several days, will eventually strip your webhook subscription entirely, breaking your app silently.
The Idempotent Webhook Pattern
To handle webhooks reliably at scale, you must implement an idempotent queue pattern. Relying solely on the Remix action execution time is incredibly dangerous when dealing with third-party APIs or heavy database writes.
- Receive: Receive the webhook payload via a dedicated, unauthenticated route.
- Verify: Verify the HMAC signature to ensure the payload actually came from Shopify. The Remix
authenticate.webhookfunction handles this automatically, comparing the raw body hash against your client secret. - Deduplicate: Shopify guarantees at-least-once delivery, meaning you will frequently receive duplicates. Check the
X-Shopify-Webhook-Idagainst a fast Redis store. Use a RedisSETNX(Set if Not eXists) command with a 24-hour expiry. If the command returnsnil, you have already seen this webhook. Acknowledge and drop it immediately. - Enqueue: Push the parsed payload to a reliable background queue.
- Acknowledge: Immediately return a
200 OKresponse to Shopify, closing the HTTP connection within milliseconds. - Process: A separate background worker pulls the job from the queue and executes the heavy lifting asynchronously, free from the 5-second timeout constraint.
Why BullMQ over a Database Queue?
For Node.js environments, BullMQ (backed by Redis) is the industry standard for webhook processing. While you can build a rudimentary queue using database tables (polling a `jobs` table), it suffers from high polling latency and severe row locking contention at scale. BullMQ operates entirely in-memory, provides built-in exponential backoff retries, and supports job priorities. When you receive an orders/create webhook during a Black Friday event, you can push it to BullMQ and guarantee processing even if your primary application servers restart or crash.
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import { webhookQueue } from "../queue.server";
import redis from "../redis.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { topic, shop, payload, webhookId } = await authenticate.webhook(request);
// Deduplication check via Redis SETNX
// EX 86400 sets expiry to 24 hours. NX ensures it only sets if key does not exist.
const isNew = await redis.set(`webhook:${webhookId}`, '1', 'EX', 86400, 'NX');
if (!isNew) {
console.log(`Duplicate webhook detected: ${webhookId}. Dropping payload.`);
return new Response(null, { status: 200 });
}
// Enqueue to background worker for heavy processing
await webhookQueue.add(topic, { shop, payload }, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: true,
});
// Acknowledge receipt to Shopify immediately
return new Response(null, { status: 200 });
};
9. The App Extensions Ecosystem
Shopify provides numerous extension types to embed your app natively into the merchant's workflow. The era of building massive standalone dashboards is fading; modern apps bring the functionality directly to where the merchant is already working. Understanding when to use each is crucial for a cohesive architecture.
- Admin UI Extensions: These allow you to inject custom UI blocks directly into the Shopify Admin (e.g., adding a custom configuration card to the Product Details page, or a tracking modal to the Order Details page). This prevents the merchant from having to navigate away to your app's main dashboard just to configure a single product, significantly improving the user experience.
- Checkout UI Extensions: The strict replacement for the deprecated `checkout.liquid` file. These allow you to add custom input fields, promotional banners, or cross-sell carousels directly into the native checkout flow. They execute in a highly restrictive, sandboxed web worker environment to ensure security and speed. Note that some capabilities require the merchant to be on Shopify Plus.
- Web Pixel Extensions: Designed specifically for analytics and tracking apps. They run in an isolated sandbox and provide a secure, standardized way to subscribe to customer behavioral events (like `product_added_to_cart` or `checkout_started`) without requiring arbitrary script tag injection that degrades performance.
- Post-purchase Extensions: These render a temporary page immediately after successful checkout but before the final thank-you page. They are exclusively used for post-purchase upsells or cross-sells, allowing the customer to add an item to their order with a single click without re-entering payment details.
10. Error Monitoring and Observability
When your app is installed on hundreds of stores, you cannot rely on manual bug reports from merchants to identify issues. You need comprehensive observability. I mandate Sentry for all my production Shopify apps.
Setting up Sentry in Remix
The @sentry/remix package is designed specifically for Remix's server-rendered routing architecture. It captures unhandled exceptions in loaders and actions, and traces the performance of slow database queries automatically.
Crucially, you must configure Sentry to scrub PII (Personally Identifiable Information). Shopify is extremely strict about data privacy under their partner agreement. If your error logs capture a customer's raw credit card, email, or physical address data via a webhook exception and leak it to a third-party logging service, you are in direct violation of your API agreement and risk immediate platform suspension.
Structured Logging for Traceability
Implement structured JSON logging using a lightweight library like Pino instead of standard `console.log`. Every single log entry should include the shop domain, the webhook_id, and the contextual topic. When a merchant submits a support ticket complaining that a specific order didn't sync, you can instantly filter your CloudWatch or Datadog logs by their shop domain and trace the exact execution path of that specific payload, identifying the failure point in seconds rather than hours.
11. App Store Listing Optimisation
Technical perfection does not guarantee commercial success in the Shopify App Store. The algorithm heavily favors apps that demonstrate momentum, reliability, and clear merchant value.
The Algorithm Factors
The core drivers of organic ranking are install velocity, review velocity, average rating, and crucially, uninstall rate. A high uninstall rate signals to Shopify that your app fails to deliver on its promises or is difficult to use, and they will bury your listing in search results. This is why a flawless, intuitive onboarding experience is critical. If your app requires manual configuration steps, guide the merchant step-by-step with clear tooltips and progress bars.
Your listing requires meticulous optimization based on strict character limits that you must adhere to:
- App Name: Maximum 30 characters. Do not stuff keywords here unnecessarily; focus on brand and primary function (e.g., "Modracx | B2B Wholesale").
- Tagline: Maximum 40 characters. This appears directly in search results and must instantly convey the primary value proposition.
- Key Benefits: You are allowed 3 bullet points, maximum 80 characters each. These appear prominently on the listing page.
- Description: Maximum 2,800 characters. Break this down with clear headings, bullet points, and specific feature highlights.
Strategic Review Requests
You can programmatically trigger the native in-app review prompt using Shopify App Bridge. The trick to high ratings is timing. Never ask for a review immediately after installation when the merchant hasn't experienced value. Wait until the merchant has experienced a definitive "moment of success"—such as successfully syncing their first 100 products, processing their first wholesale order through your portal, or generating their first automated invoice. Triggering the prompt precisely at the moment of highest satisfaction drastically improves both your review conversion rate and average star rating.
12. The App Store Submission Gauntlet
The submission process is notoriously unforgiving. A rejection means fixing the issue and returning to the back of the queue, losing valuable days or weeks of potential revenue. Returning to my initial lede, my complex B2B app was rejected for the following reasons, all of which are incredibly common pitfalls for new developers:
- Overly Broad Scopes: I requested the
write_ordersscope when the app only genuinely needed to read them for reporting. Shopify requires a written justification for every single scope requested. If you request a write scope and the reviewer cannot find a specific feature in your app that actively utilizes it, you fail automatically. Audit your scopes rigorously. - Broken Onboarding on Edge Cases: The app crashed during onboarding if the merchant's store had no active locations configured. Reviewers will deliberately test extreme edge-case store configurations. They test with zero products, zero customers, missing shipping zones, and misconfigured tax settings to ensure your app fails gracefully with helpful error messages rather than crashing the UI.
- Missing Mandatory Webhooks: I had omitted the compliance webhooks, assuming they were unneeded since we didn't store PII locally. This is an automatic failure. You must implement the three mandatory GDPR webhooks (
customers/data_request,customers/redact, andshop/redact) and return a 200 OK response within the required timeframe, regardless of your app's functionality or data storage architecture.
Additionally, your listing requires high-resolution screenshots (strictly formatted to exactly 1280×800 or 2560×1600 pixels), a detailed, compliant privacy policy hosted on your domain, a dedicated support page with contact information, and an accurate, unexaggerated description of the app's functionality. Do not use words like "revolutionary" or "game-changer"—stick to technical and business facts.
13. When NOT to Build a Public App
The operational overhead of maintaining a public app is immense. The complex OAuth architecture, strict UI compliance guidelines, mandatory highly-available webhook infrastructure, and rigorous app review process constitute significant ongoing maintenance debt. You should not undertake this path lightly.
Do not build a public app if:
- You are building a custom integration for a single enterprise client. Use a Custom App or an Admin-Created App to bypass the review process and simplify authentication.
- You are building an internal operational tool for warehouse staff. Use an Admin-Created App to securely expose a headless API to a custom, lightweight frontend application.
- You need to rapidly prototype an idea to test market viability. Use Custom Apps on development stores to bypass the review process entirely until you have validated true product-market fit and are ready to scale.
The public app infrastructure is designed for high-volume, multi-tenant distribution. For bespoke, single-tenant requirements, leverage the simpler integration paths Shopify provides to save yourself weeks of unnecessary engineering effort.
Suggested & Related Reading
Explore related engineering guides from Kenneth D'Silva:
-
Custom Shopify App Development
A deep dive into custom implementations for high-volume merchants.
-
Secure Ecommerce Checklist
Essential security implementations to protect customer data and infrastructure.