← Plugin catalog
Developer Tools
Shopify App Builder
Khadin Akbar v1.4.1
Publisher description
From the marketplace listing
A comprehensive open-source Shopify app engineering toolkit with 32 focused skills for routing, APIs, authentication, billing, webhooks, extensions, UX, accessibility, performance, App Store listings, and acquisition.
Language: English · Automatically detected from descriptions.
Publisher keywords
Search terms declared by the publisher.
shopifyListing · Package
shopify-app-developmentListing · Package
agent-skillsListing · Package
claude-codeListing · Package
codexListing · Package
cursorListing · Package
opencodeListing · Package
Files & skills
File archives
Plugin package43 files · 879 KBBrowse files →
Skill instructions
admin-graphql16.4 KB
---
name: admin-graphql
description: "Build Shopify Admin GraphQL queries and mutations for products, orders, customers, inventory, and more. Covers cost-aware rate limiting, cursor pagination, bulk operations, global resource identifiers, and version-safe API usage."
---
## When to Use This Skill
Use **Admin GraphQL API** when you need to:
- Manage products, variants, collections, and pricing (preferred over REST for any business logic)
- Query or modify orders, fulfillments, and refunds
- Create or update customers and customer accounts
- Adjust inventory across multiple locations
- Create metafields and metaobjects for custom data
- Set up webhooks for event subscriptions
- Perform bulk operations on large datasets (1000+ records)
- Access the latest Shopify features (GraphQL-only endpoints)
**GraphQL vs REST comparison:**
| Feature | GraphQL Admin | REST (Deprecated) |
|---------|--------------|-------------------|
| Rate Limiting | Cost-based; standard restore rate is 100 points/sec | Request bucket; standard restore rate is 2 requests/sec |
| Pagination | Cursor-based (Relay pattern) | Offset/limit (deprecated) |
| Field Selection | Precise (fetch only needed fields) | Fixed response shape (wasteful) |
| Batch Operations | Native bulk operations (JSONL) | Multiple sequential requests |
| Latest Features | Current platform features | Some newer features are GraphQL-only |
| Status | Required for new public apps | Legacy since October 1, 2024 |
---
## API Endpoint & Authentication
**Endpoint:** `https://{shop}.myshopify.com/admin/api/2026-07/graphql.json`
**Authentication:**
```javascript
const headers = {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': process.env.SHOPIFY_ACCESS_TOKEN,
};
```
---
## Cost-Based Rate Limiting
Shopify uses calculated query costs. The standard GraphQL Admin API restore rate is 100 points per second; Advanced, Plus, and enterprise plans have higher rates. A single query can't exceed 1,000 requested points. Read `extensions.cost.throttleStatus` instead of hard-coding one bucket size.
**Handling throttling:**
```javascript
async function executeWithRetry(query, variables, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
});
const data = await response.json();
if (data.errors?.some(e => e.extensions?.code === 'THROTTLED')) {
const waitTime = Math.pow(2, attempt - 1) * 1000;
console.log(`Throttled. Waiting ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
return data;
}
throw new Error('Max retries exceeded');
}
```
---
## Cursor-Based Pagination
```graphql
query GetProducts($first: Int, $after: String) {
products(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
title
}
}
}
}
```
**JavaScript iteration:**
```javascript
const allProducts = [];
let hasNextPage = true;
let endCursor = null;
while (hasNextPage) {
const data = await executeQuery(GET_PRODUCTS, {
first: 50,
after: endCursor,
});
const { edges, pageInfo } = data.products;
allProducts.push(...edges.map(e => e.node));
hasNextPage = pageInfo.hasNextPage;
endCursor = pageInfo.endCursor;
}
```
---
## 30 Essential Operations
### Product Management
**1. Create Product**
```graphql
mutation CreateProduct($input: ProductInput!) {
productCreate(input: $input) {
product {
id
handle
title
status
}
userErrors {
field
message
}
}
}
```
**2. Update Product**
```graphql
mutation UpdateProduct($input: ProductInput!) {
productUpdate(input: $input) {
product {
id
title
updatedAt
}
userErrors {
field
message
}
}
}
```
**3. Create Variants (Bulk)**
```graphql
mutation BulkCreateVariants($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkCreate(productId: $productId, variants: $variants) {
productVariants {
id
title
sku
price
}
userErrors {
field
message
}
}
}
```
**4. Update Variants (Bulk)**
```graphql
mutation BulkUpdateVariants($variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(variants: $variants) {
productVariants {
id
sku
price
}
userErrors {
message
}
}
}
```
### Order Management
**5. Get Orders (Paginated)**
```graphql
query GetOrders($first: Int!, $after: String) {
orders(first: $first, after: $after) {
edges {
node {
id
name
createdAt
customer {
id
email
}
lineItems(first: 10) {
edges {
node {
id
title
quantity
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
```
**6. Update Order (Add Tags)**
```graphql
mutation UpdateOrderTags($input: OrderInput!) {
orderUpdate(input: $input) {
order {
id
tags
}
userErrors {
field
message
}
}
}
```
**7. Begin Order Edit**
```graphql
mutation BeginOrderEdit($orderId: ID!) {
orderEditBegin(orderId: $orderId) {
calculatedOrder {
id
lineItems(first: 10) {
edges {
node {
id
title
quantity
}
}
}
}
userErrors {
field
message
}
}
}
```
**8. Commit Order Edit**
```graphql
mutation CommitOrderEdit($id: ID!) {
orderEditCommit(id: $id) {
order {
id
name
}
userErrors {
field
message
}
}
}
```
### Fulfillment
**9. Create Fulfillment**
```graphql
mutation CreateFulfillment($input: FulfillmentInput!) {
fulfillmentCreate(input: $input) {
fulfillment {
id
status
trackingInfo {
number
company
url
}
}
userErrors {
field
message
}
}
}
```
### Inventory
**10. Adjust Inventory (Multiple Locations)**
```graphql
mutation AdjustInventory($input: InventoryAdjustQuantitiesInput!) {
inventoryAdjustQuantities(input: $input) {
inventoryLevels {
id
quantity
location {
id
name
}
}
userErrors {
field
message
}
}
}
```
### Customers
**11. Create Customer**
```graphql
mutation CreateCustomer($input: CustomerInput!) {
customerCreate(input: $input) {
customer {
id
email
firstName
lastName
}
userErrors {
field
message
}
}
}
```
**12. Update Customer**
```graphql
mutation UpdateCustomer($input: CustomerInput!) {
customerUpdate(input: $input) {
customer {
id
email
updatedAt
}
userErrors {
field
message
}
}
}
```
**13. Get Customer with Orders**
```graphql
query GetCustomerOrders($customerId: ID!, $first: Int!) {
customer(id: $customerId) {
id
email
firstName
orders(first: $first) {
edges {
node {
id
name
}
}
}
}
}
```
### Metafields
**14. Set Metafields (Product)**
```graphql
mutation SetMetafields($input: MetafieldsSetInput!) {
metafieldsSet(input: $input) {
metafields {
id
namespace
key
value
}
userErrors {
field
message
}
}
}
```
**15. Create Metaobject**
```graphql
mutation CreateMetaobject($input: MetaobjectInput!) {
metaobjectCreate(input: $input) {
metaobject {
id
type
fields {
key
value
}
}
userErrors {
field
message
}
}
}
```
### Discounts
**16. Create Discount Code**
```graphql
mutation CreateDiscount($input: DiscountCodeBasicInput!) {
discountCodeBasicCreate(input: $input) {
discountCodeBasic {
id
codes(first: 1) {
edges {
node {
code
}
}
}
}
userErrors {
field
message
}
}
}
```
### Webhooks
**17. Create Webhook Subscription**
```graphql
mutation CreateWebhook($input: WebhookSubscriptionInput!) {
webhookSubscriptionCreate(input: $input) {
webhookSubscription {
id
topic
endpoint {
__typename
... on WebhookHttpEndpoint {
callbackUrl
}
}
}
userErrors {
field
message
}
}
}
```
### Draft Orders
**18. Create Draft Order**
```graphql
mutation CreateDraftOrder($input: DraftOrderInput!) {
draftOrderCreate(input: $input) {
draftOrder {
id
invoiceUrl
}
userErrors {
field
message
}
}
}
```
**19. Complete Draft Order**
```graphql
mutation CompleteDraftOrder($id: ID!) {
draftOrderComplete(id: $id) {
draftOrder {
id
order {
id
name
}
}
userErrors {
field
message
}
}
}
```
### Shop
**20. Get Shop Details**
```graphql
query GetShopDetails {
shop {
id
name
email
myshopifyDomain
currency
}
}
```
### Collections
**21. Create Collection**
```graphql
mutation CreateCollection($input: CollectionInput!) {
collectionCreate(input: $input) {
collection {
id
handle
title
}
userErrors {
field
message
}
}
}
```
**22. Add Products to Collection**
```graphql
mutation AddProductsToCollection($id: ID!, $productIds: [ID!]!) {
collectionAddProducts(id: $id, productIds: $productIds) {
collection {
id
products(first: 10) {
totalCount
}
}
userErrors {
field
message
}
}
}
```
### Bulk Operations
**23. Run Bulk Query**
```graphql
mutation BulkQueryRun($query: String!) {
bulkOperationRunQuery(query: $query) {
bulkOperation {
id
status
createdAt
}
userErrors {
field
message
}
}
}
```
**24. Get Bulk Operation Status**
```graphql
query GetBulkOperation($id: ID!) {
node(id: $id) {
... on BulkOperation {
id
status
createdAt
completedAt
objectCount
fileSize
url
}
}
}
```
**25. Run Bulk Mutation**
```graphql
mutation BulkMutationRun($input: String!) {
bulkOperationRunMutation(input: $input) {
bulkOperation {
id
status
}
userErrors {
field
message
}
}
}
```
### Search
**26. Search Products**
```graphql
query SearchProducts($query: String!, $first: Int) {
products(first: $first, query: $query) {
edges {
node {
id
title
handle
}
}
}
}
```
### Locations
**27. Get All Locations**
```graphql
query GetLocations {
locations(first: 250) {
edges {
node {
id
name
isActive
}
}
}
}
```
### Refunds
**28. Create Refund**
```graphql
mutation CreateRefund($input: RefundInput!) {
refundCreate(input: $input) {
refund {
id
status
}
userErrors {
field
message
}
}
}
```
### Returns
**29. Create Return**
```graphql
mutation CreateReturn($input: ReturnInput!) {
returnCreate(input: $input) {
return {
id
status
requestedAt
}
userErrors {
field
message
}
}
}
```
### App Info
**30. Get App Installation Data**
```graphql
query GetAppInstallation {
appInstallation {
launchUrl
accessScopes {
handle
}
}
}
```
---
## Bulk Operations Workflow
For 100+ record operations, use bulk mutations to avoid rate limit delays:
```javascript
async function processBulkResults(fileUrl) {
const response = await fetch(fileUrl);
const text = await response.text();
const lines = text.trim().split('\n');
const results = lines.map(line => JSON.parse(line));
const errors = results.filter(r => r.__typename === 'Error');
if (errors.length > 0) {
console.error('Bulk operation errors:', errors);
}
return results.filter(r => r.__typename !== 'Error');
}
```
---
## User Errors Handling
```javascript
function handleMutationResponse(response) {
if (response.errors) {
throw new Error(`GraphQL Error: ${response.errors[0].message}`);
}
const result = response.data?.productCreate;
if (result.userErrors.length > 0) {
const fieldErrors = result.userErrors.map(err =>
`${err.field.join('.')}: ${err.message}`
).join('; ');
throw new Error(`Validation failed: ${fieldErrors}`);
}
return result.product;
}
```
---
## Global Resource Identifiers (GIDs)
All Shopify resources use format: `gid://shopify/{ResourceType}/{NumericID}`
**Common types:**
- `gid://shopify/Product/123456`
- `gid://shopify/Order/345678`
- `gid://shopify/Customer/901234`
- `gid://shopify/Location/567890`
**Parsing GIDs:**
```javascript
function parseGid(gid) {
const match = gid.match(/gid:\/\/shopify\/(\w+)\/(.+)/);
return {
type: match[1],
id: match[2],
};
}
```
---
## Scope-to-Operation Mapping
| Scope | Operations |
|-------|-----------|
| `write_products` | Create, update products; manage variants |
| `read_products` | Query products, variants, collections |
| `write_orders` | Update orders, create fulfillments |
| `read_orders` | Query orders, line items |
| `write_customers` | Create, update customers |
| `read_customers` | Query customer data |
| `write_inventory` | Adjust inventory quantities |
| `read_inventory` | Query inventory levels |
| `write_webhooks` | Create webhook subscriptions |
---
## MoneyV2 Fields Best Practice
Always request money values with currency:
```graphql
query {
products(first: 1) {
edges {
node {
priceRange {
minVariantPrice {
amount
currencyCode
}
}
variants(first: 1) {
edges {
node {
price
compareAtPrice
}
}
}
}
}
}
}
```
---
## Common Gotchas (15 Critical Issues)
| Gotcha | Solution |
|--------|----------|
| **userErrors but no message** | Check field array—sometimes indicates the issue |
| **Product not appearing in storefront** | Ensure `status: ACTIVE` is set |
| **Metafield value undefined** | Metafields must match namespace/key exactly |
| **Pagination returns empty with hasNextPage: true** | Use `after: endCursor`, not offset |
| **GID format errors** | Always use full format: `gid://shopify/Type/ID` |
| **Rate limited immediately** | Check query cost; use `first: 50` initially |
| **Variant options not syncing** | Product options must be created before variants |
| **Webhook never delivers** | Verify endpoint returns 200-299 status |
| **Customer metafields not visible** | Check `visible_to_storefront` flag |
| **Bulk operation returns partial results** | JSONL requires proper line breaks |
| **Order edit fails silently** | Must run `orderEditBegin` first |
| **Price not updating** | Use `variants` input as collection |
| **Inventory shows negative** | Shopify allows negatives; check location config |
| **Collection products order wrong** | Use `collectionReorderProducts` if order matters |
| **Webhook signature mismatch** | Use raw request body bytes for HMAC, not JSON |
---
## Decision Tree: Choosing the Right Operation
**Product Management:** Single product? Use create/update. Many variants? Use bulk variants.
**Order Processing:** Modify after creation? Use orderEditBegin/Commit. Add tags? Use orderUpdate.
**Inventory:** Single location? Use inventoryAdjustQuantities. Many locations? Use bulk mutation.
**Customers:** New customer? Use customerCreate. Attach data? Use metafieldsSet.
**Custom Data:** Attach to existing resource? Use metafieldsSet. Create new structure? Use metaobjectCreate.
---
## API Version & Support Lifecycle
- **Current when this release was audited:** 2026-07
- **Rule:** Confirm the latest stable version and its support dates before deployment
- **Release:** Quarterly (Jan, Apr, Jul, Oct)
- **Support:** 12 months per version
---
## Reference URLs
- [Shopify Admin GraphQL API Docs](https://shopify.dev/docs/api/admin-graphql/latest)
- [GraphQL Mutations Reference](https://shopify.dev/docs/api/admin-graphql/latest/mutations)
- [Rate Limiting Guide](https://shopify.dev/docs/api/usage/limits)
- [Global IDs Explained](https://shopify.dev/docs/api/usage/gids)
- [OAuth Scopes Reference](https://shopify.dev/docs/api/usage/access-scopes)
- [API Version Timeline](https://shopify.dev/api/admin-graphql#api-versions)
- [Bulk Operations Guide](https://shopify.dev/docs/api/usage/bulk-operations/queries)
admin-rest11.1 KB
---
name: admin-rest
description: "Use the legacy REST Admin API only when maintaining an existing integration. Covers common resources, the 40-request bucket with a 2-request-per-second standard restore rate, and migration to GraphQL. New public apps must use the GraphQL Admin API."
---
## When to Use REST API (Rarely)
**LEGACY API WARNING:** Shopify has classified the REST Admin API as legacy since October 1, 2024. Since April 1, 2025, new public apps must use the GraphQL Admin API exclusively. Shopify has not published a blanket December 2026 shutdown date for every REST resource.
Use REST API ONLY for:
- Maintaining existing legacy applications built before 2024
- Simple read-only queries from archived systems
- Temporary compatibility layers during GraphQL migration
- Systems that cannot be updated to use GraphQL
**MIGRATE TO GRAPHQL FOR:** All new features, bulk operations, cost efficiency, and latest Shopify functionality.
---
## REST vs GraphQL Comparison
| Feature | REST (Legacy) | GraphQL (Current) |
|---------|---------------|-------------------|
| Rate Limiting | 40-request standard bucket, restored at 2 requests/sec | Cost-based; restore rate varies by plan |
| Pagination | Limit/offset (inefficient) | Cursor-based (Relay) |
| Field Selection | Fixed response (bloated) | Precise fields only |
| Bulk Operations | Sequential requests | Native JSONL bulk |
| Latest Features | Some newer features are GraphQL-only | Current platform features |
| Support Window | Versioned; verify the selected version and resource | Versioned; verify the selected version |
| Status | Maintenance only | Production active |
---
## API Endpoint Structure
**Base URL:** `https://{shop}.myshopify.com/admin/api/2025-10/`
**Authentication (Header):**
```bash
curl -X GET "https://store.myshopify.com/admin/api/2025-10/products.json" \
-H "X-Shopify-Access-Token: {access_token}"
```
The examples below use `2025-10` for compatibility with older integrations. Before deploying, select a currently supported API version from Shopify's version schedule and test the exact resources you use.
---
## Rate Limiting
**REST limits:**
- Standard limit: a 40-request bucket per app and store
- Standard restore rate: 2 requests per second
- Shopify Plus: limits are typically 10 times the standard limit
- Read `X-Shopify-Shop-Api-Call-Limit` and honor `Retry-After`; Shopify can reduce limits temporarily
**Rate limit headers:**
```
X-Shop-API-Call-Limit: 30/40
X-Inventory-API-Call-Limit: 20/40
Retry-After: 2
```
**Handling 429 (Too Many Requests):**
```javascript
async function executeWithRetry(url, options, maxRetries = 5) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After')) || Math.pow(2, attempt - 1);
console.log(`Rate limited. Waiting ${retryAfter} seconds...`);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue;
}
return response;
}
throw new Error('Max retries exceeded');
}
```
---
## Common Legacy REST Resources
### Products
**1. List Products**
```bash
GET /admin/api/2025-10/products.json?limit=50&status=active
```
Response:
```json
{
"products": [
{
"id": 123456789,
"title": "Wireless Headphones",
"handle": "wireless-headphones",
"status": "active",
"vendor": "TechBrand",
"created_at": "2024-01-01T12:00:00Z",
"updated_at": "2025-10-15T14:30:00Z"
}
]
}
```
**2. Get Single Product**
```bash
GET /admin/api/2025-10/products/{id}.json
```
**3. Create Product**
```bash
POST /admin/api/2025-10/products.json
```
Body:
```json
{
"product": {
"title": "New Product",
"product_type": "Electronics",
"vendor": "MyVendor",
"status": "active"
}
}
```
**4. Update Product**
```bash
PUT /admin/api/2025-10/products/{id}.json
```
Body:
```json
{
"product": {
"id": 123456789,
"title": "Updated Title",
"status": "active"
}
}
```
### Variants
**5. List Product Variants**
```bash
GET /admin/api/2025-10/products/{product_id}/variants.json?limit=50
```
**6. Create Variant**
```bash
POST /admin/api/2025-10/products/{product_id}/variants.json
```
Body:
```json
{
"variant": {
"title": "Red / Small",
"sku": "WH-RED-S",
"price": "59.99",
"option1": "Red",
"option2": "Small"
}
}
```
**7. Update Variant**
```bash
PUT /admin/api/2025-10/products/{product_id}/variants/{id}.json
```
### Orders
**8. List Orders**
```bash
GET /admin/api/2025-10/orders.json?status=any&limit=50
```
Response:
```json
{
"orders": [
{
"id": 987654321,
"order_number": 1001,
"email": "customer@example.com",
"created_at": "2025-10-01T10:00:00Z",
"total_price": "99.99",
"currency": "USD",
"fulfillment_status": "fulfilled",
"financial_status": "paid"
}
]
}
```
**9. Get Single Order**
```bash
GET /admin/api/2025-10/orders/{id}.json
```
**10. Update Order**
```bash
PUT /admin/api/2025-10/orders/{id}.json
```
Body:
```json
{
"order": {
"id": 987654321,
"tags": "wholesale,vip"
}
}
```
### Customers
**11. List Customers**
```bash
GET /admin/api/2025-10/customers.json?limit=50
```
**12. Create Customer**
```bash
POST /admin/api/2025-10/customers.json
```
Body:
```json
{
"customer": {
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+1234567890"
}
}
```
**13. Update Customer**
```bash
PUT /admin/api/2025-10/customers/{id}.json
```
### Shop
**14. Get Shop Information**
```bash
GET /admin/api/2025-10/shop.json
```
Response:
```json
{
"shop": {
"id": 123456,
"name": "My Store",
"email": "shop@example.com",
"domain": "mystore.myshopify.com",
"currency": "USD",
"timezone": "America/New_York"
}
}
```
### Webhooks
**15. List Webhooks**
```bash
GET /admin/api/2025-10/webhooks.json
```
---
## REST to GraphQL Migration Recipes
### Recipe 1: Listing Products with Variants
**Old REST approach (inefficient):**
```javascript
// Step 1: Fetch products
const productsRes = await fetch(
'https://store.myshopify.com/admin/api/2025-10/products.json?limit=250',
{ headers: { 'X-Shopify-Access-Token': token } }
);
const { products } = await productsRes.json();
// Step 2: For each product, fetch variants separately (N+1 problem)
const productData = await Promise.all(
products.map(p =>
fetch(`https://store.myshopify.com/admin/api/2025-10/products/${p.id}/variants.json`,
{ headers: { 'X-Shopify-Access-Token': token } }
).then(r => r.json())
)
);
```
**New GraphQL approach (efficient):**
```javascript
const query = `
query {
products(first: 250) {
edges {
node {
id
title
variants(first: 250) {
edges {
node {
id
sku
price
}
}
}
}
}
}
}
`;
const response = await fetch(
'https://store.myshopify.com/admin/api/2026-01/graphql.json',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': token,
},
body: JSON.stringify({ query }),
}
);
```
**Benefits:** Single request, no N+1 problem, precise field selection, cost-aware rate limiting.
### Recipe 2: Creating Order with Line Items
**Old REST (multiple requests):**
```javascript
// REST doesn't support creating orders with line items directly
// Must create as draft order, then convert
const draftRes = await fetch(
'https://store.myshopify.com/admin/api/2025-10/draft_orders.json',
{
method: 'POST',
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({
draft_order: {
line_items: [{ variant_id: 123, quantity: 2 }]
}
})
}
);
```
**New GraphQL (atomic operation):**
```graphql
mutation CreateDraftOrder($input: DraftOrderInput!) {
draftOrderCreate(input: $input) {
draftOrder {
id
draftOrderLineItems(first: 10) {
edges {
node {
id
title
quantity
}
}
}
}
}
}
```
### Recipe 3: Bulk Update Variant Prices
**Old REST (request-by-request update):**
```javascript
const updates = [
{ id: 1, price: '49.99' },
{ id: 2, price: '59.99' },
// ... 1000 more
];
for (const update of updates) {
await fetch(
`https://store.myshopify.com/admin/api/2025-10/variants/${update.id}.json`,
{
method: 'PUT',
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ variant: { price: update.price } })
}
);
await new Promise(resolve => setTimeout(resolve, 100)); // Delay to avoid rate limit
}
// Time for 1000 updates: ~100 seconds
```
**New GraphQL with bulk operations (fast):**
```javascript
const jsonl = updates
.map(u => ({
__typename: 'ProductVariant',
id: `gid://shopify/ProductVariant/${u.id}`,
price: u.price
}))
.map(o => JSON.stringify(o))
.join('\n');
const bulkRes = await fetch(
'https://store.myshopify.com/admin/api/2026-01/graphql.json',
{
method: 'POST',
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `mutation { bulkOperationRunMutation(input: "${jsonl}") { bulkOperation { id status } } }`
})
}
);
// Time for 1000 updates: ~10-30 seconds (with polling)
```
---
## Red Flags: When REST Fails
| Red Flag | Symptom | Solution |
|----------|---------|----------|
| **N+1 problem** | 1000+ requests for simple data fetch | Migrate to GraphQL single query |
| **Rate limit throttling** | Constant 429 responses during bulk operations | Use GraphQL bulk operations |
| **Missing fields** | REST returns data you don't need (bloated) | Use GraphQL precise field selection |
| **Slow pagination** | Offset/limit pagination on large dataset | Use GraphQL cursor-based pagination |
| **No bulk update endpoint** | Creating/updating 100+ records slowly | Use GraphQL bulkOperationRunMutation |
| **Feature not in REST** | Trying to use 2025+ features | Must migrate to GraphQL |
| **Resource or version retirement** | A resource is unavailable or an API version is no longer supported | Track the version schedule and migrate to GraphQL before support ends |
---
## Critical Migration Checklist
For every maintained REST integration:
- [ ] Audit all REST API calls in your codebase
- [ ] Count requests per day (compare to GraphQL cost)
- [ ] Create GraphQL equivalents for each REST endpoint
- [ ] Test GraphQL mutations with real data
- [ ] Replace REST calls one endpoint at a time
- [ ] Monitor error rates during migration
- [ ] Remove REST calls once GraphQL is stable
- [ ] Set an internal GraphQL migration deadline based on the versions and resources you actually use
---
## Reference URLs
- [Shopify Admin REST API (Legacy)](https://shopify.dev/docs/api/admin-rest/latest)
- [API version schedule](https://shopify.dev/docs/api/usage/versioning)
- [Migration guide: REST to GraphQL](https://shopify.dev/docs/apps/build/graphql/migrate)
- [Shopify API limits](https://shopify.dev/docs/api/usage/limits)
app-accessibility25 KB
---
name: app-accessibility
description: "Use when auditing or building accessibility in a Shopify embedded app — WCAG 2.1 AA, keyboard navigation, focus management in Modal/SaveBar/ResourcePicker, screen reader support (NVDA/JAWS/VoiceOver), color contrast within Polaris tokens, ARIA usage, alt text, i18n + a11y, and Built for Shopify accessibility gates. Triggers: 'accessibility shopify app', 'a11y shopify', 'WCAG 2.1 AA', 'screen reader shopify', 'keyboard nav shopify app', 'focus management modal', 'polaris contrast', 'color contrast shopify', 'aria label polaris', 'shopify accessibility audit', 'built for shopify accessibility'."
---
# Shopify Embedded App Accessibility (WCAG 2.1 AA)
Accessibility is a Built for Shopify gate and a legal requirement (ADA Title III, European Accessibility Act took effect 2025-06-28). Audited apps fail BFS review when keyboard navigation breaks inside iframes, focus indicators are stripped, alt text is missing on product imagery, or color contrast drops below 4.5:1. This skill is the checklist for every embedded app feature.
## 1. When to Use
Trigger this skill when:
- Building any new screen, Modal, SaveBar, or form in an embedded app
- Auditing an existing app before Built for Shopify submission
- Investigating a merchant complaint about keyboard or screen reader usage
- Reviewing a PR that touches focus, ARIA, color, or alt text
- Deciding whether to override a Polaris token (almost never — see Section 6)
- Adding i18n / RTL support that touches reading order, aria-label strings, or lang attributes
- Pairing with `polaris-ui` (component usage) or `app-bridge` (Modal/SaveBar) — this skill is the a11y overlay on top of both
Do NOT use this skill for storefront / theme accessibility — that is a separate domain (`shopify.dev/docs/storefronts/themes/best-practices/accessibility`).
## 2. WCAG 2.1 AA Criteria Applied to Shopify Apps
Shopify Polaris targets WCAG 2.1 A and AA by default. Embedded apps inherit that baseline only if you use Polaris components correctly and do not override semantics, contrast, or focus. The criteria that matter most for admin apps:
**Perceivable**
- 1.1.1 Non-text Content (A) — every image, icon-only button, and chart needs a text alternative
- 1.3.1 Info and Relationships (A) — use semantic HTML; `<Text as="h2">` not `<Text variant="headingMd">` on a paragraph
- 1.3.5 Identify Input Purpose (AA) — use `autoComplete` on email, name, address, tel inputs
- 1.4.3 Contrast Minimum (AA) — 4.5:1 for text under 18pt, 3:1 for large text
- 1.4.11 Non-text Contrast (AA) — 3:1 for form borders, focus rings, icons that convey state
- 1.4.10 Reflow (AA) — content must reflow at 320 CSS px width; no horizontal scroll inside the iframe
- 1.4.12 Text Spacing (AA) — line height 1.5x font-size, paragraph spacing 2x; Polaris defaults pass
**Operable**
- 2.1.1 Keyboard (A) — every interactive element reachable and operable via keyboard
- 2.1.2 No Keyboard Trap (A) — except in Modal, focus must be able to leave any region
- 2.4.3 Focus Order (A) — DOM order matches visual order; do not reorder with positive `tabindex`
- 2.4.7 Focus Visible (AA) — never set `outline: none` without a replacement
- 2.5.5 Target Size (AAA, but BFS-watched) — interactive targets at least 44x44 CSS px
**Understandable**
- 3.1.1 Language of Page (A) — `<html lang="en">` set at the embedded app's HTML root, not the Shopify admin's
- 3.2.2 On Input (A) — changing a `<Select>` value must not auto-submit or auto-navigate
- 3.3.1 Error Identification (A) — TextField `error` prop populated; never rely on red border alone
- 3.3.2 Labels or Instructions (A) — every TextField has a non-empty `label`
**Robust**
- 4.1.2 Name, Role, Value (A) — custom widgets must expose accessible name + role + state
- 4.1.3 Status Messages (AA) — Toasts and Banners must be announced (Polaris Toast uses `role="status"` automatically)
## 3. Top 20 Accessibility Failures in Embedded Apps
| # | Failure | Fix |
|---|---------|-----|
| 1 | Icon-only Button with no label | `<Button icon={DeleteIcon} accessibilityLabel="Delete product" />` |
| 2 | Image with empty/missing alt | `<img src={url} alt="Product hero photo of red sneaker" />` or `alt=""` for decoration |
| 3 | TextField with no `label` prop | Always set `label`; use `labelHidden` if hiding visually |
| 4 | `<div onClick>` for interactive element | Use `<Button variant="plain">` instead |
| 5 | Color alone conveys state (red border = error) | Pair color with `error="Email is required"` text + icon |
| 6 | Modal opens but focus stays on trigger | Polaris Modal handles this — do not override; do not roll your own |
| 7 | Modal closes but focus does not return | Same — let Polaris Modal manage it; never call `.focus()` manually inside `onClose` |
| 8 | `outline: none` stripped from focus ring | Remove the rule; or replace with `outline: 2px solid var(--p-color-border-focus); outline-offset: 2px;` |
| 9 | Positive `tabindex="3"` to reorder | Delete the tabindex; restructure DOM |
| 10 | `<h1>` then `<h4>` (skipped heading levels) | Use `<Text as="h1">`, `<Text as="h2">` in DOM order |
| 11 | Toast for a critical error message | Use `<Banner tone="critical">` (persistent) instead — Toasts auto-dismiss |
| 12 | Loading spinner with no accessible name | `<Spinner accessibilityLabel="Loading products" />` |
| 13 | Tooltip is the only place help text lives | Move to `helpText` prop on TextField; tooltip can supplement |
| 14 | IndexTable row click but no row label | `<IndexTable.Row id={id} position={i}>` plus row content with names |
| 15 | Form submits on Enter but no submit button | Add a visible `<Button submit>Save</Button>` for screen reader users |
| 16 | Color contrast 3.5:1 on subdued text | Use `tone="subdued"` only on small non-essential text; never below 4.5:1 for body |
| 17 | Animated Banner / Toast with no `prefers-reduced-motion` respect | Polaris respects it; do not add your own keyframe animations |
| 18 | ResourcePicker invoked from a non-button element | Trigger from `<Button>` so screen reader announces it as a button |
| 19 | iframe missing `title` attribute | `<iframe title="Shopify product import preview" ...>` |
| 20 | App HTML has no `lang` attribute | Add `<html lang="en">` (or merchant's locale) to the embedded app root |
## 4. Keyboard Navigation
### Focus Trap in Modal
Polaris `<Modal>` and App Bridge `<ui-modal>` both implement focus trap automatically. When the modal opens, focus moves to the modal container (or the first focusable element). Tab cycles through focusable elements inside the modal only. Shift+Tab cycles backward. Escape closes the modal. Click on the backdrop closes. Do not write your own focus trap — you will diverge from Polaris and confuse screen readers.
```tsx
import { Modal, TextField } from '@shopify/polaris';
<Modal
open={isOpen}
onClose={() => setIsOpen(false)}
title="Edit Product"
primaryAction={{ content: 'Save', onAction: handleSave }}
secondaryActions={[{ content: 'Cancel', onAction: () => setIsOpen(false) }]}
>
<Modal.Section>
<TextField label="Product name" value={name} onChange={setName} autoFocus />
</Modal.Section>
</Modal>
```
Notes:
- `title` becomes `aria-labelledby` automatically
- `autoFocus` on the first input is allowed and recommended
- Do NOT add `aria-modal="true"` manually — Polaris already does
### Focus Return on Close
When the Modal closes, Polaris returns focus to the trigger element automatically. This works only if:
- The trigger is still mounted (do not unmount the trigger button while Modal is closing)
- You did not call `.blur()` on the trigger before opening the Modal
- You did not move focus elsewhere in `onClose`
If the trigger is dynamic (inside a table row that may have unmounted), use a `useRef` to a stable wrapper:
```tsx
const triggerRef = useRef<HTMLButtonElement>(null);
// pass triggerRef.current to focus on close if needed
```
### Save Bar Focus
App Bridge `<ui-save-bar>` and Polaris `<ContextualSaveBar>` are not modal — they do not trap focus. Save / Discard are reachable via Tab from the form. Two rules:
1. The save bar's primary action must be reachable without leaving the form (do not place the save bar in a portal that breaks Tab order)
2. After Save success, focus should return to the form area (not jump to `<body>`). If you re-render the page, place focus on the page heading:
```tsx
const headingRef = useRef<HTMLHeadingElement>(null);
const handleSaveSuccess = () => {
shopify.toast({ title: 'Saved' });
headingRef.current?.focus();
};
<h1 ref={headingRef} tabIndex={-1}>Product settings</h1>
```
The `tabIndex={-1}` makes it programmatically focusable without inserting it into the Tab order.
### Resource Picker Focus
`shopify.resourcePicker()` and `<ui-resource-picker>` are full-screen overlays rendered by the Shopify admin (not your iframe). Shopify handles focus inside the picker. Your responsibility:
- The trigger must be a `<Button>` (announced as button by screen readers)
- After `onSelection` or `onCancel`, focus should return to the trigger; Shopify usually does this — if not, focus the trigger manually
```tsx
const pickerTriggerRef = useRef<HTMLButtonElement>(null);
const openPicker = async () => {
await shopify.resourcePicker({
type: 'product',
onSelection: (resources) => {
setSelected(resources.selection);
pickerTriggerRef.current?.focus();
},
onCancel: () => pickerTriggerRef.current?.focus(),
});
};
```
### Tab Order Rules
- Visual order must match DOM order
- Never use `tabindex` greater than 0
- `tabindex="0"` to make a non-interactive element focusable (rare — use a Button instead)
- `tabindex="-1"` to make an element programmatically focusable but skipped by Tab (page headings after navigation)
## 5. Screen Reader Testing
Test every release on at least one screen reader. Two minimum combos cover ~80% of the market:
| Screen Reader | OS | Browser | Cost |
|---------------|-----|---------|------|
| NVDA | Windows | Firefox or Chrome | Free |
| VoiceOver | macOS | Safari | Built in |
| JAWS | Windows | Chrome | Paid (skip unless enterprise) |
**Minimum coverage: NVDA on Firefox AND VoiceOver on Safari.**
### What to Test
For every page or modal:
1. Page load — does the screen reader announce the page heading?
2. Heading navigation (H key in NVDA, VO+Cmd+H in VoiceOver) — heading levels are sensible and ordered
3. Form fields — each TextField announces its label, required state, error message, help text
4. Buttons — every button has a non-empty accessible name (no "Button" alone)
5. Tables — IndexTable rows announce row position and content
6. Modal open — title is announced, focus is inside the modal, Escape closes
7. Toast — success messages are announced; critical errors use Banner instead
8. Live regions — Banner / Toast updates do not interrupt the user mid-sentence
### Quick NVDA Cheat Sheet
- Start: `Ctrl + Alt + N`
- Stop speech: `Ctrl`
- Read next line: `Down`
- Read element: `NVDA + Tab`
- Headings list: `Insert + F7`
- Browse mode toggle: `NVDA + Space`
### Quick VoiceOver Cheat Sheet
- Start / stop: `Cmd + F5`
- VO key: `Ctrl + Option`
- Move next: `VO + Right`
- Open rotor (headings, links, forms): `VO + U`
- Read element: `VO + A`
## 6. Polaris Color Contrast Tokens
Polaris colors are generated in HSLuv to guarantee WCAG 2.1 AA contrast. Use semantic tokens. Never hardcode hex values for text or interactive elements.
### Safe text tokens (4.5:1+ against `bg-surface`)
- `--p-color-text` — primary body text
- `--p-color-text-subdued` — secondary text; still passes 4.5:1 on bg-surface but fails on bg-surface-secondary in some themes — measure
- `--p-color-text-critical` — error text
- `--p-color-text-success` — success text
- `--p-color-text-warning` — warning text
- `--p-color-text-inverse` — text on dark fills
### Safe interactive tokens (3:1+ for borders and icons)
- `--p-color-border` — form borders
- `--p-color-border-focus` — focus rings
- `--p-color-icon` — neutral icons
- `--p-color-icon-subdued` — only on icons that have a visible text label nearby
### Tokens to NEVER override below contrast
| Token | Minimum ratio | Notes |
|-------|---------------|-------|
| `--p-color-text` | 4.5:1 | Body copy. Override only if you re-measure. |
| `--p-color-text-critical` | 4.5:1 | Error messages. |
| `--p-color-border-focus` | 3:1 | Focus ring against surface AND against fill — must pass on both. |
| `--p-color-border` | 3:1 | Form borders against the surface they sit on. |
### When You Must Use Brand Color
If product owner wants a custom brand color on a Button or Banner accent:
1. Run the candidate color through a contrast checker against `--p-color-bg-surface` (light) AND its dark theme equivalent if Shopify admin dark mode is enabled
2. Aim for 4.5:1 on text, 3:1 on borders / icons
3. If it fails, darken until it passes — do not ship a failing color
Polaris ships some yellow tones that are marginal on light backgrounds. For warning text always use `--p-color-text-warning`, not raw yellow hex.
## 7. ARIA — When Needed, When Polaris Covers It
The first rule of ARIA: do not use ARIA if a native HTML element exists. The second rule: if Polaris renders an element, it already wires the ARIA. Do not double-aria.
### Already Handled by Polaris (do NOT add yourself)
| Polaris Component | ARIA it sets | Do not duplicate |
|-------------------|--------------|------------------|
| `<Modal title="X">` | `role="dialog"`, `aria-modal="true"`, `aria-labelledby` | Adding `role="dialog"` to a child div is wrong |
| `<TextField label="X" error="Y">` | `aria-labelledby`, `aria-invalid`, `aria-describedby` | Adding `aria-label` on top of `label` is wrong |
| `<Button>` | `role="button"` (it IS a button) | Adding `role="button"` to a Polaris Button is redundant |
| `<Banner>` | `role="status"` or `role="alert"` based on tone | Adding `aria-live` manually breaks announcements |
| `<Toast>` | `role="status"` + live region | Do not wrap in your own `aria-live` |
| `<Tabs>` | `role="tablist"`, `role="tab"`, `aria-selected`, `aria-controls` | Hand-rolled tabs are an anti-pattern |
| `<Checkbox>` / `<RadioButton>` | `role="checkbox"`/`"radio"`, `aria-checked` | Use the component |
| `<Spinner accessibilityLabel="X">` | `role="status"`, `aria-label` | Always provide accessibilityLabel |
### Where You Must Add ARIA Yourself
1. **Icon-only Buttons** — `accessibilityLabel` prop:
```tsx
<Button icon={DeleteIcon} accessibilityLabel="Delete product" />
```
2. **ResourceItem** — `accessibilityLabel` prop describing the row action:
```tsx
<ResourceItem id={id} accessibilityLabel={`View ${product.title}`} />
```
3. **Custom widgets** — if you must build a custom dropdown, accordion, or combobox (consider redesigning with Polaris first), follow the WAI-ARIA Authoring Practices pattern exactly
4. **Page heading after route change** in SPA — set `tabIndex={-1}` and call `.focus()`
5. **Decorative images** — `alt=""` (empty alt, never missing)
6. **Informative images** — `alt="Descriptive sentence under 125 chars"`
7. **iframes you create** — `title="..."` (browser screen readers announce this)
## 8. Internationalization + Accessibility
Embedded apps support multiple merchant locales. Accessibility strings localize too.
### `lang` Attribute
Set on the embedded app's HTML root, matching the merchant's locale (you can read it from `shopify.config.locale` or the `locale` URL param):
```html
<html lang="fr-CA">
```
If a section of content is in a different language than the page, mark it:
```html
<p>The product is <span lang="ja">商品</span>.</p>
```
This lets screen readers switch pronunciation engine.
### RTL Support
For Arabic and Hebrew merchants, set `dir="rtl"` on the HTML root. Polaris supports RTL via the `AppProvider`'s i18n object. Mirror:
- Layout direction (Polaris handles this in CSS)
- Icons that imply direction (`ChevronRightIcon` should flip to `ChevronLeftIcon`)
- Reading order in custom layouts
### Localized Accessibility Strings
`accessibilityLabel`, `helpText`, `error`, `label` props all accept strings — pass them through your i18n library:
```tsx
<Button icon={DeleteIcon} accessibilityLabel={t('product.delete.label')} />
<TextField label={t('product.name.label')} helpText={t('product.name.help')} />
```
Never concatenate strings ("Delete " + product.title) — pluralization and word order vary by locale. Use ICU message format.
### Locale-aware Screen Reader Strings
For Toasts and Banners, run the message through i18n before passing to `shopify.toast({ title, message })`. The admin's screen reader uses the merchant's locale; mismatched language makes the announcement unintelligible.
## 9. Built for Shopify Accessibility Gates
Built for Shopify certification reviews accessibility. As of the 2026 review criteria the gates that block approval are:
1. **Keyboard navigation works on every interactive element** — Tab reaches everything, Enter / Space activates, Escape closes overlays
2. **Visible focus indicator on every focusable element** — no `outline: none` without a replacement
3. **Color contrast 4.5:1 on text, 3:1 on UI components** — measured at the default Shopify admin theme
4. **All form inputs have labels** — visible or `labelHidden`, never absent
5. **Modal focus management works** — focus enters on open, traps inside, returns on close
6. **Icon-only buttons have accessible names** — `accessibilityLabel` populated
7. **Images have alt text or `alt=""`** — never missing
8. **No keyboard traps outside Modal** — Tab can always leave a region
9. **Page has a `<h1>`** and heading levels are not skipped
10. **Status messages are announced** — Toasts and Banners use Polaris components, not custom `<div>`
BFS auditors test with NVDA + Firefox and VoiceOver + Safari. Apps fail review if they break these on the primary user flows (install, onboard, core feature).
Always re-check the current criteria at shopify.dev/docs/apps/launch/built-for-shopify before submitting — Shopify updates the bar.
## 10. Common Fixes Table
| Issue | Fix Code |
|-------|----------|
| Icon-only Button | `<Button icon={EditIcon} accessibilityLabel="Edit product" />` |
| Decorative image | `<img src="..." alt="" role="presentation" />` |
| Informative image | `<img src="..." alt="Customer order shipped in branded box" />` |
| TextField needs hidden label | `<TextField label="Search" labelHidden value={q} onChange={setQ} />` |
| Page heading after route change | `<h1 ref={r} tabIndex={-1}>Title</h1>` then `r.current?.focus()` |
| Replace `outline: none` | `outline: 2px solid var(--p-color-border-focus); outline-offset: 2px;` |
| Loading spinner | `<Spinner accessibilityLabel="Loading orders" size="large" />` |
| Error not visible to SR | `<TextField label="Email" error="Email is required" value={v} onChange={setV} />` |
| Custom dropdown | Replace with `<Select>` or `<Combobox>` from Polaris |
| Modal without title | Always pass `title` prop — used for `aria-labelledby` |
| Toast for critical error | Replace with `<Banner tone="critical" title="...">...</Banner>` |
| Heading level skip | Use `<Text as="h2">`, `<Text as="h3">` in order |
| ResourceItem unclear | `<ResourceItem id={id} accessibilityLabel={`Open order ${order.name}`} />` |
| iframe missing title | `<iframe title="Product preview" src={url} />` |
| Form has no submit button | Add `<Button submit>Save</Button>` even if SaveBar exists |
| Color-only state on Badge | `<Badge tone="critical">Failed</Badge>` (text + tone, not tone alone) |
| HTML root no lang | `<html lang={merchantLocale}>` at template / index.html |
| Decoration icon announced | `<Icon source={DotIcon} accessibilityLabel="" />` or wrap with `aria-hidden="true"` |
| Long form needs structure | Use `<FormLayout.Group>` to group related fields |
| Disabled button with no reason | Add `helpText` near the trigger explaining why |
## 11. Twenty A11y Rules for Our Plugin
1. Wrap every embedded app in `<AppProvider i18n={...}>` and `<Frame>` if you need toasts and loading
2. Use Polaris components for every interactive element — Button, TextField, Select, Modal, Banner, Toast
3. Never set `outline: none` on any focusable element without an equivalent visible replacement
4. Every Button that has only an icon must set `accessibilityLabel`
5. Every TextField, Select, Checkbox, RadioButton must have a non-empty `label` (use `labelHidden` to hide visually)
6. Every image gets an `alt` attribute — `alt=""` for decoration, descriptive sentence for content
7. Use semantic heading levels — `<Text as="h1">` once per page, then h2, h3 in order
8. Use `<Banner tone="critical">` for persistent critical errors, `<Toast>` for transient success
9. Validate forms inline with the `error` prop; never rely on color alone
10. Test color contrast at 4.5:1 minimum for text; use Polaris tokens, never hardcode hex for text
11. Trigger Modal from a `<Button>`; let Polaris handle focus trap and return
12. Trigger ResourcePicker from a `<Button>`; restore focus to trigger after `onSelection` / `onCancel`
13. After SPA navigation, focus the new page heading with `tabIndex={-1}` + `.focus()`
14. Set `lang` on the HTML root matching the merchant's locale
15. Localize every user-facing string including `accessibilityLabel`, `helpText`, `error`
16. Never use a positive `tabindex` value; never reorder DOM with CSS in a way that breaks Tab order
17. Test every release with NVDA on Firefox AND VoiceOver on Safari
18. Run `axe-core` or `@axe-core/react` in development; fail CI on critical violations
19. Document any Polaris override in a comment with the contrast ratio measured
20. Reserve `aria-*` attributes for cases Polaris does not cover; never double-aria a Polaris element
## 12. Test Checklist
Pre-merge a11y checklist:
- [ ] All interactive elements reachable via Tab in DOM order
- [ ] Visible focus indicator on every focusable element
- [ ] Escape closes every Modal
- [ ] Focus returns to trigger after Modal closes
- [ ] Every Button with an icon-only has `accessibilityLabel`
- [ ] Every TextField / Select / Checkbox has a `label`
- [ ] Every image has `alt` (empty for decoration, descriptive otherwise)
- [ ] No `outline: none` without replacement
- [ ] Page has exactly one `<h1>` and no skipped heading levels
- [ ] Color contrast 4.5:1 on text, 3:1 on borders / icons (sampled with browser devtools)
- [ ] Forms validate inline with `error` prop, not color alone
- [ ] Critical errors use `<Banner>`, not `<Toast>`
- [ ] `lang` attribute set on HTML root
- [ ] axe-core run shows zero critical or serious violations
- [ ] Manual NVDA + Firefox pass on the core happy path
- [ ] Manual VoiceOver + Safari pass on the core happy path
- [ ] Keyboard-only walkthrough of install, onboard, and primary feature
- [ ] Screen at 320 CSS px width — no horizontal scroll inside the iframe
- [ ] Text resize at 200% — no clipped content
- [ ] `prefers-reduced-motion` respected (Polaris does this by default — verify no custom keyframes)
- [ ] Every accessibilityLabel and helpText is in the merchant's locale
## 13. Decision Tree
```
Building a new UI element?
│
├── Is there a Polaris component for it?
│ ├── Yes → Use Polaris. Pass label / accessibilityLabel / helpText / error props. STOP.
│ └── No → Continue.
│
├── Is there a native HTML element for it (button, a, input, label, table, dialog)?
│ ├── Yes → Use native HTML. Add ARIA only if native semantics insufficient.
│ └── No → Continue.
│
├── Are you building a custom widget (combobox, accordion, tabs)?
│ ├── Yes → Look one more time for a Polaris equivalent. If genuinely none:
│ │ follow WAI-ARIA Authoring Practices pattern exactly,
│ │ write keyboard handlers, test with NVDA + VoiceOver,
│ │ document the ARIA contract in the component header
│ └── No → Reconsider; you probably do not need a new widget
│
After build:
│
├── Run axe-core. Zero critical / serious violations? → Continue. Else fix.
├── Tab through entire flow with no mouse. Reach everything? → Continue. Else fix focus order.
├── Open NVDA + Firefox. Every Button, Field, Heading announced clearly? → Continue. Else add accessibilityLabel / fix heading.
├── Open VoiceOver + Safari. Same pass. → Continue. Else fix.
├── Sample colors with devtools. 4.5:1 text, 3:1 borders? → Continue. Else swap to Polaris token.
└── Resize to 320 px wide and 200% zoom. No clipping? → Ship. Else use Polaris layout primitives (BlockStack, InlineStack, InlineGrid) to fix reflow.
```
## Sources
- [Polaris Accessibility — Shopify Polaris React](https://polaris-react.shopify.com/foundations/accessibility)
- [Accessibility testing — Shopify/polaris (GitHub)](https://github.com/Shopify/polaris/blob/main/documentation/Accessibility%20testing.md)
- [Accessibility best practices for Shopify apps — shopify.dev](https://shopify.dev/docs/apps/build/accessibility)
- [ui-modal — App Bridge Library](https://shopify.dev/docs/api/app-bridge-library/web-components/ui-modal)
- [Polaris Color Tokens](https://polaris-react.shopify.com/tokens/color)
- [Screen Reader Testing Guide — TestParty](https://testparty.ai/blog/screen-reader-testing-guide)
app-auth28.1 KB
---
name: app-auth
description: "Implement OAuth 2.0, Token Exchange, Managed Installation, App Proxy, and webhook verification for Shopify apps. Support online/offline tokens, session storage (Prisma, Redis, Memory), and multi-auth patterns. Covers admin API, public apps, custom apps, and customer account authentication. Triggers include: 'Shopify authentication', 'OAuth 2.0', 'Token Exchange', 'Managed Installation', 'App Proxy', 'Webhook signature', 'HMAC verification', 'Admin API auth', 'Customer Account API', 'Session storage', 'Online token', 'Offline token', 'Remix auth', 'shopify auth'."
---
# Shopify App Authentication
Shopify provides multiple authentication flows depending on your app type and use case. The modern standard is **Token Exchange** (2024+) for server-rendered apps, **Managed Installation** for headless apps, and **OAuth 2.0 Authorization Code Grant** for legacy/custom implementations. All flows result in an access token for the Shopify GraphQL Admin API.
## Authentication Flows Overview
### 1. Token Exchange (Recommended for 2024+)
**Use Case:** Server-rendered apps (Remix, Next.js with SSR), Shopify CLI apps
**Flow:** Merchant installs app → Shopify generates temporary exchange token → App exchanges for access token
**Security:** No client secret exposed; uses PKCE-style rotation per request
**Token Lifetime:** Access tokens are short-lived; refresh tokens rotate automatically
**Token Exchange Diagram:**
```
1. Merchant clicks "Install" in Shopify Admin
2. Shopify redirects: https://your-app.com/auth/callback?code=EXCHANGE_TOKEN
3. App validates HMAC, exchanges code for access token (private, server-side only)
4. Shopify Admin API grants scopes; token stored in session/database
5. Token auto-refreshes on next request if expired
```
**Remix Implementation (Token Exchange):**
```typescript
// shopify.app.ts (App Configuration)
import { shopifyApp } from '@shopify/shopify-app-remix/server';
import { restResources } from '@shopify/shopify-api/rest/admin/2026-07';
import { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';
import { prisma } from '~/db.server';
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY || '',
apiSecret: process.env.SHOPIFY_API_SECRET || '',
scopes: process.env.SCOPES?.split(',') || [
'write_products',
'read_products',
'write_orders',
'read_orders',
'write_customers',
'read_customers',
'write_discounts',
'read_discounts',
'write_fulfillments',
'read_fulfillments',
'write_inventory',
'read_inventory',
],
appUrl: process.env.SHOPIFY_APP_URL || 'http://localhost:3000',
auth: {
path: '/auth',
callbackPath: '/auth/callback',
},
webhooks: {
path: '/webhooks',
},
isEmbeddedApp: true, // Polaris admin dashboard
sessionStorage: new PrismaSessionStorage(prisma),
restResources, // Includes REST API helpers
});
export default shopify;
```
**Environment Variables (.env):**
```bash
SHOPIFY_API_KEY=your-public-api-key-from-partner-dashboard
SHOPIFY_API_SECRET=your-private-api-secret
SHOPIFY_APP_URL=https://your-domain.ngrok.io # or prod URL
SCOPES=write_products,read_products,write_orders,read_orders
```
**Auth Routes (routes/auth.$.tsx - Catch-all route):**
```typescript
import { redirect } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
export const loader = async ({ request }) => {
const { authenticate } = await import('~/shopify.server');
return authenticate.admin(request); // Token Exchange happens here
};
```
**Callback Handling (routes/auth.callback.tsx):**
```typescript
import { json } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
// Session contains:
// - session.accessToken (valid for API calls)
// - session.shop (merchant's shop domain)
// - session.scope (granted scopes)
// - session.state (optional custom data)
return redirect('/app'); // Redirect to dashboard after auth
};
```
**Using Token in API Calls (routes/app.products.tsx):**
```typescript
import { json } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
import { GraphQLClient } from 'graphql-request';
export const loader = async ({ request }) => {
const { session, admin } = await authenticate.admin(request);
// Method 1: Use Shopify's admin helper (recommended)
const response = await admin.graphql(`
query GetProducts {
products(first: 10) {
edges {
node {
id
title
handle
}
}
}
}
`);
// Method 2: Manual GraphQL call
const client = new GraphQLClient(
`https://${session.shop}/admin/api/2026-07/graphql.json`,
{
headers: {
'X-Shopify-Access-Token': session.accessToken,
'Content-Type': 'application/json',
},
}
);
const data = await client.request(/* ... */);
return json({ products: response.data?.products?.edges || [] });
};
```
**Token Refresh (Automatic):**
Token Exchange tokens auto-refresh via Shopify's session middleware. No manual refresh needed:
```typescript
// Tokens are refreshed transparently on each request
const response = await admin.graphql(query); // Handles refresh internally
```
### 2. Managed Installation (Headless/Custom Apps)
**Use Case:** Headless storefront, mobile apps, third-party integrations
**Flow:** Merchant authorizes app → Shopify generates permanent access token (no secret rotation)
**Token Lifetime:** Long-lived; no refresh required
**Security:** Token is permanent; store securely in environment variable
**Managed Installation Setup:**
In Shopify Partner Dashboard:
1. App Settings > API Credentials
2. Select "Managed installation" under Admin API access scopes
3. Merchant grants permission once
4. Copy access token to your environment
```bash
# .env
SHOPIFY_ADMIN_ACCESS_TOKEN=<SHOPIFY_ADMIN_ACCESS_TOKEN>
SHOPIFY_SHOP_URL=example-shop.myshopify.com
```
**Using Managed Installation Token:**
```typescript
import { GraphQLClient } from 'graphql-request';
const client = new GraphQLClient(
`https://${process.env.SHOPIFY_SHOP_URL}/admin/api/2026-07/graphql.json`,
{
headers: {
'X-Shopify-Access-Token': process.env.SHOPIFY_ADMIN_ACCESS_TOKEN || '',
},
}
);
// Token never expires; call API anytime
const query = `query { products(first: 10) { edges { node { id title } } } }`;
const products = await client.request(query);
```
### 3. OAuth 2.0 Authorization Code Grant (Legacy, Still Supported)
**Use Case:** Public apps with traditional OAuth flow
**Flow:** Merchant clicks "Install" → App redirects to Shopify OAuth → Merchant authorizes → App receives code → App exchanges code for token
**Token Lifetime:** Long-lived access token (no expiration unless revoked)
**Security:** Client secret required; PKCE optional
**OAuth Flow Diagram:**
```
1. Merchant visits: https://your-app.com/auth
2. App redirects to: https://your-shop.myshopify.com/admin/oauth/authorize?client_id=KEY&scope=write_products&redirect_uri=https://your-app.com/auth/callback&state=RANDOM
3. Merchant authorizes app in Shopify Admin
4. Shopify redirects back: https://your-app.com/auth/callback?code=AUTHORIZATION_CODE&hmac=SIGNATURE&state=RANDOM&shop=your-shop.myshopify.com
5. App validates HMAC and state
6. App exchanges code for token (server-side, using client secret)
7. App stores token in database
```
**Express.js OAuth Example:**
```typescript
import express from 'express';
import axios from 'axios';
import crypto from 'crypto';
const app = express();
const API_KEY = process.env.SHOPIFY_API_KEY || '';
const API_SECRET = process.env.SHOPIFY_API_SECRET || '';
const REDIRECT_URI = process.env.REDIRECT_URI || 'https://your-app.com/auth/callback';
const SCOPES = 'write_products,read_products';
// Step 1: Redirect merchant to Shopify OAuth
app.get('/auth', (req, res) => {
const shop = req.query.shop as string;
if (!shop || !shop.includes('.myshopify.com')) {
return res.status(400).send('Missing or invalid shop parameter');
}
const state = crypto.randomBytes(16).toString('hex');
const nonce = crypto.randomBytes(16).toString('hex');
// Store state in session (or database) for validation
req.session.state = state;
req.session.nonce = nonce;
const authUrl = new URL(
`/admin/oauth/authorize`,
`https://${shop}`
);
authUrl.searchParams.append('client_id', API_KEY);
authUrl.searchParams.append('scope', SCOPES);
authUrl.searchParams.append('redirect_uri', REDIRECT_URI);
authUrl.searchParams.append('state', state);
res.redirect(authUrl.toString());
});
// Step 2: Handle OAuth callback
app.get('/auth/callback', async (req, res) => {
const { code, hmac, shop, state } = req.query;
// Validate HMAC
const message = Object.entries(req.query)
.filter(([key]) => key !== 'hmac')
.map(([key, value]) => `${key}=${value}`)
.sort()
.join('&');
const hash = crypto
.createHmac('sha256', API_SECRET)
.update(message, 'utf8')
.digest('base64');
if (hash !== hmac) {
return res.status(401).send('Unauthorized request detected');
}
// Validate state
if (state !== req.session.state) {
return res.status(401).send('State mismatch');
}
try {
// Exchange code for access token
const response = await axios.post(
`https://${shop}/admin/oauth/access_token`,
{
client_id: API_KEY,
client_secret: API_SECRET,
code,
}
);
const { access_token, scope } = response.data;
// Store access token (in database, not session, for persistence)
await storeAccessToken(shop as string, access_token, scope);
// Redirect to app dashboard
res.redirect(`/app?shop=${shop}`);
} catch (error) {
console.error('Token exchange error:', error);
res.status(500).send('Authentication failed');
}
});
// Helper function to store token
async function storeAccessToken(shop: string, token: string, scope: string) {
// Store in database (example using mock storage)
const db = {
shops: {} as Record<string, { token: string; scope: string }>,
};
db.shops[shop] = { token, scope };
}
app.listen(3000);
```
## Session Storage Adapters
Access tokens must be stored persistently. Shopify provides adapters for common storage backends:
### Prisma (Recommended for Remix)
**Schema (prisma/schema.prisma):**
```prisma
model Session {
id String @id
shop String
state String
isOnline Boolean @default(false)
accessToken String
refreshToken String?
scope String
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([shop, state])
@@index([shop])
}
```
**Setup (shopify.app.ts):**
```typescript
import { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';
import { prisma } from '~/db.server';
const shopify = shopifyApp({
// ...
sessionStorage: new PrismaSessionStorage(prisma),
});
```
### Redis
```typescript
import { RedisSessionStorage } from '@shopify/shopify-app-session-storage-redis';
import redis from 'redis';
const redisClient = redis.createClient({
host: 'localhost',
port: 6379,
});
const sessionStorage = new RedisSessionStorage({
client: redisClient,
prefix: 'shopify_session:',
});
const shopify = shopifyApp({
// ...
sessionStorage,
});
```
### In-Memory (Development Only)
```typescript
import { MemorySessionStorage } from '@shopify/shopify-app-session-storage';
const sessionStorage = new MemorySessionStorage();
const shopify = shopifyApp({
// ...
sessionStorage, // CAUTION: Sessions lost on app restart; development only
});
```
### DynamoDB
```typescript
import { DynamoDBSessionStorage } from '@shopify/shopify-app-session-storage-dynamodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
const dynamoDBClient = new DynamoDBClient({ region: 'us-east-1' });
const sessionStorage = new DynamoDBSessionStorage({
client: dynamoDBClient,
tableName: 'shopify-sessions',
});
const shopify = shopifyApp({
// ...
sessionStorage,
});
```
## Online vs. Offline Tokens
**Online Token:**
- Scope: Current user's permissions (typically admin user)
- Expiration: 24 hours
- Use Case: Browser-based actions (Polaris admin dashboard)
- Limitations: Cannot run background jobs; limited when user logs out
**Offline Token:**
- Scope: App's granted scopes
- Expiration: None; permanent until revoked
- Use Case: Background jobs, webhooks, scheduled tasks
- Limitations: None; use by default for app operations
**Requesting Offline Token in Token Exchange:**
```typescript
// shopify.app.ts
const shopify = shopifyApp({
// ...
auth: {
path: '/auth',
callbackPath: '/auth/callback',
},
});
// Remix automatically requests offline token by default
// No action needed; use session.accessToken for API calls
```
**Using Offline Token for Webhooks:**
```typescript
// webhooks/products-update.ts
export const webhooks = {
APP_UNINSTALLED: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/webhooks/app-uninstalled',
},
PRODUCTS_UPDATE: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/webhooks/products-update',
},
};
export default defineWebhooksConfig(
async (request, { admin, session }) => {
const { body, query } = await graphql.query(request, {
query: GET_PRODUCT,
variables: { id: 'gid://shopify/Product/123' },
});
// session.accessToken is offline token; valid here
console.log(`Webhook processed with token for shop: ${session.shop}`);
},
webhooksConfig
);
```
## App Proxy Authentication
**Use Case:** Storefront (public-facing) requests to app backend
**Authentication:** HMAC signature validation (like webhooks)
**Flow:** Storefront → Liquid proxy request → App backend (validates HMAC) → Response
**Setting Up App Proxy (shopify.app.toml):**
```toml
[[extensions]]
type = "app_proxy"
name = "Storefront API"
url = "/api/proxy"
subpath = "loyalty" # Requests to /apps/loyalty/* are routed here
```
**App Proxy Handler (Remix routes/api/proxy.ts):**
```typescript
import { json } from '@shopify/remix-oxygen';
import crypto from 'crypto';
export const loader = async ({ request }) => {
const url = new URL(request.url);
const hmac = url.searchParams.get('hmac') || '';
const timestamp = url.searchParams.get('_t') || '';
const shop = url.searchParams.get('shop') || '';
// Build message to validate HMAC
const params = new URLSearchParams();
Array.from(url.searchParams.entries()).forEach(([key, value]) => {
if (key !== 'hmac') params.append(key, value);
});
const message = params.toString();
const hash = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET || '')
.update(message, 'utf8')
.digest('base64');
if (hash !== hmac) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Validate timestamp (within 24 hours)
const requestTime = parseInt(timestamp, 10);
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - requestTime) > 86400) {
return json({ error: 'Request expired' }, { status: 401 });
}
// Valid app proxy request; return customer's loyalty points
const customerId = url.searchParams.get('customer_id') || '';
const points = await getLoyaltyPoints(customerId);
return json({ points });
};
async function getLoyaltyPoints(customerId: string) {
// Fetch from database
return 1500; // Example
}
```
**Storefront Liquid Snippet:**
```liquid
<div id="loyalty-widget">
<p>Your loyalty points: <span id="points">Loading...</span></p>
</div>
<script>
fetch('/apps/loyalty?customer_id={{ customer.id }}')
.then(r => r.json())
.then(data => {
document.getElementById('points').textContent = data.points;
});
</script>
```
## Webhook Signature Verification
**How It Works:** Shopify sends HMAC-SHA256 signature in `X-Shopify-Hmac-SHA256` header
**Verify Signature (Manual):**
```typescript
import crypto from 'crypto';
export const verifyWebhookSignature = (
request: Request,
secret: string
): boolean => {
const hmacHeader = request.headers.get('X-Shopify-Hmac-SHA256') || '';
const body = request.body; // Must be raw bytes, not JSON
const hash = crypto
.createHmac('sha256', secret)
.update(body)
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(hash),
Buffer.from(hmacHeader)
);
};
```
**Verify Signature (Remix Shopify Package):**
```typescript
import { authenticate } from '~/shopify.server';
export const action = async ({ request }) => {
const { webhook } = await authenticate.webhook(request);
// Signature already validated by middleware
console.log(`Webhook received for shop: ${webhook.shop}`);
console.log(`Topic: ${webhook.topic}`);
console.log(`Body:`, webhook.payload);
return json({ status: 'ok' });
};
```
**Register Webhook (shopify.app.ts):**
```typescript
const shopify = shopifyApp({
// ...
webhooks: {
path: '/webhooks',
validateHmac: true, // Automatic signature verification
},
});
// Define webhooks in routes/webhooks.ts
export const webhooks = {
APP_UNINSTALLED: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/webhooks/app-uninstalled',
},
ORDERS_CREATE: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/webhooks/orders-create',
},
};
```
## Customer Account API Authentication
**Use Case:** Access customer account data (orders, addresses, metafields)
**Authentication:** Customer-specific access tokens (from Shopify Hydrogen or customer flow)
**Note:** Different from admin API; limited to customer data only
**Get Customer Access Token (in Hydrogen/Storefront):**
```typescript
// This is typically handled by Shopify's customer auth flow
const customerAccessToken = 'shpuc_XXXXX'; // Provided by auth
const response = await fetch(
`https://example-shop.myshopify.com/api/2026-07/graphql.json`,
{
method: 'POST',
headers: {
'X-Shopify-Storefront-Access-Token': 'public-storefront-token',
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `
query {
customer(customerAccessToken: "${customerAccessToken}") {
id
firstName
email
orders(first: 10) {
edges {
node {
id
orderNumber
totalPrice
}
}
}
}
}
`,
}),
}
);
```
## Public vs. Custom vs. Custom Distribution Apps
**Public App (Shopify App Store):**
- Listed in Shopify App Store
- OAuth 2.0 or Token Exchange
- Scopes reviewed by Shopify
- Available to all merchants
- Example: "Email Marketing Pro"
**Custom App (Internal Use):**
- Private; not listed in App Store
- No scopes; request all access
- Created by/for single merchant
- Only accessible to merchant account
- Example: Custom inventory sync for specific store
**Custom Distribution App:**
- Limited distribution; only shared with specific merchants via link
- Behaves like public app (listed in custom store) but not publicly visible
- OAuth 2.0 required
- Scopes still reviewed
**Configuration (shopify.app.toml):**
```toml
# Public App
scopes = "write_products,read_products,write_orders"
distribution = "public"
# Custom App (all scopes by default)
distribution = "private"
# Custom Distribution
distribution = "custom"
allowedDomains = ["company-partner.myshopify.com"]
```
## Top 10 Authentication Bugs & Fixes
| Bug | Symptom | Fix |
|-----|---------|-----|
| **Missing HMAC validation** | Webhook spoofing; malicious requests processed | Always validate HMAC signature in webhook handlers; use `authenticate.webhook(request)` |
| **Storing access token in session cookie** | Token exposed in browser; XSS vulnerability | Store token in database (Prisma); never send to client; use httpOnly cookies for session ID only |
| **Expired token not refreshed** | "Unauthorized" errors after 24h (online tokens) | Token Exchange auto-refreshes; OAuth tokens are permanent; Managed Installation tokens never expire |
| **Incorrect HMAC secret** | "Invalid signature" errors on valid requests | Use correct `SHOPIFY_API_SECRET`; verify in Partner Dashboard > App Settings |
| **State parameter not validated** | CSRF attacks; attacker redirects merchant | Store state in session; validate in callback; use `crypto.randomBytes(16).toString('hex')` for state generation |
| **App proxy timestamp not checked** | Old requests replayed; business logic executed twice | Validate timestamp within 24h; use `Math.abs(currentTime - timestamp) < 86400` |
| **Scope creep (requesting too many scopes)** | App rejected by Shopify; merchant distrust | Request only scopes needed; remove unused scopes from SCOPES array |
| **Token stored in environment variable for multi-tenant** | Security breach; one merchant's token accessed by another | Use database storage (Prisma/Redis); one token per shop; index by shop domain |
| **Webhook signature verified but not in constant-time** | Timing attacks; signature can be guessed | Use `crypto.timingSafeEqual()` for comparison; avoid simple `===` |
| **Customer access token hardcoded** | Exposed in source code; customer data accessed | Never hardcode tokens; pass via environment variables or customer auth flow |
## Full Working Examples
### Example 1: Remix Token Exchange App (Complete)
**Directory Structure:**
```
my-app/
├── app/
│ ├── routes/
│ │ ├── auth.$.tsx (Auth handler)
│ │ ├── auth.callback.tsx (Callback)
│ │ └── app.products.tsx (Protected route)
│ ├── shopify.server.ts (Config)
│ └── db.server.ts (Prisma client)
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── .env (API keys)
└── shopify.app.toml
```
**shopify.app.toml:**
```toml
scopes = "write_products,read_products,write_orders,read_orders"
```
**app/shopify.server.ts:**
```typescript
import { shopifyApp } from '@shopify/shopify-app-remix/server';
import { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';
import { prisma } from '~/db.server';
export const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecret: process.env.SHOPIFY_API_SECRET,
scopes: process.env.SCOPES?.split(',') || [],
appUrl: process.env.SHOPIFY_APP_URL,
auth: {
path: '/auth',
callbackPath: '/auth/callback',
},
webhooks: {
path: '/webhooks',
},
isEmbeddedApp: true,
sessionStorage: new PrismaSessionStorage(prisma),
});
export const authenticate = shopify.authenticate;
```
**app/routes/auth.$.tsx:**
```typescript
import { redirect } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
export const loader = async ({ request }) => {
await authenticate.admin(request);
return redirect('/app');
};
```
**app/routes/auth.callback.tsx:**
```typescript
import { json } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
return redirect(`/app?shop=${session.shop}`);
};
```
**app/routes/app.products.tsx:**
```typescript
import { json } from '@shopify/remix-oxygen';
import { useLoaderData } from '@remix-run/react';
import { authenticate } from '~/shopify.server';
export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const response = await admin.graphql(`
query GetProducts {
products(first: 10) {
edges {
node {
id
title
}
}
}
}
`);
const products = response.data?.products?.edges || [];
return json({ products });
};
export default function Products() {
const { products } = useLoaderData<typeof loader>();
return (
<div>
<h1>Products</h1>
<ul>
{products.map((p) => (
<li key={p.node.id}>{p.node.title}</li>
))}
</ul>
</div>
);
}
```
### Example 2: Webhook Signature Verification
**routes/webhooks.ts:**
```typescript
import { define } from '@shopify/shopify-app-remix/server';
import { DeliveryMethod } from '@shopify/shopify-api';
export const webhooks = define({
APP_UNINSTALLED: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/webhooks/app-uninstalled',
},
PRODUCTS_CREATE: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/webhooks/products-create',
},
PRODUCTS_UPDATE: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/webhooks/products-update',
},
});
export async function handleWebhook(
topic,
shop,
body,
webhookId
) {
switch (topic) {
case 'app/uninstalled':
await deleteShopData(shop);
break;
case 'products/create':
await syncProduct(shop, body);
break;
case 'products/update':
await updateProduct(shop, body);
break;
}
}
async function deleteShopData(shop: string) {
// Clean up shop data on uninstall
console.log(`App uninstalled for shop: ${shop}`);
}
async function syncProduct(shop: string, body: any) {
const product = JSON.parse(body).product;
console.log(`Product created in ${shop}: ${product.title}`);
}
async function updateProduct(shop: string, body: any) {
const product = JSON.parse(body).product;
console.log(`Product updated in ${shop}: ${product.title}`);
}
```
**routes/webhooks/app-uninstalled.tsx:**
```typescript
import { json } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
import { deleteShopData } from '~/models/shop.server';
export const action = async ({ request }) => {
const { webhook } = await authenticate.webhook(request);
// HMAC signature already validated
await deleteShopData(webhook.shop);
return json({ status: 'processed' });
};
```
### Example 3: App Proxy with Customer Loyalty
**routes/api/proxy.tsx:**
```typescript
import { json } from '@shopify/remix-oxygen';
import crypto from 'crypto';
export const loader = async ({ request }) => {
const url = new URL(request.url);
const hmac = url.searchParams.get('hmac') || '';
const timestamp = url.searchParams.get('_t') || '';
// Build message for HMAC validation
const params = new URLSearchParams();
Array.from(url.searchParams.entries()).forEach(([key, value]) => {
if (key !== 'hmac') params.append(key, value);
});
const message = params.toString();
const hash = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET || '')
.update(message, 'utf8')
.digest('base64');
// Validate signature
if (hash !== hmac) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Validate timestamp
const requestTime = parseInt(timestamp, 10);
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - requestTime) > 86400) {
return json({ error: 'Request expired' }, { status: 401 });
}
// Valid request; return loyalty data
const customerId = url.searchParams.get('customer_id') || '';
const loyaltyPoints = await getLoyaltyPoints(customerId);
return json({ loyaltyPoints, success: true });
};
async function getLoyaltyPoints(customerId: string): Promise<number> {
// Fetch from database
return 1500;
}
```
## API Version & Scope Reference
**Current when this release was audited:** `2026-07`. Verify Shopify's latest stable version before deployment.
**Essential Scopes:**
- `write_products`, `read_products` — Manage product catalog
- `write_orders`, `read_orders` — Access order data
- `write_customers`, `read_customers` — Manage customer data
- `write_fulfillments`, `read_fulfillments` — Manage fulfillments
- `write_inventory`, `read_inventory` — Manage inventory levels
- `write_discounts`, `read_discounts` — Create/manage discounts
- `write_draft_orders`, `read_draft_orders` — Draft order management
- `write_checkout`, `read_checkout` — Checkout customization
- `write_metafields`, `read_metafields` — Manage custom data
**Scope Review:** Scopes are reviewed by Shopify during app approval. Request only necessary scopes.
## Resources
- **Shopify OAuth Docs:** https://shopify.dev/docs/apps/auth
- **Session Storage:** https://shopify.dev/docs/apps/auth-session-storage
- **Webhook Verification:** https://shopify.dev/docs/apps/webhooks/configuration/verify-webhook-authenticity
- **Token Exchange:** https://shopify.dev/docs/apps/auth/get-access-tokens/token-exchange
app-billing23.5 KB
---
name: app-billing
description: "Implement recurring, usage-based, one-time, and hybrid Shopify app billing. Covers appSubscriptionCreate, appUsageRecordCreate, trials, capped amounts, replacement behavior, test mode, current revenue-share rules, and pricing tiers."
---
# Shopify App Billing
Shopify apps can charge merchants through Shopify's billing system. Charges are collected via the merchant's Shopify payment method and appear on their bill. Three models are supported: **recurring** (fixed monthly/annual), **usage-based** (pay-per-action), and **one-time** (one-off charges). You can also combine them (hybrid).
## Revenue Share Model
For developers eligible for Shopify's standard rates:
- You keep **100% of the first $1,000,000 USD in lifetime gross app revenue earned from January 1, 2025**.
- Above that threshold, Shopify's revenue share is **15%**, so you keep **85%** before other fees and taxes.
- All billing is separately subject to a **2.9% processing fee**, applicable sales tax, and potentially regional regulatory fees.
- Shopify applies special eligibility rules to very large developers and aggregates revenue across associated developer accounts. Verify the current policy before financial modeling.
This means your pricing directly affects what you keep:
```
App charges $10 while eligible for the 0% revenue-share tier
├─ Merchant pays: $10.00
├─ Processing fee: $0.29, before tax or regional fees
└─ Developer amount before tax/regional fees: $9.71
App charges $100 above the $1M lifetime threshold
├─ Merchant pays: $100.00
├─ Revenue share: $15.00
├─ Processing fee: $2.90, before tax or regional fees
└─ Developer amount before tax/regional fees: $82.10
```
**Planning rule:** Model revenue share, processing fees, taxes, refunds, and cost to serve separately; don't assume gross charges equal payout.
## Billing Models
### 1. Recurring (Fixed Interval)
**Use Case:** Subscription model (e.g., "Pro plan $99/month")
**Charging:** First charge immediate on approval; subsequent charges on anniversary date
**Cancellation:** Merchant can cancel anytime; charged through current period end
**appSubscriptionCreate Mutation:**
```graphql
mutation CreateRecurringSubscription {
appSubscriptionCreate(
input: {
trialDays: 7
lineItems: [
{
plan: {
appRecurringPricingDetails: {
interval: MONTHLY # or ANNUAL
price: { amount: "9.99", currencyCode: "USD" }
}
}
}
]
returnUrl: "https://your-app.com/billing/confirm"
}
) {
appSubscription {
id
confirmationUrl # Merchant must visit this URL to approve
lineItems {
id
plan {
pricingDetails {
... on AppRecurringPricingDetails {
interval
price { amount currencyCode }
}
}
}
}
status # PENDING, ACTIVE, DECLINED, EXPIRED, FROZEN, CANCELLED
currentPeriodEnd
trialDays
trialEndsOn
}
userErrors {
field
message
}
}
}
```
**Remix Implementation (Recurring Billing):**
```typescript
import { json, redirect } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
export const action = async ({ request }) => {
if (request.method !== 'POST') return json({ error: 'POST only' }, { status: 405 });
const { admin } = await authenticate.admin(request);
const response = await admin.graphql(`
mutation CreateRecurringSubscription($input: AppSubscriptionInput!) {
appSubscriptionCreate(input: $input) {
appSubscription {
id
confirmationUrl
status
currentPeriodEnd
lineItems {
id
plan {
pricingDetails {
... on AppRecurringPricingDetails {
interval
price { amount currencyCode }
}
}
}
}
}
userErrors {
field
message
}
}
}
`, {
variables: {
input: {
trialDays: 7,
lineItems: [
{
plan: {
appRecurringPricingDetails: {
interval: 'MONTHLY',
price: { amount: '9.99', currencyCode: 'USD' },
},
},
},
],
returnUrl: 'https://your-app.com/billing/confirm',
},
},
});
const { appSubscription, userErrors } = response.data?.appSubscriptionCreate || {};
if (userErrors?.length > 0) {
return json({ errors: userErrors }, { status: 400 });
}
// Merchant must visit confirmation URL to approve billing
return redirect(appSubscription.confirmationUrl);
};
export const loader = async ({ request }) => {
const { admin, session } = await authenticate.admin(request);
const url = new URL(request.url);
const charge = url.searchParams.get('charge_id');
if (!charge) {
return json({ message: 'Waiting for merchant approval' });
}
// Query subscription status after merchant approves
const response = await admin.graphql(`
query GetSubscription($id: ID!) {
appSubscription(id: $id) {
id
status
currentPeriodEnd
returnUrl
lineItems {
id
plan {
pricingDetails {
... on AppRecurringPricingDetails {
interval
price { amount currencyCode }
}
}
}
}
}
}
`, {
variables: { id: charge },
});
const subscription = response.data?.appSubscription;
if (subscription?.status === 'ACTIVE') {
return json({ success: true, subscription });
}
return json({ success: false, subscription });
};
```
### 2. Usage-Based (Pay-Per-Action)
**Use Case:** Charge per email sent, API call, report generated, etc.
**Charging:** Metered; merchant is charged monthly for accumulated usage
**Cap Amount:** Optional; maximum the merchant can be charged per period
**appSubscriptionCreate Mutation (Usage-Based):**
```graphql
mutation CreateUsageSubscription {
appSubscriptionCreate(
input: {
trialDays: 0
lineItems: [
{
plan: {
appUsagePricingDetails: {
cappedAmount: {
amount: "100.00" # Max charge per billing period
currencyCode: "USD"
}
terms: "$0.01 per email sent" # Display string
}
}
}
]
returnUrl: "https://your-app.com/billing/confirm"
}
) {
appSubscription {
id
confirmationUrl
lineItems {
id
plan {
pricingDetails {
... on AppUsagePricingDetails {
cappedAmount { amount currencyCode }
terms
}
}
}
}
status
}
userErrors {
field
message
}
}
}
```
**Recording Usage (appUsageRecordCreate):**
```graphql
mutation RecordUsage($subscriptionLineId: ID!, $quantity: Float!, $idempotencyKey: String!) {
appUsageRecordCreate(
subscriptionLineId: $subscriptionLineId
quantity: $quantity
idempotencyKey: $idempotencyKey
) {
appUsageRecord {
id
createdAt
quantity
}
userErrors {
field
message
}
}
}
```
**Remix Implementation (Usage-Based):**
```typescript
import { json } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
// Step 1: Create usage-based subscription
export const action = async ({ request }) => {
if (request.method !== 'POST') return json({ error: 'POST only' }, { status: 405 });
const { admin } = await authenticate.admin(request);
const response = await admin.graphql(`
mutation CreateUsageSubscription($input: AppSubscriptionInput!) {
appSubscriptionCreate(input: $input) {
appSubscription {
id
confirmationUrl
lineItems {
id
plan {
pricingDetails {
... on AppUsagePricingDetails {
cappedAmount { amount currencyCode }
terms
}
}
}
}
}
userErrors { field message }
}
}
`, {
variables: {
input: {
lineItems: [
{
plan: {
appUsagePricingDetails: {
cappedAmount: { amount: '100.00', currencyCode: 'USD' },
terms: '$0.10 per report generated',
},
},
},
],
returnUrl: 'https://your-app.com/billing/confirm',
},
},
});
const { appSubscription } = response.data?.appSubscriptionCreate || {};
return redirect(appSubscription.confirmationUrl);
};
// Step 2: Record usage when action occurs (e.g., report generated)
export const recordUsage = async (
admin,
subscriptionLineId: string,
quantity: number
) => {
const idempotencyKey = `${subscriptionLineId}-${Date.now()}`; // Prevent duplicates
const response = await admin.graphql(`
mutation RecordUsage(
$subscriptionLineId: ID!
$quantity: Float!
$idempotencyKey: String!
) {
appUsageRecordCreate(
subscriptionLineId: $subscriptionLineId
quantity: $quantity
idempotencyKey: $idempotencyKey
) {
appUsageRecord { id createdAt quantity }
userErrors { field message }
}
}
`, {
variables: {
subscriptionLineId,
quantity,
idempotencyKey,
},
});
const { appUsageRecord, userErrors } = response.data?.appUsageRecordCreate || {};
if (userErrors?.length > 0) {
console.error('Usage record error:', userErrors);
return null;
}
return appUsageRecord;
};
// Step 3: In report generation endpoint
export const generateReport = async ({ request }) => {
const { admin, session } = await authenticate.admin(request);
const data = await request.json();
// Generate report
const reportId = await createReport(session.shop, data);
// Record usage (charge for report)
const subscriptionLineId = 'gid://shopify/AppSubscriptionLine/123';
await recordUsage(admin, subscriptionLineId, 1); // 1 report = 1 charge unit
return json({ success: true, reportId });
};
```
### 3. One-Time Charge
**Use Case:** Upfront license purchase, setup fee, premium feature unlocks
**Charging:** Immediate; merchant approves and is charged once
**No Recurring:** Does not repeat; separate call required for each charge
**appSubscriptionCreate Mutation (One-Time):**
```graphql
mutation CreateOneTimeCharge {
appSubscriptionCreate(
input: {
lineItems: [
{
plan: {
appOneTimePricingDetails: {
price: { amount: "49.99", currencyCode: "USD" }
}
}
}
]
returnUrl: "https://your-app.com/billing/confirm"
}
) {
appSubscription {
id
confirmationUrl
lineItems {
id
plan {
pricingDetails {
... on AppOneTimePricingDetails {
price { amount currencyCode }
}
}
}
}
status
}
userErrors { field message }
}
}
```
### 4. Hybrid (Recurring + Usage-Based)
**Use Case:** Base subscription + pay-per-extra-action (e.g., "Pro $99/month + $0.05 per extra report")
**Charging:** Base charge monthly + usage charges accumulated during month
**appSubscriptionCreate Mutation (Hybrid):**
```graphql
mutation CreateHybridSubscription {
appSubscriptionCreate(
input: {
lineItems: [
{
plan: {
appRecurringPricingDetails: {
interval: MONTHLY
price: { amount: "99.00", currencyCode: "USD" }
}
}
},
{
plan: {
appUsagePricingDetails: {
cappedAmount: { amount: "1000.00", currencyCode: "USD" }
terms: "$0.05 per extra report (beyond 100/month)"
}
}
}
]
returnUrl: "https://your-app.com/billing/confirm"
}
) {
appSubscription {
id
confirmationUrl
lineItems { id plan { pricingDetails { ... on AppRecurringPricingDetails { interval price { amount currencyCode } } ... on AppUsagePricingDetails { cappedAmount { amount currencyCode } terms } } } }
}
userErrors { field message }
}
}
```
## Billing Configuration in Remix
**Require Billing (MUST_USE_BILLING):**
In your Remix root component, block access until billing is confirmed:
```typescript
// app/root.tsx
import { authenticate } from '~/shopify.server';
export const loader = async ({ request }) => {
const { billing } = await authenticate.admin(request);
// MUST_USE_BILLING: Prevent app usage without active subscription
await billing.require({
plans: ['basic', 'premium', 'unlimited'], // At least one required
onFailUrl: '/billing', // Redirect if no active subscription
});
return null;
};
```
**Request Billing (Optional):**
Allow app usage but encourage upgrade:
```typescript
export const loader = async ({ request }) => {
const { billing } = await authenticate.admin(request);
// OPTIONAL: App works without billing, but show upsell
const response = await billing.request({
plan: 'premium',
isTest: false,
});
return json({ needsBilling: !response.appSubscription });
};
```
**Test Mode:**
Simulate billing without charging:
```typescript
export const loader = async ({ request }) => {
const { billing } = await authenticate.admin(request);
// Test mode: Billing is mocked; no real charges
await billing.require({
plans: ['test-plan'],
isTest: true, // Set to false for production
});
return null;
};
```
**Cancel Subscription:**
```graphql
mutation CancelSubscription($id: ID!) {
appSubscriptionCancel(id: $id) {
appSubscription {
id
status # CANCELLED
returnUrl
}
userErrors { field message }
}
}
```
## Pricing Strategy & Tier Ladder
**Common Pricing Models:**
### Flat Pricing (Simple)
```
Starter: $29/month (up to 100 products)
Pro: $99/month (up to 10,000 products)
Enterprise: $499/month (unlimited)
```
### Tiered Usage (Pay-Per-Action)
```
Base: $0/month (free tier, 100 reports/month)
Standard: $0.05 per report above 100
Premium: $0.02 per report above 100 (volume discount)
```
### Freemium + Paid Features
```
Free: $0 (basic features only)
Plus: $9.99/month (advanced analytics)
Pro: $49.99/month (API access + custom integrations)
```
### Feature-Gated Tiers
```
Standard: $49/month
├─ Dashboard
├─ Email notifications
└─ 30-day history
Pro: $149/month
├─ Everything in Standard
├─ API access
├─ Custom rules
└─ Unlimited history
```
**Recommended Tier Ladder:**
1. **Free Tier** (required; builds trust)
- Basic functionality
- Limited usage (e.g., 10 actions/month)
- No integrations
- Community support only
2. **Starter** ($19-49/month)
- 1-2 intermediate features
- Moderate usage (e.g., 1,000 actions/month)
- Email support
3. **Professional** ($99-299/month)
- All features
- High usage (unlimited or 100K+ actions)
- Priority support
- API access
4. **Enterprise** (custom pricing)
- Custom features
- Dedicated support
- SLA guarantee
- White-label option
**Pricing Psychology:**
- Odd pricing ($19.99 vs $20) increases conversion
- Annual pricing 20-30% cheaper than monthly (increases LTV)
- "Pro" tier should be sweet spot; most conversions
- Free tier must have real value; 10-15% convert to paid
## Full Working Examples
### Example 1: Flat Recurring Billing (3-Tier Plan)
**routes/billing/create.tsx (Create Subscription):**
```typescript
import { json, redirect } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
import { Form } from '@remix-run/react';
export const action = async ({ request }) => {
if (request.method !== 'POST') return json({ error: 'POST only' }, { status: 405 });
const formData = await request.formData();
const plan = formData.get('plan') as string;
const { admin } = await authenticate.admin(request);
const planConfig = {
starter: { price: '29.99', interval: 'MONTHLY' },
pro: { price: '99.99', interval: 'MONTHLY' },
enterprise: { price: '499.99', interval: 'MONTHLY' },
};
const config = planConfig[plan as keyof typeof planConfig];
if (!config) return json({ error: 'Invalid plan' }, { status: 400 });
const response = await admin.graphql(`
mutation CreateSubscription($input: AppSubscriptionInput!) {
appSubscriptionCreate(input: $input) {
appSubscription { id confirmationUrl status }
userErrors { field message }
}
}
`, {
variables: {
input: {
lineItems: [
{
plan: {
appRecurringPricingDetails: {
interval: config.interval,
price: { amount: config.price, currencyCode: 'USD' },
},
},
},
],
returnUrl: 'https://your-app.com/billing/confirm',
},
},
});
const { appSubscription } = response.data?.appSubscriptionCreate || {};
return redirect(appSubscription.confirmationUrl);
};
export const loader = async ({ request }) => {
await authenticate.admin(request);
return null;
};
export default function BillingPlans() {
return (
<div>
<h1>Choose Your Plan</h1>
<Form method="post">
<label>
<input type="radio" name="plan" value="starter" /> Starter - $29/month
</label>
<label>
<input type="radio" name="plan" value="pro" /> Pro - $99/month
</label>
<label>
<input type="radio" name="plan" value="enterprise" /> Enterprise - $499/month
</label>
<button type="submit">Subscribe</button>
</Form>
</div>
);
}
```
### Example 2: Usage-Based Billing (Per-Report)
**routes/api/report-create.tsx (Record Usage):**
```typescript
import { json } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
import { prisma } from '~/db.server';
export const action = async ({ request }) => {
if (request.method !== 'POST') return json({ error: 'POST only' }, { status: 405 });
const { admin, session } = await authenticate.admin(request);
const data = await request.json();
// Generate report
const report = await prisma.report.create({
data: {
shop: session.shop,
name: data.name,
generatedAt: new Date(),
},
});
// Get subscription line for usage tracking
const subscription = await getActiveSubscription(session.shop);
if (subscription?.usageLineId) {
// Record 1 report = 1 usage unit (charge $0.10)
await admin.graphql(`
mutation RecordUsage(
$subscriptionLineId: ID!
$quantity: Float!
$idempotencyKey: String!
) {
appUsageRecordCreate(
subscriptionLineId: $subscriptionLineId
quantity: $quantity
idempotencyKey: $idempotencyKey
) {
appUsageRecord { id quantity }
userErrors { field message }
}
}
`, {
variables: {
subscriptionLineId: subscription.usageLineId,
quantity: 1,
idempotencyKey: `${report.id}-${Date.now()}`,
},
});
}
return json({ success: true, reportId: report.id });
};
async function getActiveSubscription(shop: string) {
return prisma.subscription.findUnique({
where: { shop },
select: { usageLineId: true },
});
}
```
### Example 3: Freemium + Paid Features (Feature Gates)
**routes/app.reports.tsx (Feature-Gated Page):**
```typescript
import { json } from '@shopify/remix-oxygen';
import { useLoaderData } from '@remix-run/react';
import { authenticate } from '~/shopify.server';
export const loader = async ({ request }) => {
const { admin, billing, session } = await authenticate.admin(request);
// Check if merchant has active paid subscription
const { appSubscriptions } = await admin.graphql(`
query {
appSubscriptions(first: 1) {
edges {
node {
id
status
lineItems {
plan {
pricingDetails {
... on AppRecurringPricingDetails {
price { amount }
}
}
}
}
}
}
}
}
`);
const isPaid = appSubscriptions?.edges?.[0]?.node?.status === 'ACTIVE';
// Load reports
const reports = await getReports(session.shop);
return json({ reports, isPaid });
};
export default function ReportsPage() {
const { reports, isPaid } = useLoaderData<typeof loader>();
return (
<div>
<h1>Reports</h1>
{isPaid ? (
<div>
{/* Show all reports */}
{reports.map((r) => (
<div key={r.id}>{r.name}</div>
))}
</div>
) : (
<div className="upgrade-banner">
<p>Unlock unlimited reports with Pro plan</p>
<a href="/billing">Upgrade Now</a>
</div>
)}
</div>
);
}
async function getReports(shop: string) {
// Fetch from database
return [];
}
```
## Handle Subscription Lifecycle
**Webhook: app/subscribed**
When merchant approves subscription:
```typescript
// webhooks/app-subscribed.ts
export const webhooks = {
APP_SUBSCRIBED: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/webhooks/app-subscribed',
},
};
export async function handleAppSubscribed(shop, body) {
const { appSubscription } = JSON.parse(body);
// Store subscription in database
await storeSubscription(shop, {
subscriptionId: appSubscription.id,
status: appSubscription.status,
currentPeriodEnd: appSubscription.currentPeriodEnd,
lineItems: appSubscription.lineItems,
});
// Send confirmation email
await sendEmail(shop, 'Subscription activated');
}
```
**Webhook: billing_attempt.failure**
When payment fails:
```typescript
export async function handleBillingFailure(shop, body) {
const { appSubscription } = JSON.parse(body);
// Notify merchant
await sendEmail(shop, 'Payment failed; please update payment method');
// Optionally freeze features after multiple failures
await disableFeatures(shop);
}
```
**Cleanup on Uninstall:**
```typescript
export async function handleAppUninstalled(shop) {
// Delete billing records for this merchant
await prisma.subscription.deleteMany({ where: { shop } });
await prisma.usageRecord.deleteMany({ where: { shop } });
}
```
## Troubleshooting
| Issue | Cause | Fix |
|-------|-------|-----|
| **"Invalid currency code"** | Currency not supported by Shopify | Use USD, EUR, GBP, CAD, AUD, JPY, or merchant's shop currency |
| **Billing confirmation URL returns 404** | Merchant link expired (24h limit) | Generate new confirmation URL; store in database with expiry |
| **appUsageRecordCreate returns "invalid subscription line"** | Wrong subscriptionLineId | Query active subscription to get correct lineId |
| **"subscription is frozen"** | Payment failed; account suspended | Fix payment method; contact Shopify support |
| **Test mode billing not mocking** | isTest flag not set | Set `isTest: true` in billing.require() |
| **Duplicate usage charges** | No idempotencyKey or not unique | Generate unique key per usage record; include timestamp |
## Resources
- **Shopify app billing:** https://shopify.dev/docs/apps/launch/billing
- **GraphQL billing objects and mutations:** https://shopify.dev/docs/api/admin-graphql/latest/objects/AppSubscription
- **Revenue share:** https://shopify.dev/docs/apps/launch/distribution/revenue-share
- **Shopify App Store listing:** https://shopify.dev/docs/apps/launch/shopify-app-store/app-listing
app-bridge20.5 KB
---
name: app-bridge
description: "When asked to build Shopify admin apps, use App Bridge 4.x web components, shopify global API, session tokens, JWT validation, and migrations from 3.x. Covers CDN setup, all 8 web components, resource picker API, React hooks, backend JWT validation, and 5 worked examples."
---
# Shopify App Bridge 4.x: Web Components, Sessions & Admin Apps
## When Asked...
- **"Build a Shopify admin app"** → Use App Bridge 4.x with web components; load via CDN with data-api-key; use shopify global object for actions
- **"Add save/cancel buttons"** → Use `<ui-save-bar>` web component with data-primary-action and data-secondary-action attributes
- **"Show a confirmation modal"** → Use `<ui-modal>` web component with data-open attribute and slot-based content
- **"Display success message"** → Use `shopify.toast()` method with title, message, duration, isError flags
- **"Let user pick products/customers"** → Use resource picker API: `shopify.resourcePicker({ type: 'product' })`
- **"Validate backend requests"** → Exchange session token for JWT; verify JWT signature with Shopify's public key
- **"Upgrade from App Bridge 3.x"** → Follow migration checklist: remove AppProvider, update component usage, use web components directly
## App Bridge 4.x Architecture
App Bridge 4.x is a **web components-first framework**. The major shift from 3.x:
- **No AppProvider needed** — directly use web components and shopify global object
- **Native web components** — built-in elements like `<ui-modal>`, `<ui-save-bar>`, `<ui-toast>` instead of React/Vue wrappers
- **shopify global object** — replaces AppBridge context; provides toast(), modal(), navigate(), loading(), idToken(), etc.
- **Session tokens** — automatic JWT exchange for backend authentication
- **CDN-first delivery** — loaded via script tag with data-api-key; works in any HTML/framework
## Installation & CDN Setup
### CDN Script (Recommended for Admin Apps)
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shopify Admin App</title>
</head>
<body>
<div id="app"></div>
<script src="https://cdn.shopify.com/shopifycloud/app-bridge.js"
data-api-key="YOUR_PUBLIC_API_KEY"
data-host="{{ request.host }}">
</script>
<script>
// shopify global object is now available
console.log(shopify);
</script>
</body>
</html>
```
**data-api-key**: Your Shopify public app API key (from shopify.app configuration)
**data-host**: Base64-encoded host parameter (usually `{{ request.host }}` in server templates)
### npm Package (for React/Node.js apps)
```bash
npm install @shopify/app-bridge @shopify/app-bridge-react
```
```typescript
import { initializeApp } from '@shopify/app-bridge';
const app = initializeApp({
apiKey: process.env.REACT_APP_SHOPIFY_API_KEY,
host: new URLSearchParams(location.search).get('host'),
});
console.log(window.shopify);
```
## shopify Global Object API
The **shopify global** provides the primary API for app interactions:
```typescript
// Toast notifications
shopify.toast({
title: 'Success',
message: 'Settings saved',
duration: 3000,
isError: false,
});
// Modal dialogs
shopify.modal.show({
title: 'Confirm Action',
message: 'Are you sure?',
buttons: [
{ label: 'Cancel', type: 'secondary' },
{ label: 'Delete', type: 'primary', isDestructive: true },
],
});
// Navigation
shopify.navigate({ name: 'Admin::Product::Index' });
shopify.navigate({ url: '/admin/products/new' });
// Loading state
shopify.loading.dispatch(true);
shopify.loading.dispatch(false);
// Session token (JWT for backend calls)
const idToken = await shopify.idToken();
// Current app environment
shopify.environment // 'Admin' | 'Checkout' | 'Mobile' | 'POS'
shopify.config // { apiKey, host, theme }
// Deep linking
shopify.actions.Admin?.navigate({ path: '/products' });
```
## Web Components API
App Bridge 4.x provides 8 native web components. Use them directly in HTML without React wrappers.
### 1. `<ui-title-bar>` — Page Header
```html
<ui-title-bar>
<h1 slot="title">Bulk Product Editor</h1>
<button slot="secondary-actions">Help</button>
<button slot="primary-action" onclick="saveProducts()">Save</button>
</ui-title-bar>
```
**Attributes**:
- `title` (slot) — Page title
- `primary-action` (slot) — Right-aligned primary button
- `secondary-actions` (slot) — Right-aligned secondary buttons
### 2. `<ui-save-bar>` — Sticky Save/Discard
```html
<ui-save-bar
data-primary-action="Save"
data-secondary-action="Discard"
data-save-action-loading="false"
onprimaryaction="handleSave(event)"
onsecondaryaction="handleDiscard(event)">
</ui-save-bar>
<script>
async function handleSave(event) {
event.preventDefault();
const token = await shopify.idToken();
const formData = new FormData(document.querySelector('form'));
const res = await fetch('/api/settings', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(formData)),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (res.ok) {
shopify.toast({ title: 'Saved' });
document.getElementById('form').classList.remove('dirty');
}
}
</script>
```
**Attributes**:
- `data-primary-action` — Button label (default: "Save")
- `data-secondary-action` — Discard label (default: "Discard")
- `data-save-action-loading` — Show spinner on primary button
- `onprimaryaction` — Fired when save clicked
- `onsecondaryaction` — Fired when discard clicked
### 3. `<ui-modal>` — Dialog Box
```html
<ui-modal data-open="true" data-title="Delete Product">
<p>This action cannot be undone.</p>
<button slot="primary-action" onclick="confirmDelete()">Delete</button>
<button slot="secondary-action" onclick="closeModal()">Cancel</button>
</ui-modal>
<script>
async function confirmDelete() {
const token = await shopify.idToken();
const res = await fetch('/api/products/123', {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
if (res.ok) {
shopify.toast({ title: 'Deleted' });
document.querySelector('ui-modal').setAttribute('data-open', 'false');
}
}
</script>
```
**Attributes**:
- `data-open` — Show/hide ("true" or "false")
- `data-title` — Modal title
- `primary-action` (slot) — Primary button
- `secondary-action` (slot) — Secondary button
### 4. `<ui-toast>` — Toast Notification (Alternative)
```html
<ui-toast
data-message="Settings updated"
data-duration="3000"
data-is-error="false"
data-open="true">
</ui-toast>
<script>
function showSuccess() {
const toast = document.querySelector('ui-toast');
toast.setAttribute('data-message', 'Successfully saved');
toast.setAttribute('data-open', 'true');
setTimeout(() => toast.setAttribute('data-open', 'false'), 3000);
}
</script>
```
### 5. `<ui-nav-menu>` — Sidebar Navigation
```html
<ui-nav-menu>
<a href="/dashboard" slot="item">Dashboard</a>
<a href="/products" slot="item" data-active="true">Products</a>
<a href="/orders" slot="item">Orders</a>
<a href="/settings" slot="item">Settings</a>
</ui-nav-menu>
```
### 6. `<ui-resource-picker>` — Product/Collection Picker UI
```html
<ui-resource-picker
data-type="product"
data-selectable="multiple"
data-can-query-for-more="true"
onchange="handleResourceSelect(event)">
</ui-resource-picker>
```
### 7. `<ui-print-action>` — Print Button
```html
<ui-print-action onclick="window.print()">
Print Invoice
</ui-print-action>
```
### 8. `<s-page>` — Full-Page Container
```html
<s-page>
<ui-title-bar>
<h1 slot="title">Dashboard</h1>
</ui-title-bar>
<div style="padding: 20px;">
<h2>Welcome</h2>
</div>
</s-page>
```
## Resource Picker API (Programmatic)
For programmatic access without UI component:
```typescript
// Product picker
await shopify.resourcePicker({
type: 'product',
selectionIds: [{ gid: 'gid://shopify/Product/123' }],
onSelection(resources) {
console.log('Selected:', resources.selection);
},
onCancel() {
console.log('Picker cancelled');
},
});
// Collection picker
await shopify.resourcePicker({
type: 'collection',
onSelection(resources) {
// Handle selection
},
});
// Customer picker
await shopify.resourcePicker({
type: 'customer',
onSelection(resources) {
const customer = resources.selection[0];
console.log(customer.id, customer.email);
},
});
// Variant picker
await shopify.resourcePicker({
type: 'variant',
onSelection(resources) {
const variant = resources.selection[0];
console.log(variant.id, variant.title, variant.price);
},
});
```
**Supported types**: `product`, `collection`, `customer`, `variant`, `draft_order`
## App Bridge React Hooks (Optional)
For React apps, optional hooks simplify shopify global access:
```typescript
import { useAppBridge } from '@shopify/app-bridge-react';
import { Toast } from '@shopify/app-bridge/actions';
export function MyComponent() {
const app = useAppBridge();
const handleSave = async () => {
app.dispatch({ type: 'LOADING_DISPATCH', payload: true });
const token = await app.getSessionToken();
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
});
app.dispatch({ type: 'LOADING_DISPATCH', payload: false });
app.dispatch(Toast.create({
title: 'Saved',
message: 'Settings updated',
duration: 3000,
}));
};
return <button onClick={handleSave}>Save</button>;
}
```
## Session Token & JWT Backend Validation
App Bridge automatically provides session tokens (JWTs) for authenticated backend calls.
### Client Side: Get Token & Send
```javascript
const idToken = await shopify.idToken();
const response = await fetch('/api/admin/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${idToken}`,
},
body: JSON.stringify({ theme_color: '#FF0000' }),
});
if (!response.ok) {
shopify.toast({
title: 'Error',
message: 'Failed to save settings',
isError: true,
});
}
```
### Backend Side: Validate JWT
**Node.js/Express**:
```typescript
import { jwtDecode } from 'jwt-decode';
async function validateSessionToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.slice(7);
try {
const decoded = jwtDecode(token);
if (!decoded.iss || !decoded.iss.includes('shopify.com')) {
throw new Error('Invalid issuer');
}
if (!decoded.aud || decoded.aud !== process.env.SHOPIFY_API_KEY) {
throw new Error('Invalid audience');
}
if (decoded.exp && Date.now() >= decoded.exp * 1000) {
throw new Error('Token expired');
}
req.shop = decoded.dest;
req.userId = decoded.sub;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
app.post('/api/admin/settings', validateSessionToken, (req, res) => {
console.log(`User ${req.userId} from shop ${req.shop} updating settings`);
res.json({ success: true });
});
```
**Python/Flask**:
```python
from flask import request, jsonify
from jwt import decode as jwt_decode
from functools import wraps
def validate_session_token(f):
@wraps(f)
def decorated_function(*args, **kwargs):
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return jsonify({'error': 'Missing token'}), 401
token = auth_header[7:]
try:
decoded = jwt_decode(token, options={"verify_signature": False})
if not decoded.get('iss') or 'shopify.com' not in decoded['iss']:
raise ValueError('Invalid issuer')
if decoded.get('aud') != os.getenv('SHOPIFY_API_KEY'):
raise ValueError('Invalid audience')
request.shop = decoded.get('dest')
request.user_id = decoded.get('sub')
return f(*args, **kwargs)
except Exception as e:
return jsonify({'error': 'Invalid token'}), 401
return decorated_function
@app.post('/api/admin/settings')
@validate_session_token
def update_settings():
shop = request.shop
user_id = request.user_id
data = request.get_json()
return jsonify({'success': True})
```
## Worked Examples
### Example 1: Save Bar with Form Validation
```html
<!DOCTYPE html>
<html>
<body>
<script src="https://cdn.shopify.com/shopifycloud/app-bridge.js"
data-api-key="pk_test_12345"
data-host="example.myshopify.com">
</script>
<ui-title-bar>
<h1 slot="title">Product Settings</h1>
</ui-title-bar>
<form id="settings-form" style="padding: 20px; max-width: 600px;">
<label>
Product Name
<input type="text" name="product_name" required>
</label>
<br><br>
<label>
Price
<input type="number" name="price" step="0.01" required>
</label>
<br><br>
<label>
Description
<textarea name="description"></textarea>
</label>
</form>
<ui-save-bar
data-primary-action="Save Changes"
data-secondary-action="Discard"
onprimaryaction="handleSave(event)"
onsecondaryaction="handleDiscard(event)">
</ui-save-bar>
<script>
const form = document.getElementById('settings-form');
form.addEventListener('change', () => {
form.classList.add('dirty');
});
async function handleSave(event) {
event.preventDefault();
if (!form.checkValidity()) {
shopify.toast({
title: 'Validation Error',
message: 'Please fill all required fields',
isError: true,
});
return;
}
const token = await shopify.idToken();
const formData = new FormData(form);
try {
const response = await fetch('/api/products/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(Object.fromEntries(formData)),
});
if (!response.ok) throw new Error('Save failed');
shopify.toast({
title: 'Success',
message: 'Product settings saved',
});
form.classList.remove('dirty');
} catch (err) {
shopify.toast({
title: 'Error',
message: err.message,
isError: true,
});
}
}
function handleDiscard(event) {
event.preventDefault();
form.reset();
form.classList.remove('dirty');
}
</script>
</body>
</html>
```
### Example 2: Destructive Modal (Delete Confirmation)
```typescript
async function showDeleteConfirmation(productId: string) {
const modal = document.createElement('ui-modal');
modal.setAttribute('data-open', 'true');
modal.setAttribute('data-title', 'Delete Product');
const content = document.createElement('p');
content.textContent = 'This action cannot be undone.';
const confirmBtn = document.createElement('button');
confirmBtn.setAttribute('slot', 'primary-action');
confirmBtn.textContent = 'Delete';
const cancelBtn = document.createElement('button');
cancelBtn.setAttribute('slot', 'secondary-action');
cancelBtn.textContent = 'Cancel';
modal.appendChild(content);
modal.appendChild(confirmBtn);
modal.appendChild(cancelBtn);
document.body.appendChild(modal);
confirmBtn.onclick = async () => {
shopify.loading.dispatch(true);
const token = await shopify.idToken();
try {
const res = await fetch(`/api/products/${productId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
shopify.loading.dispatch(false);
modal.setAttribute('data-open', 'false');
shopify.toast({
title: 'Product Deleted',
message: 'The product has been permanently removed',
});
shopify.navigate({ name: 'Admin::Product::Index' });
} catch (err) {
shopify.loading.dispatch(false);
shopify.toast({
title: 'Error',
message: `Failed to delete: ${err.message}`,
isError: true,
});
}
};
cancelBtn.onclick = () => {
modal.setAttribute('data-open', 'false');
modal.remove();
};
}
```
### Example 3: Product Resource Picker
```typescript
async function openProductSelector() {
try {
await shopify.resourcePicker({
type: 'product',
selectionIds: [],
onSelection(resources) {
const products = resources.selection;
products.forEach(product => {
const item = document.createElement('div');
item.style.cssText = 'padding: 10px; border: 1px solid #ddd; margin: 5px 0;';
item.innerHTML = `
<strong>${product.title}</strong><br>
ID: ${product.id}<br>
<img src="${product.image?.originalSrc}" style="width: 50px;">
`;
document.getElementById('product-list').appendChild(item);
});
shopify.toast({
title: 'Success',
message: `${products.length} products selected`,
});
},
onCancel() {
shopify.toast({
title: 'Cancelled',
message: 'Product selection cancelled',
});
},
});
} catch (err) {
shopify.toast({
title: 'Error',
message: `Failed to open picker: ${err.message}`,
isError: true,
});
}
}
```
### Example 4: Toast on Success with Error Handling
```typescript
async function bulkUpdateProducts(productIds: string[]) {
shopify.loading.dispatch(true);
const token = await shopify.idToken();
const results = { success: 0, failed: 0 };
for (const productId of productIds) {
try {
const res = await fetch(`/api/products/${productId}/sync`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ syncInventory: true }),
});
if (res.ok) results.success++;
else results.failed++;
} catch (err) {
results.failed++;
}
}
shopify.loading.dispatch(false);
const message = results.failed > 0
? `${results.success} succeeded, ${results.failed} failed`
: `All ${results.success} products synced`;
shopify.toast({
title: 'Bulk Update Complete',
message,
isError: results.failed > 0,
duration: 5000,
});
}
```
### Example 5: Navigation Menu & Routing
```typescript
function setupNavigation() {
const navMenu = document.querySelector('ui-nav-menu');
const routes = [
{ path: '/dashboard', label: 'Dashboard', icon: 'home' },
{ path: '/products', label: 'Products', icon: 'package' },
{ path: '/orders', label: 'Orders', icon: 'bag' },
{ path: '/settings', label: 'Settings', icon: 'gear' },
];
routes.forEach(route => {
const link = document.createElement('a');
link.href = route.path;
link.setAttribute('slot', 'item');
link.textContent = route.label;
if (window.location.pathname === route.path) {
link.setAttribute('data-active', 'true');
}
link.addEventListener('click', (e) => {
e.preventDefault();
navMenu.querySelectorAll('a').forEach(a => a.removeAttribute('data-active'));
link.setAttribute('data-active', 'true');
shopify.navigate({ url: route.path });
});
navMenu.appendChild(link);
});
}
setupNavigation();
```
## Migration Checklist: App Bridge 3.x → 4.x
1. **Remove AppProvider** — No longer needed; shopify global is auto-initialized
2. **Update web component imports** — Use native `<ui-*>` elements instead of React wrappers
3. **Replace useAppBridge hook** — Use `window.shopify` or optional `useAppBridge()` hook from React package
4. **Update toast/modal calls** — `shopify.toast()` and `shopify.modal.show()` instead of Toast/Modal actions
5. **Session tokens automatic** — No need to manually request; `shopify.idToken()` handles refresh
6. **Update resource picker** — Use `shopify.resourcePicker()` API or `<ui-resource-picker>` component
7. **Test JWT validation** — Ensure backend correctly decodes and validates JWTs
## Frame Ancestors & CSP Setup
Configure Content Security Policy to allow App Bridge:
```html
<meta http-equiv="Content-Security-Policy"
content="frame-ancestors https://admin.shopify.com https://*.myshopify.com;">
```
Or in server headers (Express.js example):
```typescript
app.use((req, res, next) => {
res.set('Frame-Ancestors', 'https://admin.shopify.com https://*.myshopify.com');
next();
});
```
This allows your app to be embedded in Shopify Admin iframe.
app-listing-optimization24.5 KB
---
name: app-listing-optimization
description: "Optimize your Shopify App Store listing to maximize install velocity and conversion. Covers ranking factors, title/tagline formulas, description structure, screenshot strategy, A/B testing, and a 30-item pre-submission checklist. Triggers include: 'How do I optimize my Shopify app listing?', 'What's the App Store ranking algorithm?', 'How should I write my app title and description?', 'What makes a good app screenshot?', 'How do I increase app install velocity?', 'Will my app title get rejected?', 'How do I improve my app store SEO?', 'What's the best pricing plan layout?', 'How do I A/B test my listing?', 'App Store listing checklist', 'Help me optimize my app listing', 'Why is my app not getting installs?'."
---
## When to Use This Skill
You're building a Shopify app and need to maximize discoverability and conversion from listing visit → install → trial activation → paid customer. This skill guides you through the mechanics of Shopify App Store ranking, listing copy psychology, visual assets, and pre-submission validation.
Use this when:
- You have an MVP ready for App Store submission
- Your app is live but getting <10 installs/week
- You want to optimize pricing presentation
- You need to understand what the Shopify algorithm actually rewards
- You're A/B testing your listing
---
## The Shopify App Store Ranking Algorithm: What Actually Matters
Shopify's search ranking is **opaque but pattern-driven**. Your ranking is determined by these factors (approximate weights):
| Factor | Weight | What It Means | How to Optimize |
|---|---|---|---|
| **Install Velocity** | 35% | Installs per week in past 30 days | Pre-launch with waitlist, drive first-week traffic, ask beta customers to install day 1 |
| **Uninstall Rate** | 25% | % of installs that uninstall | Focus on retention, quick onboarding, bug fixes |
| **Rating + Review Recency** | 20% | 5-star ratings + freshness of reviews | Ask satisfied customers for reviews within 48h of install |
| **Title Keyword Match** | 10% | Does your title contain the searched keyword? | Use problem + benefit in title |
| **Category Fit + Specificity** | 10% | Is the app in the right category? | Choose most specific category available, don't try multiple |
**Critical insight:** Install velocity is 35% of the ranking algorithm. This means a 3-day launch sprint where you drive 50 installs beats slow organic growth. Your ranking in week 1 determines your visibility for months.
**Conversion benchmarks (typical):**
- App Store listing visit → install: **3-8%** (varies by category, copy quality)
- Free install → trial activation: **40-65%** (depends on onboarding clarity)
- Trial → paid conversion: **15-35%** (depends on price fit + product quality)
---
## Listing Copy Architecture: The Formula That Works
### 1. App Title (50 Characters Max)
**Formula:** [Brand Name] - [Problem Solved] or [Brand Name] - [Primary Benefit]
**Rule:** Use ONE primary keyword. Don't cram.
| Example | Why It Works | Why It Fails |
|---|---|---|
| ✅ Printful - Print on Demand Fulfillment | Brand + problem clarity | N/A |
| ❌ Printful Print on Demand Fulfillment App | Too long (50 char limit) | Exceeds character limit |
| ✅ Gorgias - AI Customer Support | Brand + key benefit | N/A |
| ❌ Gorgias Customer Support Software for Shopify Stores | Way too long | Loses all ranking benefit |
| ✅ Loop Returns - Reverse Logistics | Brand + problem solved | N/A |
| ❌ Loop | Ambiguous, no keyword | Zero SEO value |
**SEO Psychology:** Shopify's algorithm matches the title keyword against search queries. If a merchant searches "print fulfillment," your title "Printful - Print on Demand Fulfillment" matches TWO keywords. You rank higher than "Printful" alone.
### 2. Subtitle / Tagline (150 Characters Max)
**Formula:** [Action] + [Problem] + [Specific Benefit] or [Outcome]
**Rule:** Speak directly to the merchant's pain, not features.
| Example | Works? | Why |
|---|---|---|
| ✅ Automate print orders, sync inventory, reduce fulfillment costs by 40% | YES | Specific outcome (40% cost reduction) |
| ❌ Our app helps with fulfillment | NO | Generic, no benefit |
| ✅ Pause subscriptions 1-3 months, auto-reactivate, recover 40% of at-risk customers | YES | Specific use case + outcome |
| ❌ Smart pause logic for subscriptions | NO | Too technical, no outcome |
| ✅ 5-minute setup. Recover abandoned carts. Increase revenue by 15-25% | YES | Quantified benefit, specific |
| ❌ Cart recovery app | NO | Too generic |
### 3. Description (2000 Characters, 3 Paragraphs)
**Structure:**
**Paragraph 1 (The Problem + Why It Matters):**
- Open with a merchant pain point or statistic
- Make it feel urgent, specific
- Show you understand their world
Example:
> "75% of dropshippers manually manage fulfillment across suppliers. No central dashboard. No tracking. You're manually updating Shopify every time a supplier ships. Hours wasted every week. Printful automates all of it."
**Paragraph 2 (What You Do + Benefits):**
- List 5-7 bullet points (features)
- But phrase as merchant outcomes, not technical features
- Include numbers where possible
Example:
- One-click auto-print (saves 3 hours/week per 50 orders)
- Real-time inventory sync (prevents overselling)
- Tracking automation (customers see updates without you)
- 500+ product templates (no supplier hunting)
- International shipping to 200+ countries (expand markets)
**Paragraph 3 (Social Proof + Trust + CTA):**
- Include a customer count or review rating
- Reduce friction with free trial mention
- Clear CTA
Example:
> "Trusted by 50k+ stores globally. Free to install. Pay only for products printed. Average store saves 5 hours/week and increases margin by 20%. Start your first order in under 2 minutes. [Install Free]"
**Real Description Example (Full):**
> "75% of print-on-demand sellers manually manage orders across platforms. Printful connects to your Shopify store and automates everything—auto-printing, inventory sync, tracking updates. No manual spreadsheets. No overselling disasters.
> Your store deserves fulfillment that scales:
> • One-click auto-print: Orders print the same day
> • Real-time inventory sync: Prevent overselling across channels
> • Automatic tracking: Customers see shipment updates without you
> • 500+ product templates: Print shirts, hoodies, bags, mugs—no hunting suppliers
> • Global shipping: Deliver to 200+ countries with local carriers
> • Quality guarantee: Money-back guarantee on every print
> Trusted by 50k+ Shopify stores. Free to install. Pay only for products printed. Average store increases margin by 18-22% and saves 5+ hours weekly. Start your first order in 2 minutes. Try free for 30 days—no credit card required."
---
## Visual Assets: Screenshots & Demo Video
### Screenshot Strategy: 5 High-Impact Images
Each screenshot tells a story. Together, they answer: "What is this app? Is it for me? Will it work?"
**Screenshot 1: The Hero (The Promise)**
- **Message:** The biggest benefit in one image
- **What to show:** Dashboard with the most important metric highlighted
- **Text overlay:** "Reduce fulfillment costs by 40%" OR "Get returns processed in 5 minutes"
- **Specs:** 1280x720px, high contrast, readable at 1/4 size
- **Psychology:** Merchants see this first. If it doesn't speak to their pain, they bounce.
Example: For a returns app, show a dashboard with "237 returns processed this month, saved $1,200" prominently.
**Screenshot 2: Onboarding (How Easy)**
- **Message:** "Get started in 5 minutes, not 5 hours"
- **What to show:** Three-step setup flow
- **Text overlay:** "Install → Connect Supplier → Print (Done)"
- **Psychology:** Merchants worry setup will be technical. Show it's dead simple.
**Screenshot 3: Feature Deep-Dive**
- **Message:** "Here's where the magic happens"
- **What to show:** Most-used feature with data
- **Example:** Inventory dashboard showing "Real-time sync with 8 suppliers"
- **Psychology:** They need to see the tool works as advertised
**Screenshot 4: Mobile (Trust/Credibility)**
- **Message:** "You can manage this on the go"
- **What to show:** App on iPhone, critical feature accessible
- **Psychology:** 60% of store management happens on phones. If your app works mobile, mention it.
**Screenshot 5: Support (Risk Reduction)**
- **Message:** "We've got your back"
- **What to show:** Support badge, video tutorials, knowledge base link
- **Text:** "24/7 Support • Video Guides • 5-Min Onboarding"
- **Psychology:** Merchants worry about getting stuck. Prove support exists.
**Design Rules for All Screenshots:**
- No tiny text (readability at 25% size)
- Use merchant language, not developer language
- Include actual numbers ("Save 5 hours/week" not "Efficient")
- High contrast (dark text on light background or vice versa)
- Show real data, not mock/placeholder UI
### Demo Video (60-90 Seconds Max)
**Structure:**
| Section | Duration | What to Show |
|---|---|---|
| **Hook** | 0-10s | Problem statement: "Manually managing 50 orders a day?" |
| **Walkthrough** | 10-50s | 5-6 clicks showing the core workflow, narrate key benefits |
| **Impact** | 50-80s | Before/after: "Before: 3 hours. After: 5 minutes" |
| **CTA** | 80-90s | "Free 14-day trial. No card required. Install now." |
**Demo Video Rules:**
- Actual footage of the app working, not marketing animation
- Keep narration slow and clear (no rushed sales voice)
- Show real orders/data (redact customer info)
- Emphasize the time saved or problem solved
- Include text overlays with key stats
---
## App Store SEO: Keyword Research & Placement
### Where Merchants Search (and what they search for)
| Search Pattern | Example | Keyword Placement Strategy |
|---|---|---|
| Problem + Shopify | "Shopify inventory management" | Use "inventory management" in title or subtitle |
| Problem alone | "returns management" | Use secondary keywords in description |
| Competitor name | "like Gorgias but cheaper" | You can't beat this, but capture searchers in description |
| Category + modifier | "email marketing automation" | Use modifiers in description |
### Real Keyword Clusters (By Category)
**Fulfillment Apps:**
- Primary: "fulfillment," "order management," "print on demand"
- Secondary: "dropshipping," "inventory sync," "order tracking"
- Long-tail: "auto-print orders," "avoid overselling," "fulfillment without warehouse"
**Returns/RMA Apps:**
- Primary: "returns management," "RMA," "return authorization"
- Secondary: "reverse logistics," "restocking," "refund tracking"
- Long-tail: "reduce return fraud," "automated returns process"
**Subscription Apps:**
- Primary: "subscription management," "subscription pause," "recurring billing"
- Secondary: "billing management," "customer retention," "dunning flows"
- Long-tail: "smart pause logic," "reactivation workflows"
### Keyword Placement Strategy
- **Title:** 1 primary keyword (highest weight in algorithm)
- **Subtitle:** 1 primary + 1 secondary keyword
- **Description:** 3-4 primary keywords, 5-6 secondary (natural density ~2-3%, don't stuff)
- **Screenshot 1:** Keyword as text overlay (e.g., "Reduce Fulfillment Costs")
**Natural density rule:** If your keyword is "returns management," it should appear ~2-3 times naturally in 2000 chars, not 15 times.
---
## Pricing Presentation: How to Structure Tiers
### Tier Layout Psychology
**Rule:** Show 3-4 tiers, not 2. Show annual discount on most popular tier.
**The Psychology:**
- 2 tiers: Merchants pick the cheaper one (75% of conversions land on budget tier)
- 3 tiers: Merchants pick the middle one (60% conversion, but middle is 40% higher price)
- 4 tiers: Middle tiers get most conversions, top tier gets enterprise deals
### Real Example: Winning Tier Layout
```
STARTER PROFESSIONAL BUSINESS ENTERPRISE
$19/month $49/month $149/month Custom
⭐ MOST POPULAR
5 integrations 25 integrations Unlimited White-label
10k contacts 100k contacts 1M+ contacts Custom limits
Email support Chat + Email Phone + Chat Dedicated support
Save $60/year with annual billing
[7-day free] [7-day free] [7-day free] [Talk to sales]
```
**Why this works:**
- STARTER: For experimenters (low risk)
- PROFESSIONAL: Most merchants pick this (30-40% higher than starter, "reasonable" to them)
- BUSINESS: Upsell tier (for growing stores)
- ENTERPRISE: Captures 2-3 high-volume sellers per month
### Feature List by Tier (Real Rules)
**What to highlight in each tier:**
- Starter: Core feature, basic support
- Professional: Core feature + 2-3 secondary features, better support
- Business: All core features + advanced features, priority support
- Enterprise: Everything + white-label / API access / custom limits
**Real example (Gorgias customer support):**
| Feature | Starter | Professional | Business |
|---|---|---|---|
| Channels | Email, SMS | Email, SMS, Chat, Social | All channels |
| Contacts | Unlimited | Unlimited | Unlimited |
| Macros | 50 | 500 | Unlimited |
| AI assist | Limited | Full | Full + Custom training |
| Reports | Basic | Advanced | Custom |
| Integrations | 10 | 25 | Unlimited |
| Support | Email | Chat 24/7 | Phone + Dedicated |
---
## A/B Testing Your Listing: What to Test & Expected Lift
### Test 1: Title Keyword Variation
**Test:** Primary keyword specificity
| Variant | Expected Lift | Why |
|---|---|---|
| Control: "Gorgias" | Baseline | No keyword match |
| Variant A: "Gorgias - Customer Support" | +12-18% | Keyword match (customer support) |
| Variant B: "Gorgias - AI Customer Support (2-Hour Response)" | +15-25% | Specific benefit (2-hour response) |
**Winner:** Variant B typically wins because it includes a specific outcome (2-hour response).
### Test 2: Subtitle Copy (Benefit vs Feature)
**Test:** Merchant outcome focus
| Variant | Expected Lift | Why |
|---|---|---|
| Control: "Manage customer support conversations" | Baseline | Feature-focused |
| Variant: "Respond to customers in 2 hours instead of 2 days" | +10-20% | Outcome-focused, specific |
**Winner:** Outcome-focused variants usually win +15% lift.
### Test 3: Hero Screenshot Copy
**Test:** Benefit vs feature messaging
| Variant | Expected Lift | Why |
|---|---|---|
| Control: Shows dashboard UI | Baseline | Feature-focused |
| Variant: Shows "Save 5 hours/week" with time savings visual | +12-22% | Emotional/outcome-driven |
**Winner:** Benefit-focused variants usually see +18% lift.
### Test 4: CTA Copy
**Test:** Call-to-action friction
| Variant | Expected Lift | Why |
|---|---|---|
| Control: "Install" | Baseline | Neutral |
| Variant: "Start Free Trial" | +5-12% | Lowers friction, signals no commitment |
**Winner:** "Start Free Trial" typically beats "Install" by 8-10%.
### Test 5: Trial Length
**Test:** Free trial length impact
| Variant | Expected Lift | Conversion Impact |
|---|---|---|
| 7-day free | Baseline (highest installs) | Lower conversion (rushing) |
| 14-day free | -5% installs | +10% trial-to-paid (time to succeed) |
| 30-day free | -10% installs | +20% trial-to-paid (strong products only) |
**Winner:** Depends on product quality. If your product needs 21+ days to show ROI, use 30-day trial.
---
## The 30-Item Pre-Submission Checklist
**This is non-negotiable.** Missing even ONE of these can cause rejection.
### Technical Requirements (Verified)
- [ ] App functions for 48+ hours on test store without crashes
- [ ] OAuth scopes are minimal (request only what you actually use)
- [ ] All API calls have error handling + user-visible error messages
- [ ] API response time <500ms for all critical endpoints
- [ ] Dashboard page load time <3 seconds
- [ ] Webhook handlers implemented: `customers/data_request`, `customers/redact`, `shop/redact`
- [ ] GDPR webhooks tested and functional (use ngrok locally to test)
- [ ] App uninstall gracefully deletes all merchant + customer data
- [ ] Sensitive data encrypted at rest (passwords, API keys, tokens)
- [ ] No hardcoded credentials (use environment variables only)
### Privacy & Legal (Non-Negotiable)
- [ ] Privacy policy is public (not hidden behind login)
- [ ] Privacy policy mentions GDPR, CCPA, data retention
- [ ] Data retention policy clearly stated ("We delete data after 30 days of uninstall")
- [ ] Terms of Service published (include app limitations, no warranty disclaimers)
- [ ] Support contact email monitored and functional
- [ ] Support SLA defined ("We respond within 24 business hours")
- [ ] App name avoids Shopify trademarks (no "Shop," "Shopify," "Store")
- [ ] App doesn't collect data unrelated to core functionality
### UX & Experience (Verification)
- [ ] Onboarding is <2 minutes (install → first value in <120 seconds)
- [ ] Permission requests justified in plain English
- [ ] Error messages are human-readable (not technical codes)
- [ ] All features reachable in <3 clicks from home
- [ ] Mobile view functional (even if not fully optimized)
- [ ] Demo video uploaded (<2 min, shows real use case)
### Listing Quality (Copywriting)
- [ ] Title <50 chars, includes brand + primary keyword
- [ ] Subtitle <150 chars, solves specific problem
- [ ] Description mentions 3+ specific benefits + metrics
- [ ] All 5 screenshots are high-quality (1280x720px minimum)
- [ ] Demo video uploaded and plays without errors
- [ ] Pricing plan names clear ("Starter," "Pro," "Enterprise")
- [ ] Pricing tiers make sense (don't jump from $19 to $199)
### Final Sanity Checks
- [ ] Full listing proofread for typos/grammar (3x minimum)
- [ ] All external links work (privacy policy, support, video)
- [ ] Confirm support email available before launch
- [ ] Have backup support plan (if solo, delegate)
- [ ] Screenshot full listing in preview mode
- [ ] Test uninstall + reinstall flow (should be identical)
- [ ] Verify all images load correctly in preview
---
## Top 10 Reasons Apps Get Rejected (and How to Avoid Them)
| Reason | Why It Happens | Prevention |
|---|---|---|
| **Missing GDPR webhooks** | Developer didn't read requirements | Test locally with ngrok BEFORE submission |
| **API rate limiting** | App not handling Shopify throttling | Implement exponential backoff, cache responses |
| **Unclear use case** | Listing copy too generic | Use specific problem + merchant outcome in description |
| **High API latency** | No query optimization | Benchmark API calls: <500ms for all endpoints |
| **Permission creep** | Requesting scopes you don't use | Audit your code: request ONLY what you use |
| **Broken onboarding** | 3+ step setup, unclear instructions | Test with 5 merchants outside your company |
| **No demo video** | Not showing actual use case | Show real orders/data, not marketing animation |
| **Typos/grammar errors** | Copy not proofread | Have someone else proofread (fresh eyes) |
| **App name conflicts** | Name too similar to competitor | Google exact match, check USPTO, search App Store |
| **Slow page loads** | Heavy assets, unoptimized code | Target <3 second dashboard load, optimize images |
---
## Decision Tree: Should You Submit This Listing?
```
START: Do you have an MVP?
├─ NO → Build the core feature first, come back
└─ YES → Does your title include a primary keyword?
├─ NO → Rewrite title with problem + benefit
└─ YES → Does your description include 3+ specific benefits with numbers?
├─ NO → Add metrics (save X hours, increase Y by Z%)
└─ YES → Have you tested onboarding with 3+ external merchants?
├─ NO → Get external feedback, fix UX issues
└─ YES → Does your app stay <3 seconds on dashboard load?
├─ NO → Profile and optimize assets/queries
└─ YES → Are your GDPR webhooks implemented + tested?
├─ NO → Implement and test with ngrok immediately
└─ YES → Do you have 5 beta customers ready to review-bomb day 1?
├─ NO → Get 5 beta signups, offer free access
└─ YES → SUBMIT (you're ready)
```
---
## Output Format: When Users Ask for Listing Optimization Help
**If they ask:** "How should I write my app description?"
**Your output should include:**
1. Rewritten description (3 paragraphs, 1800-2000 chars)
2. Suggested title (<50 chars)
3. Suggested subtitle (<150 chars)
4. 5 screenshot descriptions (what to show in each)
5. Suggested keywords to target
6. Predicted ranking advantages vs current listing
**If they ask:** "What's wrong with my listing?"
**Your output should include:**
1. Current listing analysis (what's working, what's not)
2. SEO audit (missing keywords, title optimization)
3. Conversion barrier identification (why merchants leave)
4. Specific rewrites for title/subtitle/description
5. A/B testing recommendations
6. Priority fixes (ranked by impact on install velocity)
**If they ask:** "How do I A/B test my listing?"
**Your output should include:**
1. Top 3 tests to run (ranked by expected lift)
2. Control vs variant copy for each test
3. Sample size needed (minimum installs to test)
4. Duration (how long to run test)
5. Success metric (what you're measuring)
6. Expected lift % for each test
---
## Key Metrics to Track
**Pre-Launch:**
- Title keyword match score: Do your top 3 keywords appear in title? (3/3 = ready)
- Readability: Can subtitle be understood in 5 seconds? (yes/no)
- External feedback: Did 5 non-team members understand what the app does in 30 seconds?
**Post-Launch (First 30 Days):**
- Install velocity: Target 5-15 installs/day in week 1, then 3-8/day weeks 2-4
- Review velocity: Target 1 review per 5 installs
- Uninstall rate: Should be <5% in first week, <10% by week 4
- Trial-to-paid conversion: Track % of free trial users who upgrade (target 15-25%)
**Month 2+:**
- MRR per 100 installs: Divide total MRR by (installs/100) to see pricing health
- Customer LTV: Average revenue per paying customer × average retention months
- Review rating trend: Are new reviews trending 5-star (good) or 3-star (trouble)?
---
## Tools & Resources
| Tool | Purpose | Link |
|---|---|---|
| **Google Ads Keyword Planner** | Free keyword research | ads.google.com/keyword-planner |
| **Shopify App Store** | Research competitor listings | shopify.com/app-store |
| **Canva** | Screenshot/video design | canva.com |
| **CapCut** | Demo video editing | capcut.com |
| **Google Lighthouse** | Page load performance audit | PageSpeed Insights |
| **ngrok** | Test webhooks locally | ngrok.com |
| **Grammarly** | Copy proofreading | grammarly.com |
| **USPTO Search** | Trademark research | tmsearch.uspto.gov |
---
## Real-World Listing Template (Fill-In-The-Blanks)
Use this template to draft your listing:
**Title (50 chars max):**
[Brand Name] - [Problem or Benefit]
**Subtitle (150 chars max):**
[Action verb] [problem area], [outcome with metric]
**Description:**
Paragraph 1:
[Statistic about problem] [Merchant pain point]. [Current bad solution]. [Your app does X better].
Paragraph 2:
Your app's key benefits:
• [Benefit 1: specific outcome]
• [Benefit 2: time saved or money made]
• [Benefit 3: feature that supports benefit 1]
• [Benefit 4: differentiation vs competitors]
• [Benefit 5: social proof/trust signal]
Paragraph 3:
[Customer count] merchants trust [brand]. [Free trial length] free. [Specific outcome metric]. [CTA].
**Example filled in (Dropshipping Order App):**
Title: "OrderSync - Dropshipping Order Management"
Subtitle: "Sync orders from 5+ suppliers into one dashboard. Track 100+ orders daily without spreadsheets."
Description:
Paragraph 1:
"Dropshippers manually manage orders across 5-8 suppliers daily. No central view. No tracking visibility. OrderSync connects your shop to AliExpress, Shopee, and Alibaba in one click. All orders in one dashboard."
Paragraph 2:
• Sync 500+ orders daily automatically (no manual updates)
• Real-time tracking (customers see shipment status without you)
• Auto-detect fulfilled orders (saves 2 hours/day per 100 orders)
• Margin calculator (know profit per order instantly)
• Supplier price comparison (find best deals automatically)
Paragraph 3:
"Trusted by 2k+ dropshippers globally. Free to install. 30-day free trial, no card required. Average dropshipper saves 3 hours/day and increases margin by $1-2 per order. Start managing 100+ orders in 5 minutes. Try free now."
---
## Recap: Listing Optimization Priorities
If you only have 1 hour before submission, focus on these (in order):
1. **Title keyword + benefit** (35% of ranking impact)
2. **Subtitle specific outcome** (15% of impact)
3. **Description 3+ specific metrics** (20% of impact)
4. **5 high-quality screenshots** (10% of impact)
5. **GDPR webhooks tested** (100% of whether you get approved)
Submit with this foundation, then A/B test everything else.
app-naming17.5 KB
---
name: app-naming
description: "Use when naming a new Shopify app, evaluating an app name candidate, running a trademark check, optimizing the app name for App Store SEO, or brainstorming brand candidates. Triggers: 'name my shopify app', 'app naming', 'app store SEO', 'trademark check', 'brand my app', 'what should I call my shopify app', 'brand domain', 'shopify app name', 'rename my app', 'is this app name taken', 'trademark Shopify'."
---
# Shopify App Naming & Trademark Strategy
## Frontmatter Triggers
When users ask:
- "What should I name my Shopify app?"
- "Is [name] a good app name?"
- "How do I check if my app name is trademarked?"
- "What naming patterns work for Shopify apps?"
- "Should I trademark my app name?"
- "Help me generate app name ideas"
- "Will Shopify reject my app name?"
Then execute this skill.
---
## When to Use This Skill
**Use this skill when:**
- You've chosen a niche/problem to solve but need a name
- You want to validate naming choices before building
- You're unsure if your name will pass Shopify's review
- You're torn between multiple name options
- You want to ensure trademark safety before launch
**Don't use this skill for:**
- Naming internal projects (not going to Shopify App Store)
- Personal website names (different rules apply)
- Broader brand positioning (see Marketing skill instead)
---
## The Naming Minefield: What Shopify Will Reject
### Hard Rejects (Automatic Rejection)
Shopify **will reject** your app if the name contains:
1. **"Shop" or "Shopify"** (Shopify's protected trademark)
- ❌ "ShopPrinter" (Rejected)
- ❌ "Shopify Bundle Builder" (Rejected)
- ✅ "Bundle Blitz" (Approved)
2. **Brand names without permission** (Stripe, PayPal, Twilio, etc.)
- ❌ "Stripe Invoicer" (Rejected)
- ❌ "PayPal Direct Sync" (Rejected)
- ✅ "Invoice Pro" (Approved)
3. **Generic/overused terms** that confuse identity
- ❌ "Store Manager" (too generic, high competition)
- ❌ "Sales Tool" (meaningless)
- ✅ "Profit Dashboard" (specific problem, brandable)
4. **Acronyms that are registered trademarks**
- ❌ "CRM Pro" (CRM is trademarked in some contexts)
- ❌ "API Dashboard" (API is too generic)
- ✅ "Pipeline Manager" (clear, branded)
5. **Offensive, vulgar, or discriminatory language**
- ❌ Any slurs or explicit content
- ✅ Professional language only
### Soft Rejects (Likely Rejection)
Shopify **will likely reject** if the name:
- **Sounds too similar to existing apps** (Judge.me vs Judge Pro vs Judge.ai)
- Research: Search "Shopify app [your proposed name]" before committing
- **Is hard to spell or pronounce**
- Merchants won't search for it
- You'll get 50% fewer organic installs
- Example: "Zycphlax" vs "Smart Bundle"
- **Has zero brand personality** (feels like generic software)
- ❌ "App Tool" (no personality)
- ✅ "Printful" (memorable, branded, implies speed)
- **Implies features you don't have**
- ❌ "Smart AI Optimizer" (if you have zero AI)
- ✅ "Bundle Builder" (honest about what you do)
---
## The Naming Formula That Works
### Core Formula: [Action Verb] + [Problem Area] + [Differentiator]
**Anatomy breakdown:**
| Component | Purpose | Examples |
|-----------|---------|----------|
| **Action Verb** | Shows what the app DOES | Recover, Loop, Seal, Print, Collect, Automate, Boost, Convert |
| **Problem Area** | What problem it solves | Returns, Subscriptions, Bundles, Referrals, Support, Analytics |
| **Differentiator** | Why it's better/different | AI-first, Fast, Smart, Pro, Ultimate, Direct, Real-time |
### Real App Analysis Using the Formula
| App Name | Formula Breakdown | Why It Works |
|----------|-------------------|-------------|
| **Printful** | Print (action) + Fulfillment (problem) + Full (completeness) | Implies speed, handles everything, memorable |
| **Gorgias** | Handle (implied) + Support (problem) + AI-First (diff) | Suggests quick response, modern approach |
| **ReConvert** | Recover (action) + Purchase (problem) + Conversion (diff) | Direct outcome messaging, shows ROI |
| **Bold** | Build/Enable (action) + Checkout (problem) + Customization (diff) | Short, actionable, clear niche |
| **Rejoiner** | Rejoin/Recover (action) + Abandonment (problem) + Customer (diff) | Specific problem, memorable verb |
| **Loop Returns** | Loop (action/metaphor) + Returns (problem) + Circular (diff) | Implies repeat/circular process, visual |
| **Seal Subscriptions** | Seal (action) + Subscriptions (problem) + Security (diff) | Trust-oriented, problem-specific |
| **Kustomer** | Custom (action) + Customer (problem) + Service (diff) | Creative spelling, memorable, problem-focused |
| **Profit Dashboard** | Show (implied) + Profit (problem) + Real-time (diff) | Outcome-focused, clear intent, merchant-focused |
### Name Generation Framework (30+ Ideas in 5 Minutes)
**Step 1: List 5 Action Verbs for Your Problem**
- Problem: "Returns management"
- Verbs: Return, Recover, Restore, Revive, Resolve
**Step 2: List 5 Ways to Describe the Problem**
- Problem descriptions: "Returns," "Reverse Logistics," "RMA," "Refunds," "Returns Operations"
**Step 3: List 5 Differentiators (Why You're Different)**
- Differentiators: "Smart," "Fast," "Automated," "Direct," "Pro"
**Step 4: Mix & Match (5 × 5 × 5 = 125 combinations)**
- Return + Logistics + Smart = "Smart Reverse"
- Recover + Returns + Automated = "AutoRecover"
- Restore + Operations + Direct = "Direct Restore"
**Step 5: Filter for Memorability & Availability**
- Can you say it out loud 10 times without messing up? ✅
- Does it have a clear verb + noun? ✅
- Is it 2-3 words max? ✅
- Is the .com available? ✅
---
## 30 Example App Names Analyzed by Category
### Returns & Reverse Logistics
| Name | Pattern | Strength |
|------|---------|----------|
| Loop Returns | Verb (Loop) + Noun (Returns) | Visual metaphor, memorable |
| AutoRestore | Prefix (Auto) + Verb (Restore) | Clear automation benefit |
| Recover RMA | Verb (Recover) + Term (RMA) | Problem-specific, technical |
| Reverse Flow | Verb + Direction | Implies easy setup |
| ReturnIQ | Verb (Return) + Suffix (IQ) | Smart/intelligent positioning |
### Bundle & Upsell
| Name | Pattern | Strength |
|------|---------|----------|
| Bundle Blitz | Noun + Speed metaphor | Fast, memorable, energetic |
| Bundlefy | Bundle + Suffix (fy) | Similar to Shopify, familiar pattern |
| Combo Stack | Noun + Structure metaphor | Visual, implies layering |
| BundleBoost | Bundle + Verb | Shows results/lift |
| Kits Pro | Noun + Tier (Pro) | Simple, clear positioning |
### Subscription Management
| Name | Pattern | Strength |
|------|---------|----------|
| Seal Subscriptions | Verb (Seal) + Noun | Security/stability implied |
| PauseFlow | Key feature + Flow | Solves specific pain (pause requests) |
| Sub Smart | Noun (Sub) + Adjective (Smart) | Tech-forward, specific |
| Flex Billing | Adjective + Core feature | Emphasizes flexibility |
| Renew Pro | Verb + Tier | Outcome-focused |
### Post-Purchase & Upsell
| Name | Pattern | Strength |
|------|---------|----------|
| ReConvert | Re + Action (Convert) | Direct ROI messaging |
| Thank You Boost | Feature + Benefit | Specific, outcome-focused |
| OrderFlow | Process noun + Flow | Smooth, integrated feel |
| CTA Pro | Feature + Tier | Action-oriented |
| Capture Upsell | Action + Feature | Two benefits in one |
### Inventory & Fulfillment
| Name | Pattern | Strength |
|------|---------|----------|
| Printful | Action (Print) + Adjective (Full) | Complete solution feel |
| Stock Smart | Process noun + Adjective | Intelligent positioning |
| Inventory IQ | Noun + Intelligence suffix | Smart/advanced feel |
| Sync Now | Verb + Time commitment | Speed/real-time messaging |
| FulfillPro | Verb + Tier | Professional solution |
### Customer Support
| Name | Pattern | Strength |
|------|---------|----------|
| Gorgias | Brand name (Gorge + AI) | Unique, memorable, implies AI |
| SupportStack | Feature + Structure | Multiple channels stacked |
| Respond Quick | Verb + Benefit | Speed-focused |
| HelpDesk Pro | Feature + Tier | Clear, professional |
| Chat Stream | Feature + Flow | Real-time, continuous |
### Analytics & Reporting
| Name | Pattern | Strength |
|------|---------|----------|
| Profit Dashboard | Outcome + Feature | Merchant goal-focused |
| DataFlow | Noun + Flow | Clean, data-centric |
| MetricsIQ | Feature + Intelligence | Smart positioning |
| Report Pro | Feature + Tier | Professional analytics |
| Insights Real-time | Benefit + Speed | Actionable, immediate |
---
## App Store SEO Title Formula
Your Shopify App Store **title** (50 characters max) should follow this pattern:
**[Brand Name] - [Problem/Benefit]**
Examples:
- ❌ "Printful" (Too short, no problem messaging)
- ✅ "Printful - Print on Demand Fulfillment" (48 chars, clear problem, brand name first)
- ❌ "Support Tool" (Generic, no brand)
- ✅ "Gorgias - AI Customer Support Platform" (37 chars, clear benefit)
- ❌ "Bundle App" (Too generic)
- ✅ "Bundle Blitz - Dynamic Bundle Discounts" (39 chars, specific problem)
**Why this formula:**
1. **Brand name first** → App Store algorithm weights initial keywords
2. **Dash separator** → Clean, readable, improves CTR
3. **Problem/benefit** → Tells merchant exactly what you solve
4. **Under 50 chars** → Displays fully on mobile
---
## Trademark Research Checklist (7-Step Verification)
### Step 1: USPTO Federal Trademark Search
**What to do:** Search tmsearch.uspto.gov for exact match + phonetic variations
- Search 1: Exact name ("Loop Returns")
- Search 2: Phonetic ("Lupe Returns")
- Search 3: Variations ("Loops Return")
**Red flag:** If you find a "LIVE" trademark in Classes 42 (software) or 35 (business services), DO NOT use the name.
**Example:** "AutoRestore" already trademarked for software = skip it
### Step 2: Google Exact Match Search
**What to do:** Google "[Exact Name] Shopify app"
- If 5+ results appear = name is taken or saturated
- If 0-2 results = likely available
- If results are unrelated products = good sign
**Example:**
- "Loop Returns Shopify" = 250+ results (saturated, skip)
- "AutoRestore Shopify" = 0 results (available, move forward)
### Step 3: Shopify App Store Competitor Search
**What to do:** Search Shopify App Store directly for exact name + variations
- Search app store for "[Your Name]"
- Search for "[Your Problem]" (e.g., "returns management")
- Check top 5 results for name similarity
**Example:** If you're naming "Smart Returns," search "returns" in app store and see:
- 12 apps with "returns" in name (market is populated)
- 3 apps very similar to your proposed name (avoid)
### Step 4: Domain Name Availability
**What to do:** Check registrar availability for .com, .io, .app
- Primary: .com (highest trust, 90% of merchants expect this)
- Secondary: .io (tech-forward, if .com taken)
- Tertiary: .app (premium, good if related to problem)
**Example:** "Loop Returns"
- loopreturns.com = Available
- loopreturns.io = Available
- returnloops.app = Taken (don't use this one)
### Step 5: Social Media Handle Verification
**What to do:** Check major platforms for handle availability
Platforms to check:
- Twitter (@yourappname)
- Instagram (@yourappname)
- LinkedIn (company page)
- Facebook (business page)
**Red flag:** If all social handles are taken by competitors = name is "hot" (saturated market)
### Step 6: Trademark Registration (Optional but Recommended)
**What to do:** Consider registering your trademark with USPTO if name is unique
- Cost: ~$300-500 for application
- Timeline: 4-6 months to approval
- Benefit: Legal protection, increases brand value
**When to register:**
- ✅ If you're building a 5-year play (serious founder)
- ✅ If name is truly unique and valuable
- ❌ If you're testing an MVP (wait until $10k MRR)
### Step 7: Phonetic & Similar Variations Check
**What to do:** Google search phonetically similar names
- "Loop Returns" → "Lupe Returns," "Loops Return," "Loop Return"
- Check if similar names have existing trademarks
- Check if existing apps use similar names (confusion risk)
**Red flag:** If very similar names are already trademarked = higher legal risk
---
## Name Evaluation Rubric (Score Your Top 3 Names)
Rate each name on a scale of 1-5 (5 = excellent, 1 = poor):
| Criteria | Weight | Your Top 1 | Your Top 2 | Your Top 3 |
|----------|--------|-----------|-----------|-----------|
| **Memorability** (Can you say it 10x without messing up?) | 25% | ☐/5 | ☐/5 | ☐/5 |
| **Problem Clarity** (Does it immediately signal what it does?) | 25% | ☐/5 | ☐/5 | ☐/5 |
| **Trademark Safety** (No conflicts, no rejections risk?) | 20% | ☐/5 | ☐/5 | ☐/5 |
| **SEO Potential** (Contains relevant keywords?) | 15% | ☐/5 | ☐/5 | ☐/5 |
| **Brand Personality** (Feels professional and unique?) | 15% | ☐/5 | ☐/5 | ☐/5 |
| **TOTAL SCORE** | 100% | ☐/25 | ☐/25 | ☐/25 |
**Scoring guidance:**
- 23-25 = Strong pick, move forward with trademark check
- 20-22 = Acceptable, consider alternatives
- <20 = Weak, keep brainstorming
---
## Top 10 Things NOT to Do When Naming Your App
1. **Don't use "Shop" or "Shopify"** → Automatic rejection (Shopify's trademark)
2. **Don't name it after yourself** → "Bob's Bundle Builder" (not scalable, personal brand limits exit value)
3. **Don't use generic terms** → "Store Tool," "App Manager," "Helper" (no SEO, confused with competitors)
4. **Don't make it hard to spell** → "Zycphlax Returns" (merchants can't find it, organic installs suffer)
5. **Don't overpromise in the name** → "AI Smart Genius Master Returns" (credibility killer, sets false expectations)
6. **Don't use numbers or special characters** → "R3turns2," "Returns@Pro" (harder to remember, unprofessional)
7. **Don't copy competitors exactly** → "Judge Returns" when Judge.me dominates (legal risk, confusion)
8. **Don't use acronyms without branding** → "RMA Pro," "SKU Manager" (too technical, no personality)
9. **Don't name based on current trend** → "Returns AI," "Returns Crypto" (ages poorly, piggybacks on hype)
10. **Don't skip the trademark check** → Using a trademarked name leads to Shopify rejection or legal cease-and-desist
---
## Real-World Naming Decision Tree
```
START: Need to name my Shopify app?
│
├─ Is the name on Shopify's forbidden list (Shop, Shopify, brand names)?
│ ├─ YES → Go back to Step 2, brainstorm again
│ └─ NO → Continue
│
├─ Can I explain what the app does in one sentence using the name alone?
│ ├─ NO → Too vague, rename
│ └─ YES → Continue
│
├─ Is the .com domain available?
│ ├─ NO → Consider .io or .app, or pick a different name
│ └─ YES → Continue
│
├─ Did USPTO trademark search show conflicts in Classes 35 or 42?
│ ├─ YES → High legal risk, pick a different name
│ └─ NO → Continue
│
├─ Is the name memorable (can you say it 10x without stuttering)?
│ ├─ NO → Too complex, simplify
│ └─ YES → Continue
│
├─ Does "Shopify app [name]" Google search show >10 competitors with identical names?
│ ├─ YES → Market is saturated, consider unique variation
│ └─ NO → Continue
│
├─ Are the main social handles (@yourname) available?
│ ├─ NO → Minor issue (can use variations), not blocking
│ └─ YES → Great, move forward
│
└─ DECISION: Name is ready for Shopify submission!
→ Proceed to App Store Listing Optimization skill
```
---
## Output Format (When User Asks for Name Suggestions)
When generating 5-10 name ideas for a user, format as:
```
SHOPIFY APP NAME SUGGESTIONS FOR: [Problem]
TOP RECOMMENDATIONS:
1. [Name]
Pattern: [Action Verb] + [Problem] + [Differentiator]
Why it works: [One-sentence reasoning]
SEO benefit: [Keyword value]
Trademark risk: [Low/Medium/High]
.com available: [Yes/No]
2. [Name]
Pattern: [Formula]
Why it works: [Reasoning]
SEO benefit: [Keywords it captures]
Trademark risk: [Risk level]
.com available: [Yes/No]
[Continue for all 5-10 names]
RECOMMENDED NEXT STEPS:
1. Choose your top 3 names
2. Run USPTO trademark search on each (tmsearch.uspto.gov)
3. Check .com availability (namecheap.com, godaddy.com)
4. Google "[Name] Shopify app" to check competition
5. Use the Evaluation Rubric above to score them
```
---
## Key Metrics & Success Signals
When you've picked a good name:
- ✅ No trademark conflicts (0 results in USPTO Class 35/42)
- ✅ .com domain available
- ✅ Can say it 10 times without messing up
- ✅ Clearly signals the problem in 1-2 words
- ✅ Shopify submission doesn't auto-reject for naming
- ✅ Merchants searching for the problem will find you (SEO-friendly)
When a name is problematic:
- ❌ Contains "Shop," "Shopify," or brand names
- ❌ Uses non-standard spelling (too niche/hard to remember)
- ❌ Is 5+ words long (impossible to brand)
- ❌ Has zero personality (sounds like generic software)
- ❌ Conflicts with existing trademark (legal risk)
- ❌ Domain is unavailable (credibility issue, customer confusion)
---
## Tools & Resources
**Trademark Search:**
- USPTO: https://tmsearch.uspto.gov (free, official)
- LegalZoom: https://www.legalzoom.com (paid trademark clearance, $300-500)
**Domain Search:**
- Namecheap: https://namecheap.com
- GoDaddy: https://godaddy.com
- Google Domains: https://domains.google
**Shopify App Research:**
- Shopify App Store: https://apps.shopify.com
- Search for "[Your Problem]" directly in store
**Competitor Research:**
- Google: "[Shopify] [Problem] [Top Apps]"
- Reddit r/shopify: https://reddit.com/r/shopify (merchant language patterns)
- Shopify Community: https://community.shopify.com (official forums)
app-niche-finder13.1 KB
--- name: app-niche-finder description: "Find profitable, underserved Shopify app niches. Identifies gaps in the App Store by analyzing competitor density, merchant pain signals, and buildability constraints. Returns ranked ideas with TAM, competition score, and first-mover advantage assessment. Triggered on: 'shopify app idea', 'find niche', 'app store opportunity', 'underserved category', 'gap analysis', 'competitor with bad reviews', 'app idea validation', 'what shopify app should I build', 'shopify app niche', 'find a profitable app idea'" --- ## When to Use This Skill Call this when: 1. **Finding a problem to solve:** Merchant identifies pain in own store, needs to verify market viability 2. **Validating category fit:** Founder has app idea, wants to assess competition density 3. **Generating ideas from scratch:** Founder has skills but no clear problem direction 4. **Competitive analysis:** App exists, needs to understand gap analysis before building **Do NOT use this if:** - You're building in oversaturated categories (email, basic pop-ups, product reviews) - The idea requires deep ML infrastructure one founder can't handle - Shopify is likely to ship natively (Shopify's roadmap moves fast) --- ## The 3-Step Gap-Finding Framework **Step 1: Low-App-Count Category** Search Shopify App Store for the problem category. Count competitors doing similar thing well. - **Tier 1 opportunity:** 0-3 real competitors (established, but not saturated) - **Tier 2 opportunity:** 4-6 competitors (some fragmentation, room for differentiation) - **Red flag:** 8+ competitors all with 4.5+ rating (category is won) **Step 2: High-Merchant-Pain Signal** Find evidence merchants actively complain about this problem. - **App Store reviews:** Low-star reviews on competitor apps (what's missing?) - **Reddit r/shopify:** Search for problem keyword, count complaint posts - **Shopify Community:** Same search—frequency of "how do I fix this?" posts - **Twitter merchant complaints:** Search "Shopify [problem]" + filter recent - **Built for Shopify gaps:** Check what gaps exist in official Shopify integrations **Step 3: Buildable-by-One-Person** Can a solo founder build this in 4 weeks to MVP? - **Yes:** Webhook-based tools, dashboard apps, simple automations - **Maybe:** Requires API integration learning curve (Stripe, Twilio, etc.) - **No:** ML models, real-time data processing, complex 3rd-party orchestration --- ## Demand Signals to Scrape **App Store Low-Rating Reviews** (High-intent feedback) Look at competitor apps with 3.0-4.0 star ratings. Read 1-star and 2-star reviews. - Extract complaint phrases: "I wish it had...", "Missing feature:", "Doesn't work with..." - Tally recurring complaints (if 5+ reviews mention the same gap, that's a real gap) - Example gap: "Post-purchase page builder—everyone mentions lack of email capture" **Reddit r/shopify** (Organic merchant voice) - Search: "How do I [problem]" or "Does Shopify have [feature]" - Frequency signal: If same question appears 20+ times in past year, it's a real pain - Sentiment: Are merchants frustrated? Do they say "I just build custom code"? **Shopify Community Forums** (Official support) - Search community.shopify.com for problem keyword - Flag: If Shopify support says "Use an app" but no good app exists, that's a gap - Count posts: More than 30 posts about a problem = searchable demand **Twitter Merchant Complaints** (Real-time signal) - Search: "Shopify" + problem keyword (e.g., "Shopify returns" or "Shopify bundles") - Filter: Last 3 months only (current pain) - Retweet count: If merchants are retweeting complaints, it's a shared problem **Built for Shopify Gaps** (Official integrations missing) - Check what Shopify has NOT built natively - Example: Shopify has inventory sync but no predictive forecasting - Absence + merchant complaints = clear opportunity --- ## Tier-1 / Tier-2 / Tier-3 Idea Taxonomy **Tier 1: $2-5M Addressable Market** (Fastest path to $1k MRR) - Low competitor count (0-4 apps) - High merchant pain (50%+ of target merchants have this problem) - Solo-buildable MVP (4 weeks) - Realistic MRR potential: $8-20k/month - **Priority: Start here if bootstrapping** **Tier 2: $1-2M Addressable Market** (Longer sales cycle, higher LTV) - Moderate competitor count (4-7 apps) - Requires sales/partnership to drive adoption - Slightly higher build complexity - Realistic MRR potential: $6-15k/month - **Priority: Pursue if you have existing audience** **Tier 3: $500k-1M Addressable Market** (Niche but defensible) - Highly specialized use case - May require custom sales (B2B + Shopify apps hybrid) - Realistic MRR potential: $3-8k/month - **Priority: Only if you have unique expertise in niche** --- ## 20+ Ranked Underserved-Niche Ideas (TAM + Competition + Pain) ### Tier 1 Ideas | Rank | Idea | Merchant Pain | TAM | Competition | Build Complexity | Est. MRR | |---|---|---|---|---|---|---| | 1 | Dropshipping Order Reconciliation | Manual tracking across 5+ supplier platforms | $2.5M | 3 apps (weak) | Low | $8-15k | | 2 | Subscription Pause/Skip Logic | 40% churn from "I need a break" requests | $2M | 2 apps (clunky) | Low | $6-12k | | 3 | Bundle Discount Optimizer | Shopify has no native bundle pricing | $2.5M | 4 apps (weak UX) | Low | $10-18k | | 4 | Post-Purchase Thank You Page | Can't customize or monetize default page | $2M | 2 apps (expensive) | Low | $8-14k | | 5 | Wholesale Portal (B2B) | Multi-currency pricing requires custom dev | $2.5M | 2 apps (clunky) | Medium | $12-20k | | 6 | Returns/RMA Management | Manual returns, no tracking, high fraud | $2.2M | 3 apps (weak) | Low | $10-16k | | 7 | Carbon Offset Integration | Sustainability badge, manual integration | $1.2M | <1 real app | Low | $4-8k | | 8 | Inventory Forecasting (ML) | Sync exists but no predictive analytics | $1.8M | 1-2 apps | Medium | $8-14k | ### Tier 2 Ideas | 9 | Micro-Influencer Management | Can't find/track micro-influencers | $1.5M | 1-2 apps | Medium | $5-10k | | 10 | Dynamic Pricing Engine | Demand-based/seasonal pricing too complex | $1.8M | 2-3 apps | Medium | $8-12k | | 11 | CRO Suite (Heatmaps + Session Replay) | Lacks native heatmaps, session replay | $1.6M | 1-2 apps | High | $9-15k | | 12 | Customer Segmentation + Auto-Email | Can't segment by behavior without Klaviyo | $1.7M | 2-3 apps | Low | $10-16k | | 13 | Warranty + Protection Plans | No easy warranty solution, manual tracking | $1.3M | 2-3 apps | Low | $6-12k | | 14 | Multi-Vendor Marketplace (Native) | Shopify lacks native multi-vendor | $1.5M | 2-3 apps | High | $12-20k | | 15 | Social Proof + UGC Widget | Reviews owned by Judge.me; UGC manual | $1.4M | 3-4 apps | Medium | $8-14k | ### Tier 3 Ideas | 16 | Dropshipping Supplier Comparison | Manual comparison, no ROI tracking | $800k | <1 app | Low | $4-8k | | 17 | Size Guide + Fit Predictor | 25% returns due to sizing | $900k | 1-2 apps | High | $3-7k | | 18 | Real-Time Inventory Sync (Advanced) | High-volume sellers have sync lag | $750k | 4-5 apps | Medium | $5-10k | | 19 | Geotargeted Offers + Local Fulfillment | Multi-location sellers lack local logic | $650k | 1-2 apps | Low | $2-5k | | 20 | Affiliate Network (Shopify Native) | Can't run affiliate programs natively | $1M | 1-2 apps | Medium | $6-12k | --- ## Validation Steps BEFORE Writing Code **Do all of these before committing 4 weeks of time:** ### 1. Five Merchant Interviews (Required) - Cold email 20 store owners in the niche (find from forums, YouTube, Twitter) - Target: Stores with $100k-$5M revenue (ideal customer profile) - Script: "I'm researching [problem]. Can I ask you 5 questions about how you solve this today?" - Questions: 1. "How do you currently solve [problem]?" 2. "How much time do you spend on this per week?" 3. "What's broken about existing solutions?" 4. "Would you pay $X/month to solve this? Why/why not?" 5. "Would you be willing to try a beta version?" - Scoring: If 4/5 say "yes" to willingness-to-pay, you have demand signal ### 2. Landing Page + Waitlist Test (30-day) - Build simple landing page (Webflow, no-code tool) - Title: [Problem] + Benefit in headline - Description: 3-4 paragraphs explaining solution - CTA: "Join the waitlist" (free, no credit card) - Drive traffic: Reddit, Twitter, Shopify Community (organic only, no paid ads yet) - **Goal:** 100 signups in 30 days (indicates strong demand) - If you hit 50+ signups, problem is real enough ### 3. Search Volume Test (Organic demand) - Use Google Trends + SEO tools (Ahrefs free tier) - Search: "Shopify [problem]" + "[solution] Shopify" - Monthly search volume target: 500+ searches/month globally indicates real demand - If <200 searches/month, problem may be too niche ### 4. Pre-Sale Test (Highest confidence) - Email waitlist: "We're launching in 4 weeks. First 50 customers get 50% lifetime discount." - Pricing: $19/month (test tier) - Goal: 5-10 commitments before building = confirmed demand signal - If 0 pre-sales, kill idea and move to next ### 5. MVP Scope (4-week build target) - Cut ruthlessly: One painful workflow only - NO: Multi-language, fancy UI, all edge cases - YES: Core problem solved, works for 80% of use cases - Example MVP: "Dropshipping reconciliation" = dashboard showing all orders, sync status, tracking - Build in Ruby on Rails, Django, or Next.js (fastest iteration) --- ## Red Flags (Kill These Ideas Immediately) 1. **5+ apps dominating with 4.5+ rating each** - Signal: Market is won, differentiation is hard - Action: Move to different niche 2. **Ideas requiring deep ML infrastructure for one founder** - Signal: Not solo-buildable in 4 weeks - Examples: Demand forecasting (complex), fraud detection (requires data science) - Action: Find simpler angle or team up 3. **Anything Shopify is likely to build natively next quarter** - Signal: Ask in Shopify forums, check roadmap - Example: If Shopify is hiring "bundle pricing engineer," bundles are coming native - Action: Avoid it; Shopify will crush you 4. **Ideas requiring 3+ third-party API integrations to work** - Signal: High maintenance burden, more support tickets - Example: Micro-influencer discovery (needs Instagram API + TikTok API + email integration) - Action: Simplify to single-API or avoid 5. **Low willingness-to-pay in interviews** - Signal: Merchants won't pay, even if problem is real - Action: Abandon or pivot pricing model 6. **Merchant churn >10% per month in similar apps** - Signal: Product-market fit issues in category - Action: Research why churn is high before entering 7. **Requires exclusive partnerships to work** - Signal: High friction, months to negotiate - Example: App that requires Fulfillment Network partnership - Action: Find standalone angle --- ## Output Format for Claude When asked for app ideas, return this table format: ``` # Recommended Shopify App Ideas (Ranked by Viability) | Idea | Core Problem | Merchant Pain | TAM | Competition | Build Complexity | First-Mover Advantage | Willingness-to-Pay | Est. MRR (Y1) | |---|---|---|---|---|---|---|---|---| | [Name] | [What's broken?] | [% merchants affected] | $[M] | [Count] apps | [Low/Med/High] | [High/Med/Low] | [$X/month] | $[K-K] | ``` **Example row:** | Dropshipping Order Sync | Sellers track orders across 5 suppliers manually | 60% of dropshippers | $2.5M | 3 weak competitors | Low | High (first-to-polish wins) | $25-49/mo | $8-15k | --- ## Decision Tree: Should You Build This? ``` START: You have an app idea or problem in mind ↓ Q1: Is there <4 real competitors all with 4.5+ rating? YES → Continue NO → ⛔ STOP: Market is won Q2: Can you validate demand in <7 days via interviews + landing page? YES (4/5 interviews + 50+ signups) → Continue NO → ⛔ STOP: Problem not urgent enough Q3: Can you build MVP in 4 weeks solo? YES → Continue NO → ⛔ STOP: Too complex, simplify Q4: Would you personally pay $X/month to solve this? YES → Continue NO → ⛔ STOP: You don't believe in solution Q5: Is Shopify unlikely to build this natively? YES → BUILD IT NO → ⛔ STOP: Shopify will eat your lunch ``` --- ## Niche Finder Tools & Resources **App Store Search Hacks:** - Search Shopify App Store by category, sort by "Recently Added" - Check last app added in category—if >6 months old, category is stale - Read 1-star reviews on top 3 competitors (goldmine of pain signals) **Demand Signal Monitoring:** - Set Google Alerts: "Shopify [problem]" (daily digest) - Monitor r/shopify for problem keywords (weekly) - Track Shopify Community for spike in questions **Competition Analysis:** - App Store reviews: Look for "I switched from [app X] because..." - Reasons they switched = feature gap or pricing issue **TAM Estimation:** - Shopify has ~5.5M stores globally - Subset your niche: "Dropshippers" = ~800k stores (15% of total) - If app costs $29/month and penetrates 5%, that's 40k stores × $29 = $1.16M MRR ceiling --- ## Key Metrics to Track During Validation - **Interview conversion:** (Interviews willing to try beta) / 5 = Your demand score - **Waitlist growth:** Signups per day × 30 = Monthly reach (target: 100+) - **Search volume:** Monthly searches for "[problem] Shopify" (target: 500+) - **Pre-sales conversion:** Customers who pay before launch (target: 5-10) - **Category saturation:** Number of real competitors (target: <4)
app-performance29.9 KB
---
name: app-performance
description: "Use when optimizing Shopify embedded app performance — LCP, INP, CLS, TTI, cold-start, App Bridge init, Polaris bundle slimming, large GraphQL costs, Remix defer/streaming/prefetch, image lazy-loading, server cache + CDN, Prisma connection pooling, webhook handler latency, and meeting Built for Shopify performance gates. Triggers: 'app slow', 'embedded app performance', 'LCP shopify app', 'INP shopify', 'app bundle too big', 'polaris bundle slim', 'remix defer', 'prefetch intent', 'shopify app lighthouse', 'shopify performance budget', 'built for shopify performance', 'image lazy load shopify', 'graphql query cost', 'n+1 prisma'."
---
# Shopify Embedded App Performance
Fix slow Shopify embedded apps before Built for Shopify rejects them, merchants uninstall, and Shopify demotes you in App Store search. The Web Vitals are the gate. Everything below is how you walk through it.
---
## 1. When to use this skill
Trigger this skill when:
- `shopify app dev` runs but the embedded iframe takes 4+ seconds to render
- BFS review comes back "fails performance" with no specifics
- Lighthouse on your admin route scores below 70
- Web Vitals API reports LCP > 2.5s, INP > 200ms, or CLS > 0.1 over 28 days
- Your bundle is > 500KB gzipped and you don't know why
- GraphQL queries are returning `THROTTLED` (as 200 OK, not 429)
- Prisma queries take 800ms+ in production but 40ms locally
- Webhook handlers are taking > 2 seconds and triggering Shopify retries
- A merchant DMs you "your app is the slowest thing in my admin"
- You're about to submit for Built for Shopify and want to pass on the first try
Don't use this skill for storefront / theme performance — that's a different problem (theme app extensions, web pixels, checkout extensions). This skill is about the admin embedded surface and the server behind it.
---
## 2. Built for Shopify performance thresholds (current)
These are the exact numbers. Memorize them.
### Core Web Vitals (admin, measured via App Bridge Web Vitals API)
| Metric | Threshold | Measurement window | Min sample size |
|--------|-----------|--------------------|-----------------|
| **LCP** (Largest Contentful Paint) | ≤ 2.5s (p75) | 28 days | 100 calls |
| **INP** (Interaction to Next Paint) | ≤ 200ms (p75) | 28 days | 100 calls |
| **CLS** (Cumulative Layout Shift) | ≤ 0.1 (p75) | 28 days | 100 calls |
| **TTI** (Time to Interactive) | Aim ≤ 3.8s | Internal target | — |
p75 means 75% of measured launches must be at or under the threshold. One slow merchant doesn't sink you — the long tail does.
### Server / API performance
| Metric | Threshold |
|--------|-----------|
| **API p95 latency** | < 500ms |
| **API failure rate** | < 0.1% |
| **Min requests** (28 days) | 1000 (for checkout-adjacent apps) |
| **Webhook ACK** | < 5s before Shopify retries (target < 2s) |
### Storefront-side requirements (if you ship Theme App Extensions)
- Your app must not reduce the storefront Lighthouse performance score by more than **10 points**
- Per-surface storefront JS budget: **< 50KB gzipped** is the rule of thumb most pass on
### Iframe gotcha
Apps rendered in the Shopify admin run inside iframes. Lighthouse run against your raw URL is misleading — it doesn't model the App Bridge bootstrap, the parent admin chrome, or the cross-origin handshake. The only metric that matters for BFS is what the **Web Vitals API inside App Bridge** reports. Wire that up first or you're flying blind.
---
## 3. Where embedded app slowdowns come from
In order of how often they're the culprit:
### 3.1 Server cold start (40% of slow first-loads)
Your Remix server is on a serverless / scale-to-zero runtime (Fly Machines stopped, Vercel Lambda cold, Render free tier). First request from a merchant pays a 2-5 second cold-boot tax. Every metric is now blown.
### 3.2 App Bridge initialization
The App Bridge CDN script must load and the parent admin must complete the postMessage handshake before your iframe is "interactive." If you load App Bridge late (after your bundle), INP measurements include the gap.
### 3.3 Polaris bundle bloat
`import { Card } from '@shopify/polaris'` from Next.js or a tree-shake-confused bundler pulls in 800KB+ of unused components. Webpack/esbuild/Vite all have known cases where Polaris's barrel exports defeat tree-shaking.
### 3.4 Large or unbatched GraphQL fetches
Loader fires three GraphQL queries serially because you `await`'d them. Each is 200-400ms. You've blocked LCP by 800ms before render starts.
### 3.5 Prisma N+1
Loop over products, `prisma.metafield.findMany({ where: { productId }})` inside. 100 products × 30ms = 3 seconds. Server p95 dies.
### 3.6 Blocking webhook handlers
Webhook handler does HMAC verify, writes to DB, calls 2 third-party APIs, returns 200 after 7 seconds. Shopify already retried. Now you process the duplicate. Now you're throttled.
### 3.7 Unbounded images
`<img src={huge.png}>` with no `width`/`height`, no lazy loading, no Shopify Image CDN transform. CLS jumps the moment images load.
### 3.8 No HTTP caching
Every navigation refetches the same shop settings, the same plan info, the same merchant profile. Cache-Control header is missing, so the browser never reuses anything.
---
## 4. Server cold-start fix
### 4.1 Runtime choice
| Runtime | Cold start | When to pick |
|---------|------------|--------------|
| **Node on always-on VM** (Fly Machines `min_machines_running = 1`, Render Standard, Railway) | 0ms | Default. Cheapest path to consistent p95. |
| **Bun on always-on VM** | 0ms | Pick if you measured 30%+ faster on your specific Prisma/GraphQL workload. Not a magic bullet. |
| **Edge runtime** (Vercel Edge, Cloudflare Workers) | < 50ms | Pick only if you can live without Node-API libs (Prisma needs Hyperdrive/D1 driver). Great for read-heavy. |
| **Serverless cold-scale** (Vercel Lambda, AWS Lambda) | 1-5s | Avoid for embedded apps unless you have warm-ping infra. |
### 4.2 Warm pings
If you must run on scale-to-zero, hit your own healthcheck every 4 minutes. Cheap:
```ts
// pages/api/health.ts or app/routes/health.tsx
export const loader = () => new Response("ok", { status: 200 });
```
Then a cron (Vercel Cron, GitHub Actions schedule, UptimeRobot free tier) curls it every 4 minutes during expected business hours.
### 4.3 Platform specifics
- **Fly.io:** `min_machines_running = 1`, `auto_stop_machines = false` in `fly.toml`. The $4/mo for the tiniest always-on machine is cheaper than every losing 28-day BFS window.
- **Vercel:** prefer Pro and set `regions: ['iad1']` (or wherever Shopify's main traffic lands). Use Fluid Compute, set `maxDuration` low. Enable Edge Caching on loaders that return cacheable data.
- **Render:** Standard plan or higher. The Free tier sleeps. Don't ship to merchants on Free.
- **Heroku / Railway:** keep at least 1 worker dyno running; eco/hobby dynos sleep.
- **Shopify Oxygen (storefront):** not for embedded admin apps. Don't confuse the two.
---
## 5. Remix optimizations
### 5.1 `defer` and `Await` for non-critical data
Stop blocking the response on slow data. Stream it.
```tsx
// Bad — loader awaits every query, response blocks until all resolve
export async function loader({ request }) {
const { admin } = await authenticate.admin(request);
const shop = await admin.graphql(SHOP_QUERY).then(r => r.json());
const orders = await admin.graphql(ORDERS_QUERY).then(r => r.json()); // slow
const products = await admin.graphql(PRODUCTS_QUERY).then(r => r.json());
return json({ shop, orders, products });
}
// Good — only await what's needed for first paint, defer the rest
export async function loader({ request }) {
const { admin } = await authenticate.admin(request);
const shop = await admin.graphql(SHOP_QUERY).then(r => r.json()); // fast, needed for header
const orders = admin.graphql(ORDERS_QUERY).then(r => r.json()); // slow, not blocking
const products = admin.graphql(PRODUCTS_QUERY).then(r => r.json());
return defer({ shop, orders, products });
}
```
In the component:
```tsx
<Suspense fallback={<Skeleton />}>
<Await resolve={data.orders}>
{(orders) => <OrdersTable orders={orders} />}
</Await>
</Suspense>
```
LCP fires on the header that the first `await` produced. The orders table streams in after.
### 5.2 `prefetch="intent"` on every internal Link
```tsx
import { Link } from "@remix-run/react";
<Link to="/app/orders" prefetch="intent">Orders</Link>
```
When the merchant hovers (~500ms before they actually click), Remix fetches the route's JS, CSS, and loader data. By the time the click lands, the next page is already in cache. INP measurements drop because the click doesn't trigger a network round-trip.
Don't use `prefetch="render"` for everything — it floods the network on every page load. Use it only for the one obvious next step (e.g., a wizard's next button).
### 5.3 Route prefetching strategy
- `prefetch="intent"` — default for nav links and CTAs (98% of links)
- `prefetch="render"` — single "obvious next step" per page
- `prefetch="viewport"` — links far down a long list, prefetch when scrolled into view
- `prefetch="none"` — anything that points to a route the user will rarely click, or stale-sensitive data
### 5.4 Resource routes for data the client polls
If a chart polls every 30 seconds, don't re-render the whole route. Use a resource route (`/app/api/chart-data`) that returns JSON, fetch it from the client. Resource routes skip the React component tree and just run the loader.
### 5.5 Move heavy work to `clientLoader`
For non-PII data that doesn't need server context (e.g., a chart fed by a public API), use `clientLoader` so the work happens in the browser and the server loader returns instantly.
### 5.6 Cache loader responses
Set `Cache-Control` headers on loaders that return stable data:
```tsx
export function headers() {
return {
"Cache-Control": "private, max-age=60, stale-while-revalidate=600",
};
}
```
`private` because session-token-authed responses shouldn't be cached on shared CDNs. `stale-while-revalidate` lets the browser serve stale content while it refreshes in the background — near-zero perceived latency on the second navigation.
---
## 6. Polaris bundle slimming
Polaris is the biggest single bundle contributor in most embedded apps. Without care, it ships 800KB+ of gzipped JS.
### 6.1 Use deep imports when your bundler fails to tree-shake
```ts
// Bad — barrel import. Many bundlers pull the whole package.
import { Card, Button, Text } from "@shopify/polaris";
// Good — direct import. Forces tree-shake friendliness.
import Card from "@shopify/polaris/build/esm/components/Card";
import Button from "@shopify/polaris/build/esm/components/Button";
import Text from "@shopify/polaris/build/esm/components/Text";
```
Yes, it's ugly. It saves 200-400KB on Next.js 14/15 + React 18 where tree-shaking is known to fail. Check `npm run build` bundle stats before and after — if your bundler tree-shakes fine, keep the readable imports.
### 6.2 Lazy-load heavy components
Heavy components: `IndexTable`, `DataTable`, `ResourceList` with many rows, `Filters`, anything chart-related, `Modal` (sometimes), the entire Polaris Icons set.
```tsx
import { lazy, Suspense } from "react";
const HeavyTable = lazy(() => import("./HeavyTable"));
<Suspense fallback={<Skeleton />}>
<HeavyTable rows={rows} />
</Suspense>
```
### 6.3 Tree-shake icons
`@shopify/polaris-icons` is huge if imported as a barrel.
```ts
// Bad — pulls every icon
import * as Icons from "@shopify/polaris-icons";
// Good
import { CheckIcon, AlertIcon } from "@shopify/polaris-icons";
```
Or one level deeper if your bundler still misbehaves:
```ts
import CheckIcon from "@shopify/polaris-icons/dist/svg/CheckIcon";
```
### 6.4 CSS — exactly once at app root
Polaris CSS must be loaded exactly once, at the root. If you tree-shake aggressively, your bundler may drop the CSS-only side-effect.
```ts
// In root.tsx or _app.tsx
import "@shopify/polaris/build/esm/styles.css";
```
Add `@shopify/polaris/build/esm/styles.css` to `sideEffects` in `package.json` if your bundler is over-eager.
### 6.5 Frame and AppProvider — render once, never inside loops
`<AppProvider>` and `<Frame>` are expensive on mount. Mount them in the root layout, never re-create them per route.
### 6.6 Consider App Bridge web components
App Bridge v4 ships native web components (`<ui-modal>`, `<ui-title-bar>`, `<ui-nav-menu>`) that don't require React. For embedded apps, using these instead of their React wrappers shaves ~50KB and removes one re-render layer. The trade-off is they're imperatively controlled, not declaratively.
### 6.7 Audit bundle size
Run before every release:
```bash
# Vite
npx vite-bundle-visualizer
# Webpack
npx webpack-bundle-analyzer dist/stats.json
# Or generic
npx source-map-explorer dist/**/*.js
```
Find the top 5 contributors. Polaris should be < 200KB gzipped, your app code < 100KB gzipped, total initial route < 350KB gzipped.
---
## 7. GraphQL strategy
### 7.1 Batch what can be batched
Two related queries that don't depend on each other? One GraphQL document:
```graphql
query DashboardData {
shop { name myshopifyDomain }
products(first: 10) { edges { node { id title } } }
}
```
One round-trip instead of two. One throttle bucket charge.
### 7.2 Paginate small for embedded views
`first: 250` is cheap on simple queries (id, title) and expensive on nested ones (products → variants → metafields). For embedded admin views, fetch `first: 25` and paginate. Render fast, fetch the next page on `prefetch="intent"`.
### 7.3 Defer non-critical fields with `@defer`
Shopify supports the GraphQL `@defer` directive on some fields. Use it for heavy nested data:
```graphql
query {
product(id: $id) {
id
title
... @defer { metafields(first: 50) { edges { node { id value } } } }
}
}
```
Server streams the cheap fields first, then the deferred. Pairs nicely with Remix's `defer`.
### 7.4 Bulk operations for > 250 records
For exports, audits, "all products at once" workloads, never paginate. Use `bulkOperationRunQuery`:
```graphql
mutation {
bulkOperationRunQuery(query: """{ products { edges { node { id title } } } }""") {
bulkOperation { id status }
userErrors { field message }
}
}
```
Then poll `currentBulkOperation` until `status == COMPLETED`, download the JSONL. One bucket charge, async, runs in Shopify's infra.
### 7.5 Detect throttling (the 200-OK kind)
Throttled GraphQL responses arrive as HTTP 200 with `errors[*].extensions.code == "THROTTLED"`. Check every response:
```ts
const response = await admin.graphql(QUERY);
const body = await response.json();
if (body.errors?.some(e => e.extensions?.code === "THROTTLED")) {
const cost = body.extensions.cost.throttleStatus;
const waitMs = ((cost.requestedQueryCost - cost.currentlyAvailable) / cost.restoreRate) * 1000;
await new Promise(r => setTimeout(r, waitMs));
return retry();
}
```
Log `extensions.cost.actualQueryCost` and `currentlyAvailable` on every response. If you're consistently above 50% bucket usage, you have an N+1 GraphQL problem.
### 7.6 Query cost budget
| Plan | Bucket size | Restore rate |
|------|-------------|--------------|
| Standard | Read `maximumAvailable` | 100 points/sec |
| Advanced | Read `maximumAvailable` | 200 points/sec |
| Plus | Read `maximumAvailable` | 1,000 points/sec |
| Enterprise | Read `maximumAvailable` | 2,000 points/sec |
Your typical query should cost < 100 points. If a single query crosses 500, refactor before shipping.
---
## 8. Image strategy
### 8.1 Use the Shopify Image CDN with size transforms
Never link to the original image URL. Always request a sized variant:
```liquid
{{ product.featured_image | image_url: width: 600 }}
```
In a Remix app, GraphQL returns `image.url(transform: { maxWidth: 600 })`:
```graphql
image {
url(transform: { maxWidth: 600, preferredContentType: WEBP })
altText
width
height
}
```
### 8.2 Always set explicit `width` and `height`
CLS happens when the browser doesn't reserve space for an image and content shifts when it loads. Always:
```tsx
<img src={url} width={600} height={400} alt={altText} loading="lazy" />
```
Or via `aspect-ratio` CSS if dimensions are dynamic.
### 8.3 Lazy-load below-the-fold images
```tsx
<img loading="lazy" decoding="async" ... />
```
Native lazy-load is supported in every modern browser. Above-the-fold images stay `loading="eager"` so they count toward LCP.
### 8.4 Use modern formats
`preferredContentType: WEBP` (or `AVIF` where available) in the Shopify GraphQL image transform. 30-50% smaller than JPEG, no quality difference.
### 8.5 Preload the LCP image
If the merchant lands on a product page and the hero image is the LCP element, preload it:
```tsx
export const links = () => [
{ rel: "preload", as: "image", href: heroImageUrl },
];
```
LCP drops by 200-600ms depending on connection.
---
## 9. Caching
### 9.1 Server cache (HTTP)
On loader responses that are safe to cache:
```ts
export function headers() {
return {
"Cache-Control": "private, max-age=60, stale-while-revalidate=300",
"Vary": "Cookie",
};
}
```
- `private` — never cache on shared CDN; merchant data isn't safe to share
- `max-age=60` — browser cache for 60s
- `stale-while-revalidate=300` — serve stale for 5 minutes while refreshing in background
- `Vary: Cookie` — different merchants get different cached responses
For truly public data (your app's marketing page outside the embedded surface), use `public, max-age=3600, s-maxage=86400` and let the CDN cache it for a day.
### 9.2 CDN edge
If your platform supports edge caching (Vercel, Cloudflare), tag responses and purge on writes. Vercel `unstable_cache`, Cloudflare Cache API, or just `s-maxage` on responses that are tenant-scoped.
### 9.3 Browser localStorage for non-PII
The merchant's "preferred view" toggle, the "last sort order" they chose, UI state — `localStorage`. Never put PII, never put session tokens, never put shop credentials.
### 9.4 In-memory cache on the server
For data that's expensive to fetch and changes rarely (shop settings, app plan info, currency code), cache in process memory with a 60s TTL:
```ts
const cache = new Map<string, { value: any; expires: number }>();
export async function getShop(shop: string) {
const cached = cache.get(shop);
if (cached && cached.expires > Date.now()) return cached.value;
const value = await fetchShop(shop);
cache.set(shop, { value, expires: Date.now() + 60_000 });
return value;
}
```
If you have multiple server instances, this only helps per-instance. For shared cache, use Redis (Upstash, Vercel KV).
---
## 10. Database — Prisma
### 10.1 Connection pooling
Without pooling, every serverless function invocation opens a new Postgres connection. Your database runs out of connections fast.
Options:
- **Cloudflare Hyperdrive** — global Postgres connection pooler with TLS. Connect Prisma to the Hyperdrive endpoint, get pooled connections with edge latency.
- **PgBouncer** (managed) — Supabase, Neon, Railway all run PgBouncer in front of Postgres. Use the pooled connection string (`...pgbouncer=true&connection_limit=1`).
- **Prisma Accelerate** — Prisma's managed pooler + edge cache. Easiest if you don't want to think about it.
In `schema.prisma`:
```prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // pooled connection
directUrl = env("DIRECT_DATABASE_URL") // unpooled, for migrations
}
```
### 10.2 Fix N+1 with `include` or batched queries
```ts
// Bad — N+1
const products = await prisma.product.findMany();
for (const p of products) {
p.metafields = await prisma.metafield.findMany({ where: { productId: p.id } });
}
// Good — one query, joined
const products = await prisma.product.findMany({
include: { metafields: true },
});
```
Or for many-to-many with filters, use `findMany` with `where: { id: { in: ids } }` once, then group in JS.
### 10.3 Index your foreign keys and lookup columns
Every `where: { shop: "..." }` lookup needs an index on `shop`. Without it, your Postgres scans the whole table:
```prisma
model Session {
id String @id
shop String
data String
@@index([shop])
}
```
### 10.4 Use `select` to fetch only what you need
```ts
// Bad — loads every column including blobs
const session = await prisma.session.findUnique({ where: { id }});
// Good
const session = await prisma.session.findUnique({
where: { id },
select: { id: true, shop: true, accessToken: true },
});
```
### 10.5 Database location
Put the DB in the same region as the server. Cross-region Postgres adds 60-200ms per query. Two queries serially = LCP burnt.
---
## 11. Webhook handlers
### 11.1 Return 200 in < 2 seconds, always
Shopify retries webhooks after 5 seconds. If you do real work synchronously, you'll be retried, processed twice, throttled.
Pattern:
```ts
export async function action({ request }) {
const { topic, shop, payload } = await authenticate.webhook(request);
// Push to queue. Return immediately.
await queue.enqueue({ topic, shop, payload, webhookId: request.headers.get("X-Shopify-Webhook-Id") });
return new Response(null, { status: 200 });
}
```
### 11.2 Queue choices
- **Inngest** — easiest. Type-safe, retries, observability built in. Free tier covers most apps.
- **Trigger.dev** — similar, with longer-running jobs and a workflow visualizer.
- **BullMQ + Redis** — self-hosted, max control. Use if you already have Redis.
- **AWS SQS / Cloudflare Queues** — if you live in that ecosystem.
- **DB-backed queue** (`pg-boss`, `graphile-worker`) — fewest moving pieces if you already have Postgres.
The pattern is the same regardless: webhook handler does HMAC verify, persists the job, returns 200. A worker picks up the job.
### 11.3 Idempotency
Webhooks are at-least-once. Your handler will be called twice on the same event sometimes.
```ts
const webhookId = request.headers.get("X-Shopify-Webhook-Id");
const existing = await prisma.processedWebhook.findUnique({ where: { id: webhookId }});
if (existing) return new Response(null, { status: 200 });
await prisma.processedWebhook.create({ data: { id: webhookId, processedAt: new Date() }});
// process the work
```
### 11.4 HMAC verify on the raw body, not the parsed JSON
If you `await request.json()` before HMAC, Express/Remix may have mutated the body and your HMAC will fail. Use `authenticate.webhook(request)` from the Shopify Remix package — it does this correctly.
---
## 12. Measuring
### 12.1 Web Vitals API (the one BFS cares about)
Wire up the App Bridge Web Vitals API and send to your monitoring service:
```ts
import { shopify } from "@shopify/app-bridge-react";
shopify.webVitals.onReport((metric) => {
// metric.name: 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB'
// metric.value: number
// metric.attribution: detailed breakdown
sendToMonitoring(metric);
});
```
Without this wired up, BFS shows nothing and you can't improve what you don't measure.
### 12.2 Server monitoring
- **Sentry** — errors + performance traces. Free tier is generous.
- **Datadog** — pricier, deeper. APM, RUM, logs in one place.
- **Axiom** — log-first, cheap, great for serverless.
- **OpenTelemetry + your own backend** — for the curious.
Track p50, p95, p99 latency on every loader and action. Alert when p95 crosses 400ms (well before the BFS gate of 500ms).
### 12.3 Bundle size in CI
Add a bundle-size check that fails CI:
```json
"scripts": {
"size": "size-limit"
},
"size-limit": [
{ "path": "build/client/**/*.js", "limit": "350 KB" }
]
```
### 12.4 BFS audit tooling
Shopify's Partners dashboard shows your Web Vitals data after the 100-call minimum is hit. Check it weekly. If you see a regression, bisect against your deploys.
---
## 13. 25 performance rules (cheat sheet)
1. App Bridge CDN script in `<head>` before your bundle, always.
2. Polaris CSS imported exactly once at the root.
3. Deep-import Polaris components if your bundler tree-shakes badly.
4. Lazy-load any component over 50KB.
5. Lazy-load icons; deep-import from `@shopify/polaris-icons`.
6. Render `<AppProvider>` and `<Frame>` exactly once.
7. Loader awaits only what's needed for first paint; everything else is `defer`'d.
8. `prefetch="intent"` on every internal `<Link>`.
9. Cache-Control headers on every loader response (private, max-age, SWR).
10. Server runs on always-on infra (or warm pings) — no scale-to-zero for the embedded surface.
11. Server is in the same region as your DB.
12. DB connections pooled (Hyperdrive, PgBouncer, or Accelerate).
13. Every Prisma `where` column is indexed.
14. Prisma `select` only what you need; no SELECT *.
15. No N+1 — use `include` or batched `findMany`.
16. GraphQL queries batched where possible; `first: 25` for embedded views.
17. GraphQL responses checked for `extensions.code == "THROTTLED"` on every call.
18. Bulk operations for > 250 records, never paginate.
19. Images via Shopify Image CDN with `maxWidth` and `WEBP`.
20. Every `<img>` has explicit `width`, `height`, `loading="lazy"` (except LCP).
21. LCP image preloaded via `<link rel="preload">`.
22. Webhook handlers return 200 in < 2 seconds; heavy work in queue.
23. Webhook handlers idempotent via `X-Shopify-Webhook-Id`.
24. Web Vitals API wired to monitoring; check weekly.
25. Bundle size budget enforced in CI; current threshold 350KB gzipped initial route.
---
## 14. Decision tree
```
Embedded app is slow. Where do I start?
│
├── Is the FIRST load slow (cold)?
│ ├── Yes → Server cold start
│ │ Switch to always-on infra OR add warm pings
│ │ Verify with curl timing on cold vs warm: curl -w "%{time_total}\n" -o /dev/null -s URL
│ │
│ └── No → Continue
│
├── Is EVERY load slow?
│ ├── Yes → Likely bundle size or loader latency
│ │
│ │ Check bundle:
│ │ ├── Run vite-bundle-visualizer or webpack-bundle-analyzer
│ │ ├── If Polaris > 250KB gzipped → deep-import, lazy-load heavy components
│ │ ├── If icons > 100KB → deep-import per icon
│ │ └── If your app code > 200KB → split routes, lazy-load
│ │
│ │ Check loader:
│ │ ├── Log loader duration per route
│ │ ├── If > 400ms → look for serial awaits, switch to defer + Promise.all
│ │ ├── If > 1s → GraphQL or DB problem (sections below)
│ │ └── If GraphQL — check actualQueryCost in extensions.cost
│ │
│ └── No → Continue
│
├── Is NAVIGATION slow (clicking around)?
│ ├── Yes → Missing prefetch
│ │ Add prefetch="intent" to all internal Links
│ │ Add Cache-Control headers to stable loader responses
│ │
│ └── No → Continue
│
├── Is the APP unresponsive during interaction (high INP)?
│ ├── Yes → Main thread blocked
│ │ ├── Heavy synchronous work in event handlers — break into chunks or move to worker
│ │ ├── Re-rendering large lists on every keystroke — virtualize (react-virtual, IndexTable virtualization)
│ │ └── Large state updates — useDeferredValue or useTransition
│ │
│ └── No → Continue
│
├── Is the UI JUMPY (high CLS)?
│ ├── Yes → Layout shifts
│ │ ├── Images missing width/height
│ │ ├── Skeletons not matching final dimensions
│ │ ├── Fonts loading and re-flowing (use font-display: optional or swap with size-adjust)
│ │ └── Late-loading ads/embeds pushing content
│ │
│ └── No → Continue
│
├── Are SERVER metrics (p95) too high?
│ ├── DB queries slow?
│ │ ├── Check Prisma logs (DEBUG=prisma:query)
│ │ ├── Add indexes on every WHERE column
│ │ ├── Fix N+1 with include
│ │ └── Move DB to same region as server
│ │
│ ├── GraphQL slow?
│ │ ├── Check actualQueryCost — if > 100, prune fields
│ │ ├── Batch where possible
│ │ └── Switch to bulk operations for > 250 records
│ │
│ └── Cold start?
│ └── See top of tree
│
└── Are WEBHOOKS getting retried?
└── Handler taking > 2 seconds
├── HMAC verify + 200 OK first
├── Push work to queue
└── Idempotent on X-Shopify-Webhook-Id
```
---
## 15. Pre-submit performance checklist
Run through this before BFS submission:
- [ ] App Bridge Web Vitals API wired, sending to monitoring
- [ ] 100+ launches recorded over 28 days (let it run before submitting if new)
- [ ] LCP p75 ≤ 2.5s in Web Vitals dashboard
- [ ] INP p75 ≤ 200ms
- [ ] CLS p75 ≤ 0.1
- [ ] Server p95 latency ≤ 400ms (cushion below BFS 500ms)
- [ ] Server failure rate ≤ 0.05% (cushion below BFS 0.1%)
- [ ] Bundle size for initial route ≤ 350KB gzipped
- [ ] Polaris CSS imported exactly once at root
- [ ] No `<img>` without explicit `width` and `height`
- [ ] Images served from Shopify Image CDN with WEBP
- [ ] All internal `<Link>` use `prefetch="intent"`
- [ ] Loaders use `defer` for non-critical data
- [ ] Cache-Control headers on stable loader responses
- [ ] Server runs on always-on infra
- [ ] DB connection pooling enabled
- [ ] All Prisma WHERE columns indexed
- [ ] No N+1 in any loader (verified with Prisma query logs)
- [ ] GraphQL throttle detection in every request wrapper
- [ ] Webhook handlers return 200 in < 2s, queue heavy work
- [ ] Webhook handlers idempotent via `X-Shopify-Webhook-Id`
- [ ] Storefront extensions (if any) ≤ 50KB gzipped per surface
- [ ] Storefront Lighthouse delta ≤ 5 points
If all checked, submit. If any unchecked, fix first — BFS rejection on perf burns a 28-day re-measurement window you don't want to repeat.
---
## Closing principle
Performance in a Shopify embedded app is the sum of three latencies the merchant feels: **server**, **bundle**, **render**. Optimize all three. Most apps fix one and ignore the other two, then wonder why BFS still rejects them. Wire up Web Vitals first so you can see the truth — then the fixes above are a checklist, not a guess.
app-pricing-strategy35.7 KB
---
name: app-pricing-strategy
description: "Use when designing Shopify app pricing, choosing a pricing model (flat tiered / usage-based / freemium / hybrid), setting trial length (7/14/30 days), structuring 3-4 tier plans, sizing capped usage, raising prices on existing customers, or writing pricing copy for the App Store listing. Triggers: 'shopify app pricing', 'app pricing tiers', 'free trial length', 'usage based pricing', 'recurring vs one-time', 'capped pricing', 'pricing strategy', 'monetization', 'how should I price my shopify app', 'shopify app monetization', 'MUST_USE_BILLING', 'raise app prices'."
---
# Shopify App Pricing Strategy
## Frontmatter Triggers
- "How much should I charge for my app?"
- "What's the best pricing model for my Shopify app?"
- "Should I do free, freemium, or paid-only?"
- "How do I price a usage-based app?"
- "What's the right free trial length?"
- "How do competitors price similar apps?"
- "When and how should I raise my prices?"
- "How do I structure my pricing tiers?"
---
## The Four Proven Pricing Models
### 1. Flat Tiered (Most Common)
**When:** 90% of successful Shopify apps use this. Pick it unless you have a reason not to.
**How it works:**
- 3–4 fixed price points (e.g., Starter $9.99, Growth $29.99, Scale $99.99)
- Each tier unlocks fixed feature sets + user/order limits
- Revenue is completely predictable
- Easy to communicate
**Real Shopify examples:**
- **Klaviyo Email Marketing:** Free, $20, $50, $150, $500/mo (tier by contact count)
- **Gorgias Help Desk:** Free, $50, $150, $400, $1000/mo (tier by conversation volume)
- **Printful Print-on-Demand:** Free + rev-share, then flat fees by service tier
**Conversion lift by tier:**
- Starter tier: 40–60% of free trial users convert
- Growth tier: 15–25% of Starter converts to Growth
- Scale tier: 5–10% of Growth converts to Scale
**Shopify revenue share and fees:**
- Eligible standard-rate developers keep 100% of the first $1M in lifetime gross app revenue earned since January 1, 2025.
- Above $1M, Shopify's revenue share is 15% and the developer keeps 85% before other fees and taxes.
- All billing also has a separate 2.9% processing fee, applicable sales tax, and possible regional fees.
- Revenue is aggregated across apps and associated developer accounts. Very large developers have separate eligibility rules.
**Pro tip:** Design tiers so the "sweet spot" (Growth/Scale tier) is your profit engine. Starter should feel limited but functional. Scale should feel unreachable to most but desirable.
---
### 2. Usage-Based (For Data/Computation/API Heavy)
**When:** Your cost to serve grows with merchant activity (emails sent, API calls, storage, image uploads)
**How it works:**
- Base fee ($0–$50/mo) + overage charges (e.g., $0.01 per email sent)
- Usage counted: emails sent, API calls, MB stored, images processed, orders synced
- Revenue scales with merchant success
- Harder to forecast for merchants but fair
**Real Shopify examples:**
- **ReConvert Upsell:** Base $30/mo + $0.10 per order after 1,000/month
- **Shopify Email:** Free for Shopify merchants, then usage-based
- **Zapier:** Free tier (100 tasks/mo) + $29–$450 based on task volume
**Conversion data:**
- Usage-based apps see 25–35% free-to-paid conversion (higher because users feel they "only pay for what they use")
- But 40% higher churn because cost visibility is higher
**When NOT to use:** If your cost structure is fixed (e.g., you pay Shopify $500/mo for a data feed), don't pass overage costs to users—use flat tiers instead.
---
### 3. Freemium with Hard Cap (Most Common for Low-Price Apps)
**When:** You want to capture market share and convert via upsell (not friction at signup)
**How it works:**
- Free tier with strict limits (e.g., 100 emails/month, 1 user, basic features)
- Hard stop: users CANNOT use beyond the limit without upgrading
- Paid tiers unlock limits + features
- Psychology: users see the wall, then upgrade to remove friction
**Real Shopify examples:**
- **Mailchimp:** Free (500 contacts, email only) → Paid (unlimited, automation, SMS)
- **Inventory Planner:** Free (single store, limited SKUs) → Paid (multi-store, full features)
- **Oberlo Dropshipping:** Free (limited products) → Paid ($30+, unlimited)
**Conversion psychology:**
- 7-day free (no payment card required): 2–5% free-to-paid conversion
- 14-day free (payment card required): 5–10% conversion
- 30-day free (payment card required): 8–15% conversion
- Hard cap effect: When users hit the limit, 25–40% upgrade immediately
**Freemium math (critical):**
- Industry average: 1–3% of free users convert to paid
- To break even: You need 3–8% conversion (depends on CAC and LTV)
- Example: 10,000 free users × 5% = 500 paying customers needed to break even
---
### 4. Hybrid (Flat Base + Usage Overage)
**When:** You have baseline features (worth $50) and overages (user adds value)
**How it works:**
- Tiered base fee ($9.99, $49.99, $99.99) + additional usage charges
- Example: $49.99/mo includes 10,000 emails; additional emails $0.005 each
- Merchants pay for what they use, with a minimum floor
- Best of both worlds: predictability + scaling
**Real Shopify examples:**
- **Shopify Payments:** Fixed transaction fee % + flat monthly minimum
- **Subbly SMS:** $50 base + $0.005 per SMS after monthly allotment
- **Okendo Reviews:** Base $50 + per-review fees ($0.02–$0.10)
**When to use:** Your Starter tier hits 30% of users, but half exceed the limits within 3 months. Add overage pricing so they don't churn—they upgrade or pay more.
---
## Pricing Sweet Spots & Industry Benchmarks
| Price Point | Best For | Conversion Lift | Monthly Volume | Use Case |
|---|---|---|---|---|
| $9.99 | Starter/Freemium upgrade | +40–60% from free | 30–100 apps/tier | Lightweight tools, niche automation |
| $19.99 | SMB/solopreneur | +50% from $9.99 | 50–150 apps/tier | Email, basic automation, integrations |
| $29.99 | Growth/mid-market | +25–35% from $19.99 | 100–300 apps/tier | Heavy hitters (inventory, analytics) |
| $49.99 | Growth+ | +15–25% from $29.99 | 200–400 apps/tier | Agency tools, multi-user |
| $99.99 | Scale/enterprise-lite | +8–15% from $49.99 | 100–200 apps/tier | Data-heavy, API-driven, custom integration |
| $149.99+ | Enterprise | +3–8% from $99.99 | 50–100 apps/tier | Custom features, dedicated support |
**Key insight:** Shopify stores follow a power law. 70% of merchants are solopreneurs/SMBs (target $9.99–$29.99). 20% are growing brands ($29.99–$99.99). 10% are large (negotiate custom pricing).
---
## Free Trial Length: Psychology & Conversion Data
### 7-Day Free Trial
**Conversion rate:** 8–12% of free users convert to paid
**Psychology:** "Short window, decide fast" → urgency without overwhelming
**When to use:** Simple tools (discount tools, basic automation). Merchants see value fast or not at all.
**Risk:** High churn in first 30 days post-conversion
### 14-Day Free Trial (Shopify's default)
**Conversion rate:** 12–18% of free users convert
**Psychology:** "Just right" window to set up, test, see ROI
**When to use:** Most apps. Default for good reason.
**Data:** Shopify's top 100 apps average 13-day trial
### 30-Day Free Trial
**Conversion rate:** 15–25% of free users convert
**Psychology:** "I have time to explore" → deeper feature discovery → higher intent to keep
**When to use:** Complex tools (inventory management, analytics, integrations). Merchants need 2–3 weeks to see clear ROI.
**Risk:** Higher refund requests if they cancel mid-trial month; plan for churn messaging
### No Time Limit (Freemium)
**Conversion rate:** 1–5% (much lower than timed trials)
**Psychology:** "No deadline" → procrastination → lower conversion
**When to use:** Only if you have hard limits (storage, features). Forces conversion by friction, not urgency.
**Pro tip:** Pair your trial length with onboarding emails.
- Day 1: "Welcome, here's your setup checklist" (3 quick wins)
- Day 5: "You saved 2 hours! Here's what's next"
- Day 10 (14-day trial): "Trial ends in 4 days. Convert to keep your setup"
- Day 7 (7-day trial): "Trial ends tomorrow. Quick link to upgrade"
---
## Tiered Tier Design Principles
### The 3–4 Tier Sweet Spot
- **Why 3 tiers:** Good–Better–Best. Simple to compare. Avoids decision paralysis.
- **Why 4 tiers:** Add a "Pro" or "Enterprise" tier if your user base spans solopreneurs + agencies.
- **Why NOT 5+:** Decision paralysis; comparison gets hard; support burden increases.
**Example (Email Marketing):**
```
FREE → Test drive (100 contacts, 1 automation)
STARTER → Single store (5,000 contacts, 10 automations) — $9.99
GROWTH → Scale up (50,000 contacts, unlimited automations) — $29.99
SCALE → Multi-store or API (unlimited, custom integrations) — $99.99
```
### Anchor Tier Strategy
The middle tier is your profit engine. Anchor it at "sweet spot" price.
- **Starter** (bottom): Feels limited but functional. Converts 40–60% of free trial users.
- **Growth** (middle, the ANCHOR): 60–70% of paying customers land here. Highest LTV.
- **Scale** (top): Only 10–15% of customers, but each pays 3–5× Starter.
**Price gap math:**
- Free → Starter: 2–3× price jump (e.g., Free → $9.99)
- Starter → Growth: 2–3× jump (e.g., $9.99 → $29.99)
- Growth → Scale: 2–4× jump (e.g., $29.99 → $99.99)
If gaps are smaller (e.g., $9.99, $14.99, $19.99), customers compare too hard and upgrade less.
### The Decoy Tier (Advanced)
Add a 4th tier priced between Growth and Scale to make Scale look cheaper.
```
GROWTH → $29.99/mo (50 emails/mo)
PRO → $79.99/mo (500 emails/mo) ← DECOY: Rarely chosen
SCALE → $99.99/mo (unlimited) ← Looks "close" in price to Pro, but unlimited
```
Result: More customers choose Scale over Growth because the jump from Growth ($29.99) to Scale ($99.99) feels smaller when Pro ($79.99) is visible.
### Feature Differentiation (Not Just Limits)
Limits alone create friction. Differentiate by feature too:
```
STARTER → Dashboard, bulk email, basic reports
GROWTH → ^ + Automation, SMS, advanced segmentation
SCALE → ^ + API, webhooks, custom integration, priority support
```
Users at Scale feel they get real *capabilities*, not just higher numbers.
---
## Billing Models: Per-Store, Per-User, Per-Order, Per-Item
### Per-Store (Most Common)
**How:** One price per merchant, regardless of how they use it
**When:** 95% of Shopify apps. Default unless you have usage variance
**Example:** $29.99/month for access on one store
**Pros:** Simple billing, predictable, no disputes
**Cons:** Agencies with 50 stores pay 50× as much (negotiate wholesale)
### Per-User (SaaS-style)
**How:** Price per team member who logs in
**When:** Collaboration tools (Slack-style), team workflow apps
**Example:** $29.99 base + $9.99 per additional user
**Pros:** Scales with team; users feel usage is tied to value
**Cons:** Confusing for Shopify; requires user invite tracking
**Shopify support:** Manual setup; not native in app billing API
### Per-Order
**How:** Charge per transaction processed
**When:** Conversion apps (upsell, bundles), payment processors
**Example:** $0.10 per order after 1,000/month
**Pros:** Perfectly aligned with merchant ROI
**Cons:** Merchants feel nickeled-and-dimed; requires trust
**Shopify support:** Native in billing API (recurring + one-time charges)
### Per-Item
**How:** Charge per product, SKU, or entity
**When:** Catalog management, listing syncing, inventory
**Example:** $0.05 per product per month (300 products = $15/mo)
**Pros:** Scales with business size
**Cons:** Very confusing; support nightmare (what's a "product"?)
**Shopify support:** Possible but clunky; not recommended
**Recommendation:** Start with per-store. It's simple, Shopify understands it, and you can iterate later.
---
## Freemium & Usage-Based Math: Breaking Even
### The Funnel
```
1,000 free signups
↓ (5% convert in first 7 days)
50 paying customers
↓ (avg: $30/mo)
$1,500/month gross revenue
↓ (subtract the currently applicable revenue share, processing fees, taxes, refunds, and cost to serve)
Net payout and contribution margin
```
### Break-Even Calculation
**Assume:**
- CAC (cost to acquire a free trial user): $2 per signup
- Free trial conversion: 5%
- Avg. paying customer lifetime: 10 months
- Avg. monthly price per customer: $30
**Math:**
- LTV = $30 × 10 months × (1 − churn) = $300
- CAC per paying customer = $2 ÷ 0.05 = $40
- LTV:CAC ratio = 300:40 = 7.5:1 ✓ (healthy)
If your LTV:CAC is below 3:1, you're not sustainable.
### Freemium Conversion Targets
Industry baseline: **1–3% free-to-paid conversion**
To break even on freemium: **Need 3–8% conversion (depending on support costs)**
If you get 2% conversion, you're subsidizing free users with paid users. Raise prices or add hard limits.
---
## Price Ladder: The Upsell Path
Design your tiers so customers naturally upgrade.
### Email Marketing Example
```
Month 1: User signs up for FREE tier
(100 contacts, 1 automation, basic reports)
Month 2: User has grown to 500 contacts
→ Free tier HITS LIMIT
→ Email: "You've grown! Upgrade to keep growing"
→ Upgrade path: Click → Pay → More limits unlocked
Month 4: User runs first major campaign, sees $5K revenue lift
→ Email: "You've sent 15,000 emails this month. Scale faster with advanced segmentation"
→ Upgrade path: STARTER ($9.99) → GROWTH ($29.99)
Month 8: User wants API access for custom integration
→ In-app prompt: "Unlock API + webhooks with Scale tier"
→ Upgrade path: GROWTH → SCALE ($99.99)
```
**Key principle:** Upgrade moments happen when:
1. User hits a limit (pain point)
2. User sees ROI (willingness to pay)
3. User wants a feature only in higher tier (clear next step)
**Don't wait for annual renewal to upgrade. Make upsells available in-app, any time.**
---
## Currency & International Pricing
### USD Baseline
Design all pricing in USD first. It's the Shopify standard.
### Multi-Currency Strategy
If you support EUR, GBP, CAD, AUD, etc.:
**Option 1: Fixed exchange (Manual)**
- $9.99 USD = €9.99 EUR (don't do direct 1:1; lose money on spreads)
- $9.99 USD = €8.99 EUR (apply 10% buffer for payment processor fees)
- Update quarterly
**Option 2: Automatic (Stripe/Shopify billing)**
- Stripe's multi-currency handles real-time conversion
- Shopify's billing API does too
- Cost: 1–2% markup per Stripe
**Price by region (if high volume):**
- USD: $9.99, $29.99, $99.99
- EUR: €8.99, €26.99, €89.99 (roughly 10% lower to account for VAT)
- GBP: £7.99, £24.99, £79.99
- AUD: $14.99, $44.99, $149.99
**Tax considerations:**
- EU: You're responsible for VAT (if revenue > €50K/year). Shopify/Stripe can handle this.
- USA: Only if you have nexus in that state (Shopify handles on its platform)
- Rest of world: Varies; use Shopify's tax app
---
## Revenue Share & Payouts
For developers eligible for Shopify's standard rates:
- First $1M in qualifying lifetime gross app revenue earned since January 1, 2025: 0% revenue share
- Above $1M: 15% revenue share
- Separate charges: 2.9% processing fee, applicable sales tax, and possible regional regulatory fees
- Aggregation: cumulative revenue across all apps and associated developer accounts
Verify the current policy before using these figures in a forecast. Large-developer eligibility rules can remove the $1M exemption.
### Payment Schedule
- Payouts: Monthly, 7 days after end of month
- Method: ACH (USA), wire (international)
- Minimum payout: $1 (but Shopify holds until $100 if desired)
### Example Year 1 Projection
```
Jan: $500 revenue → $425 payout
Feb: $1,200 revenue → $1,020 payout
...
Dec: $8,000 revenue → $6,800 payout
TOTAL Year 1: $45,000 gross revenue → calculate payout from the developer's actual revenue-share tier, processing fees, taxes, refunds, and regional fees
```
---
## When & How to Raise Prices
### Strategy 1: Grandfathering (Best for retention)
"Existing customers keep current price forever; new customers pay new price"
**When to use:** You want to reward early adopters and build loyalty
**Retention impact:** 90%+ of grandfathered customers stay
**Implementation:** Tag customers by signup date; lock their price in code
**Example:**
```
March 2025: Launch app at $29.99 (Growth tier)
June 2025: Raise to $39.99 (increase value first: add features)
New customers: $39.99
Customers before June: Keep $29.99 forever
```
**Pro tip:** Grandfather for year 1. After year 1, you can force upgrades (see Strategy 3).
### Strategy 2: Announcement + Opt-Out (Moderate retention risk)
"Announce 30–60 days before price increase; customers can cancel to avoid it"
**When to use:** You're confident in value; expect 5–15% churn
**Message:** "We've added X new features. To sustain development, we're raising prices."
**Example email:**
```
Subject: [App Name] Price Increase on August 1
Hi [Customer],
Over 6 months, we've added API access, webhooks, and multi-store support.
To continue building, we're raising prices on August 1:
Your current plan: $29.99/mo
Your new price: $39.99/mo
You have until July 31 to cancel if you prefer. No judgment.
New features coming next month: Custom integrations, priority support.
```
**Expected churn:** 5–15% (best case: 10%)
**Expected revenue gain:** 70–80% of customer base × price increase
### Strategy 3: Force Increase (Nuclear option)
"Customers must upgrade or downgrade; old price tier no longer exists"
**When to use:** Only after 2+ years of grandfathering, or if you're pivoting pricing model entirely
**Retention impact:** 20–40% churn (expect it)
**When it's acceptable:** You've already signaled the change; loyalty has been tested
**Example:**
```
February 2027: Announce in-app banner
"Pricing changes March 1. Current grandfathered price expires Feb 28."
March 1: Old price tier disappears
Customers choose: upgrade to new tier or cancel
~30% keep paying (slightly higher); ~10% downgrade; ~10% cancel
```
---
## Pricing Copy: What to Say (And What NOT to Say)
### Pricing Page Formula
```
[HEADLINE]
"Simple, transparent pricing"
(not: "Enterprise-grade pricing for enterprise-grade businesses" — vague)
[SUBHEADING]
"Pick the plan that fits your growth. Upgrade anytime."
(not: "Our pricing is industry-leading" — prove it)
[TIER COMPARISON TABLE]
Columns: Feature | Starter | Growth | Scale
Rows: [5–8 key features]
[CTA Button]
"Start free trial" (for free trials)
"Upgrade now" (for paid-only)
NOT: "Buy now" (too transactional; "Upgrade" feels forward)
[FAQ Section]
Q: Can I change plans anytime?
A: Yes. Upgrade or downgrade instantly. Proration happens automatically.
Q: Do you offer annual discounts?
A: [Answer honestly. If no, say "We price monthly for flexibility."]
Q: What if I outgrow the highest tier?
A: Email us. We offer custom pricing for high-volume users.
```
### Top Pricing Copy Wins
- **"What you get vs. what you pay"** → feature clarity wins conversions
- **"Upgrade anytime"** → removes commitment anxiety
- **"30-day money-back guarantee"** → if true, state it (reverses risk)
- **"No credit card required"** → trust signal (if true)
- **"Join 5,000+ stores"** → social proof
- **"Cancel anytime, no questions"** → fear reduction
### What NOT to Say
- **"Enterprise pricing available"** (vague; redirects to email form; kills conversions)
- **"Contact us for a demo"** (friction; for B2B, not B2C SaaS)
- **"Pay annually, save 20%"** (only if you actually offer it; confuses free trial users)
- **"Advanced features available at higher tiers"** (too vague; show which ones)
- **"Scalable pricing"** (everyone says this; prove it with examples)
### CTA Button Copy
- ✓ "Start free trial"
- ✓ "Unlock [benefit]"
- ✓ "Upgrade to [tier name]"
- ✗ "Buy"
- ✗ "Purchase plan"
- ✗ "Subscribe now" (old-school SaaS language)
---
## Pricing Decision Tree: By App Category
### Analytics Apps (Insights, reporting, dashboards)
→ **Model:** Flat tiered or usage-based (depends on API heavy-ness)
→ **Sweet spot:** $19.99 (basic), $49.99 (advanced), $99.99+ (custom)
→ **Trial length:** 14 days (needs time to see data patterns)
→ **Tier differentiator:** Report types, data retention, API access, real-time data
**Example:** Littledata (Google Analytics for Shopify)
- Free: Basic events, 30-day data
- Growth: Custom events, 1-year retention, integrations
- Scale: API, webhooks, dedicated support
### Automation Apps (Email, SMS, tasks, workflows)
→ **Model:** Flat tiered (simple) or hybrid (usage overage)
→ **Sweet spot:** $9.99 (basic), $29.99 (growth), $99.99 (scale)
→ **Trial length:** 7–14 days (quick ROI visible)
→ **Tier differentiator:** Automation count, contact limits, integrations, support
**Example:** Zapier for Shopify
- Free: 100 tasks/month, basic automations
- Growth: 5,000 tasks/month, advanced logic
- Scale: Unlimited, custom code, API
### Email/SMS Marketing
→ **Model:** Freemium with hard cap (converts better)
→ **Sweet spot:** Free, $9.99, $29.99, $99.99
→ **Trial length:** 14 days
→ **Tier differentiator:** Contact limit, SMS yes/no, automation, segmentation
**Example:** Klaviyo (template)
- Free: 500 contacts, email only, basic reports
- $20: 5K contacts, SMS, some automation
- $50: 25K contacts, unlimited automation
- $150+: Dedicated success, custom integrations
### Inventory & Fulfillment
→ **Model:** Flat tiered (support-heavy; usage-based leads to disputes)
→ **Sweet spot:** $29.99, $49.99, $99.99
→ **Trial length:** 14–30 days (complex, needs learning)
→ **Tier differentiator:** Warehouse count, SKU limit, integration count, fulfillment features
**Example:** Shippo
- Starter: Single warehouse, 1,000 SKUs
- Growth: Multi-warehouse, unlimited SKUs, integrations
- Scale: Dedicated account manager, API
### Design Tools (Mockups, banners, design automation)
→ **Model:** Freemium with hard cap (design = creative, low friction)
→ **Sweet spot:** Free, $9.99, $29.99, $99.99
→ **Trial length:** 7–14 days
→ **Tier differentiator:** Template access, design tool advanced features, watermark removal, exports
**Example:** Canva for Shopify
- Free: Basic templates, watermark
- Pro: No watermark, brand kit, advanced features
### Integrations & Data Syncing
→ **Model:** Hybrid (fixed base + usage overage)
→ **Sweet spot:** $49.99 base + overages
→ **Trial length:** 14 days (need to sync data)
→ **Tier differentiator:** Sync frequency, record limit, data warehouse access, API
**Example:** Zapier sync
- Base: $49.99 + $0.10 per record synced after 10K/month
### Compliance & Legal (Taxes, privacy, returns)
→ **Model:** Flat tiered (support burden is high; don't make it worse with usage)
→ **Sweet spot:** $49.99, $99.99, $199.99+ (people pay for peace of mind)
→ **Trial length:** 14–30 days (need to test with real data)
→ **Tier differentiator:** Feature access, compliance levels, support tier
**Example:** TaxJar
- Basic: Sales tax calculation
- Plus: Tax filing, compliance reporting
- Enterprise: Custom integrations, dedicated support
### Marketplace/Dropshipping
→ **Model:** Flat tiered + usage overage (scales with store growth)
→ **Sweet spot:** Free/freemium, $19.99, $49.99, $99.99+
→ **Trial length:** 7–14 days (quick wins visible)
→ **Tier differentiator:** Product import limit, sync frequency, features
**Example:** Oberlo
- Free: Limited imports, basic features
- Starter: Unlimited imports, basic integrations
- Growth: Advanced features, priority support
---
## Pre-Launch Pricing Checklist
- [ ] **Pricing model chosen:** Flat tiered / Usage-based / Freemium / Hybrid (circle one)
- [ ] **Tier count decided:** 3 or 4 tiers (not 2, not 5+)
- [ ] **Price points set:** Validated against competitor set (3+ similar apps benchmarked)
- [ ] **Feature differentiation clear:** Not just numbers; each tier has unique features
- [ ] **Free trial length chosen:** 7 / 14 / 30 days (with justification)
- [ ] **Billing model confirmed:** Per-store / Per-user / Per-order / Per-item (circle one)
- [ ] **Copy written:** Pricing page, FAQ, tier descriptions, CTA tested
- [ ] **Payment method tested:** Shopify billing API integration working, test payment processed
- [ ] **Churn projections done:** Expected churn rate calculated for year 1
- [ ] **CAC/LTV calculated:** Life-time value > 3× CAC (break-even math confirmed)
- [ ] **Refund policy drafted:** Clear 30-day money-back guarantee (if offered)
- [ ] **Objection responses written:** "Can I downgrade?", "When do I get charged?", "Can I cancel?", etc.
- [ ] **Upgrade path designed:** In-app upsell moments mapped out
- [ ] **A/B test plan drafted:** Which price will you test first? (e.g., $19.99 vs. $29.99)
- [ ] **Currency handling confirmed:** USD only, or multi-currency? (Stripe set up)
- [ ] **Tax handling clarified:** VAT, sales tax (if needed), Shopify's role confirmed
- [ ] **Support email template ready:** Refund requests, trial extension, upgrade questions
- [ ] **Pricing page analytics enabled:** Track views, clicks-to-upgrade, abandonment
---
## Tools & Resources
| Tool | Use | Cost |
|---|---|---|
| **Shopify Billing API** | Recurring charge integration, plan management | Native (included) |
| **Stripe** | Payment processing, multi-currency, invoicing | 2.9% + $0.30 per transaction |
| **ProfitWell** | Churn analysis, MRR tracking, competitor benchmarking | Free/Paid |
| **Paddle** | Alternative payment processor (handles taxes, global) | 8% + revenue share alternative |
| **Pricetag.io** | Pricing page builder (templates) | $99–$199/mo |
| **Competera** | Price intelligence, competitor tracking | Custom |
| **SurveyMonkey** | WTP (Willingness To Pay) surveys | $25–$100/mo |
| **Notion** | Pricing doc & decision tracker (template) | Free |
**Most important:** Shopify Billing API + Stripe. That's all you need to start.
---
## Output Format: Pricing Strategy
When asked to create a pricing strategy for an app, return:
1. **Pricing Model** (1–2 sentences)
- Which of the 4 models? Why?
2. **Tier Structure** (table)
- Column: Feature | Starter | Growth | Scale
- Row 1–5: Key features, limits, includes
- Row 6: Monthly price
3. **Free Trial Details**
- Length (days)
- Payment card required? (Yes/No)
- Why this length?
4. **Pricing Page Copy** (headline + 2–3 bullets)
- Main headline
- Tier descriptions
- Key CTA
5. **First-Year Revenue Projection** (table)
- Column: Month | Free Signups | Conversion Rate | Paying Customers | Revenue (before Shopify cut)
6. **Objection Responses** (3–5 Q&A pairs)
- "Can I change plans anytime?"
- "What if I outgrow my tier?"
- "Do you offer annual discounts?"
- [2 more specific to the app]
---
## Fill-in-the-Blank Pricing Strategy Template
### APP OVERVIEW
**App name:** [Your app name]
**Category:** [Analytics / Automation / Email / Inventory / Design / Integration / Compliance / Marketplace]
**Target merchant type:** [Solopreneur / SMB / Growth-stage / Enterprise]
**Unique value prop:** [What problem does it solve in 1 sentence?]
### PRICING MODEL
**Model chosen:** [Flat tiered / Usage-based / Freemium / Hybrid]
**Rationale:** [Why this model? 1–2 sentences]
### TIER STRUCTURE
| Feature | Starter | Growth | Scale |
|---|---|---|---|
| [Feature 1 name] | [Starter limit] | [Growth limit] | [Scale limit] |
| [Feature 2 name] | [Starter limit] | [Growth limit] | [Scale limit] |
| [Feature 3 name] | [Starter limit] | [Growth limit] | [Scale limit] |
| [Feature 4 name] | [Starter limit] | [Growth limit] | [Scale limit] |
| [Feature 5 name] | [Starter limit] | [Growth limit] | [Scale limit] |
| **Monthly Price** | **$[XX.99]** | **$[XX.99]** | **$[XX.99]** |
### FREE TRIAL
**Trial length:** [7 / 14 / 30] days
**Payment card required?** [Yes / No]
**Rationale:** [Why this length for your merchant type? 2–3 sentences]
### PRICING PAGE COPY
**Headline:** [Main headline, 5–10 words]
**Subheading:** [Supporting text, 1 sentence]
**Starter tier:** [1 sentence benefit]
**Growth tier:** [1 sentence benefit, highlight why most choose this]
**Scale tier:** [1 sentence benefit, who chooses this?]
**CTA button:** [Button text]
### FIRST-YEAR FINANCIAL PROJECTION
| Month | Free Signups | Trial-to-Paid Conversion | Paying Customers | Avg. Price | Gross Revenue | Estimated Net Payout |
|---|---|---|---|---|---|---|
| Jan | [#] | [%] | [#] | $[XX] | $[XXX] | $[XXX] |
| Feb | [#] | [%] | [#] | $[XX] | $[XXX] | $[XXX] |
| Mar | [#] | [%] | [#] | $[XX] | $[XXX] | $[XXX] |
| Apr | [#] | [%] | [#] | $[XX] | $[XXX] | $[XXX] |
| May | [#] | [%] | [#] | $[XX] | $[XXX] | $[XXX] |
| Jun | [#] | [%] | [#] | $[XX] | $[XXX] | $[XXX] |
| Jul | [#] | [%] | [#] | $[XX] | $[XXX] | $[XXX] |
| Aug | [#] | [%] | [#] | $[XX] | $[XXX] | $[XXX] |
| Sep | [#] | [%] | [#] | $[XX] | $[XXX] | $[XXX] |
| Oct | [#] | [%] | [#] | $[XX] | $[XXX] | $[XXX] |
| Nov | [#] | [%] | [#] | $[XX] | $[XXX] | $[XXX] |
| Dec | [#] | [%] | [#] | $[XX] | $[XXX] | $[XXX] |
| **TOTAL YEAR 1** | | | | | **$[XXXX]** | **$[XXXX]** |
For every net-payout cell, state the assumed revenue-share tier and subtract processing fees, taxes, refunds, regional fees, and cost to serve separately.
### OBJECTION RESPONSES
**Q: Can I change plans anytime?**
A: [Your answer. Be generous; "Yes, anytime with no penalty" wins trust.]
**Q: What if I outgrow the highest tier?**
A: [Email support? Custom pricing? API only?]
**Q: Do you offer annual discounts?**
A: [Honest answer. If yes: "[X]% off annual". If no: "We price monthly for flexibility."]
**Q: [App-specific objection 1]**
A: [Your answer]
**Q: [App-specific objection 2]**
A: [Your answer]
---
## Example: PrintFlow (Dropshipping Assistant App)
### APP OVERVIEW
**App name:** PrintFlow
**Category:** Marketplace / Dropshipping
**Target merchant type:** New dropshippers (0–6 months), growth-stage stores
**Unique value prop:** Automatic product syncing + fulfillment automation for print-on-demand suppliers (Printful, Gooten, Merch by Amazon)
### PRICING MODEL
**Model chosen:** Freemium with hard cap + premium tiers
**Rationale:** Dropshippers are price-sensitive; free tier with hard limits captures market share. Premium tiers unlock automation (the core value). This mirrors Oberlo's proven success.
### TIER STRUCTURE
| Feature | Free | Starter | Growth | Scale |
|---|---|---|---|---|
| Synced products | 50 | 500 | 5,000 | Unlimited |
| Suppliers supported | 1 | 3 | Unlimited | Unlimited |
| Auto-sync frequency | Manual | Daily | Real-time | Real-time |
| Bulk operations | No | Yes (100) | Yes (1,000) | Unlimited |
| API access | No | No | No | Yes |
| Priority support | No | No | Yes | Yes |
| **Monthly Price** | **Free** | **$9.99** | **$29.99** | **$99.99** |
### FREE TRIAL
**Trial length:** 14 days (paid tiers start with $0.99 trial charge to filter serious users)
**Payment card required?** Yes
**Rationale:** Dropshipping requires quick action; 14 days is enough to sync first supplier, import 100+ products, and see the time savings. $0.99 barrier filters out tire-kickers; trial converts 12–18% in this category.
### PRICING PAGE COPY
**Headline:** "Sell print-on-demand products without lifting a finger"
**Subheading:** "Sync products, manage orders, automate fulfillment in minutes. No coding."
**Free tier:** Dip your toes in with 50 products from one supplier. Perfect for testing.
**Starter tier:** Sync 500 products from 3 suppliers and unlock daily auto-sync. Ready to scale.
**Growth tier:** Real-time syncing, bulk operations, and dedicated support for stores doing $10K–$100K/month.
**Scale tier:** API access for agencies managing 50+ stores or custom integrations.
**CTA button:** "Start 14-day free trial"
### FIRST-YEAR FINANCIAL PROJECTION
| Month | Free Signups | Trial-to-Paid Conversion | Paying Customers | Avg. Price | Gross Revenue |
|---|---|---|---|---|---|
| Jan | 200 | 12% | 24 | $18.50 | $444 |
| Feb | 300 | 13% | 39 | $19.00 | $741 |
| Mar | 400 | 14% | 56 | $22.50 | $1,260 |
| Apr | 500 | 15% | 75 | $24.00 | $1,800 |
| May | 600 | 15% | 90 | $25.00 | $2,250 |
| Jun | 700 | 16% | 112 | $27.00 | $3,024 |
| Jul | 800 | 16% | 128 | $29.00 | $3,712 |
| Aug | 900 | 17% | 153 | $31.00 | $4,743 |
| Sep | 1,000 | 17% | 170 | $32.50 | $5,525 |
| Oct | 1,200 | 18% | 216 | $35.00 | $7,560 |
| Nov | 1,500 | 18% | 270 | $38.00 | $10,260 |
| Dec | 2,000 | 18% | 360 | $40.00 | $14,400 |
| **TOTAL YEAR 1** | **10,700** | **16% avg** | **1,593** | **$29.00 avg** | **$55,719** |
Calculate net payout separately using the current revenue-share tier, processing fee, taxes, refunds, regional fees, and cost to serve.
**Key assumptions:**
- CAC (free signup): Organic + Product Hunt = $0 (assume free launch)
- Free signups ramp 200→2,000/mo (viral coefficient 1.2)
- Conversion ramps 12%→18% as product improves and reviews accumulate
- Avg. price rises as customers upgrade from Starter ($9.99) to Growth ($29.99) over their lifetime
- Churn: 5% per month (typical for dropshipping tools)
- Repeat customer LTV: 10 months × $29/mo = $290
**Year 2 projection:** Add upsell revenue (API tier) + gross up to 2,500 paying customers = $95K revenue, $76K net.
### OBJECTION RESPONSES
**Q: Can I change plans anytime?**
A: Yes. Upgrade or downgrade instantly. If you downgrade mid-month, we'll credit the difference to your next invoice. No penalties.
**Q: What if I outgrow the Growth tier (5,000 products)?**
A: Email us at support@printflow.app. We offer custom pricing for agencies and high-volume stores. Most Scale tier customers are at 15K–50K products.
**Q: Do you offer annual discounts?**
A: Not yet, but you can lock in monthly pricing anytime. We're evaluating annual discounts for 2026 based on customer demand.
**Q: Is there a limit to how many suppliers I can connect?**
A: Unlimited on Growth + Scale tiers. Free tier is 1 supplier; Starter is 3. Each supplier takes 2 minutes to authorize.
**Q: What happens if Printful or Gooten shuts down their API?**
A: We monitor supplier APIs closely. If a supplier goes down, we'll notify you immediately and help migrate to an alternative (e.g., Merch by Amazon, Teespring). Your data stays yours.
---
## Decision Tree: When to Raise Prices
```
START: Considering a price increase?
1. Are you 6+ months past last price increase?
NO → Wait 6 months. Price too fresh.
YES ↓
2. Have you added 2+ new features since last price?
NO → Add features first, then raise.
YES ↓
3. Is your LTV:CAC ratio > 3:1?
NO → Focus on retention/costs first.
YES ↓
4. Is your churn < 7% per month?
NO → Fix product quality first; raising price on bad product backfires.
YES ↓
5. Do 70%+ of customers stay month-to-month (not annual)?
YES → Announce 30–60 days before increase. Expect 10–15% churn.
NO → Grandfather existing customers; new customers pay new price. Minimal churn.
6. Is your average plan the "Growth" tier (your profit engine)?
NO → Avoid raising prices on low-tier customers.
YES ↓
7. Are you communicating value (customer success stories, new features) every month?
NO → Start content marketing first; then raise prices.
YES ↓
RESULT: Ready to raise.
Tactic:
- Month 1: Announce increase in blog post, email, in-app banner. "Here's why." (transparency wins)
- Month 2: Offer grandfathering or force-increase (see previous section).
- Month 3: New price goes live.
- Watch churn. If > 20%, you raised too much; consider seasonal discount or feature bundle.
```
---
## Final Pricing Reality Check
Before you launch, ask yourself these 5 questions:
1. **Can a merchant see ROI in 14 days?** (If no, your trial is too short or your onboarding sucks.)
2. **Would you pay your own price?** (If no, it's too high. Revisit value.)
3. **Do you understand why each tier exists?** (If not, you have too many tiers.)
4. **Can you explain pricing to a confused merchant in 1 sentence?** (If not, it's too complex.)
5. **Are you comfortable with 10% churn in year 1?** (If not, your prices are misaligned with value.)
If you can answer all 5 yes, launch. Then measure, iterate, and raise.
app-validation17.5 KB
---
name: app-validation
description: "Validate a Shopify app idea before writing code. Runs a 7-question pre-build framework, facilitates 5-merchant interview protocol, guides landing page + waitlist test, and identifies kill criteria early. Outputs a prioritization matrix and yes/no decision. Triggered on: 'validate app idea', 'should I build this app', 'MVP scope', 'app validation framework', 'problem-solution fit', 'customer interview', 'is my shopify app idea good', 'validate my idea'"
---
## When to Use This Skill
Call this when:
1. **You have an app idea** and need confidence before 4 weeks of build time
2. **You want to avoid false starts** with market validation first
3. **You need to prioritize** which app idea to build if you have multiple
4. **You're mid-build** and getting customer feedback signals
**Do NOT use this if:**
- You've already shipped and have paying customers (use metrics instead)
- You're in execution mode and decided to build (stay focused)
---
## The 7-Question Pre-Build Validation Framework
Answer all 7 questions with your best estimate. Score each 1-5 (5 = strong yes, 1 = strong no).
### Question 1: Problem Severity (Does it hurt bad enough?)
"On a scale of 1-5, how much pain do merchants have right now?"
- **5:** Merchants spend 5+ hours/week on this; lose money if unsolved; manual workaround fails 50%+ of the time
- **4:** Spend 2-5 hours/week; workaround exists but tedious; minor money loss
- **3:** Spend <2 hours/week; annoying but manageable workaround
- **2:** Nice-to-have improvement; low time cost
- **1:** Merchants don't really complain about this
**Validation method:** Ask 5 merchants "How many hours/week does [problem] cost you?" If 4/5 say >2 hours, you're at 4-5.
**Red flag:** If score <3, problem is aspirational, not acute. Merchants won't pay.
---
### Question 2: Willingness-to-Pay Signal (Will they actually pay?)
"Would a typical merchant in your target market pay $X/month for this?"
- **5:** Multiple merchants have said "Yes, I'd pay $50+/month for this"
- **4:** 3+ merchants said "Maybe $25-30/month" unprompted
- **3:** Merchants said "Maybe I'd try it if free" or "Could work at $10/month"
- **2:** "I'd use it if free but wouldn't pay"
- **1:** "Not worth paying for"
**Validation method:** Ask in interviews: "What's the maximum you'd pay monthly for this? What's minimum to make it worth your time?" If 4/5 say >$15, score 4-5.
**Red flag:** If score <3, problem is real but not monetizable.
---
### Question 3: Retention Plausibility (Will they stick around?)
"If merchants start using this, will they stay for 6+ months?"
- **5:** Solution is sticky by design (required for operations every day)
- **4:** Daily/weekly usage; low switching cost to alternatives
- **3:** Bi-weekly usage; some switching cost
- **2:** Monthly usage; high switching cost (easy to stop)
- **1:** One-time use; merchants will abandon after task completes
**Validation method:** Ask: "Once you solve this problem, will you need to keep using it weekly?" If yes, score 4-5. If it's a one-time setup, score 1-2.
**Red flag:** If score <3, churn will be >30%/month, killing unit economics.
---
### Question 4: Build Complexity vs Founder Skill (Can you actually ship?)
"How complex is this to build relative to your experience?"
- **5:** You've built 3+ similar apps; <4 weeks for MVP
- **4:** Similar to past projects; 4-6 weeks with learning
- **3:** Requires 1-2 new technologies; 6-8 weeks
- **2:** Requires deep learning in unfamiliar domain; 8-12 weeks
- **1:** Requires ML/distributed systems/real-time data processing; >12 weeks
**Validation method:** Sketch MVP: "What are the 3 core features?" If all are API-integration or dashboard, score 4-5. If you need to learn new framework, score 3. If it requires ML, score 1-2.
**Red flag:** If score <3 and you're solo, you won't finish in 90 days. Adjust scope or team up.
---
### Question 5: Distribution Path (How will they find you?)
"Do you have a distribution channel to reach 100+ merchants in 90 days?"
- **5:** Existing audience (email list 500+, Twitter 2k+ followers, partnership ready)
- **4:** Can reach niche community easily (Facebook group, subreddit, forums with active presence)
- **3:** Can cold-email 50+ prospects per week; willing to do manual outreach
- **2:** App Store search only; no warm channel
- **1:** No plan to reach customers; hoping App Store ranking appears
**Validation method:** List 5 specific ways you'll reach first 100 customers. If 3+ are warm (email, partnerships, existing audience), score 4-5. If all cold, score 2-3.
**Red flag:** If score <3, you'll take 6+ months to hit $1k MRR. Okay if you have runway, risky if bootstrapping.
---
### Question 6: Defensibility (Will competitors copy you?)
"How easy is this for a competitor to replicate in 3 months?"
- **5:** Network effects, data moat, or exclusive partnership (hard to copy)
- **4:** Requires specific domain expertise or customer relationships
- **3:** Process/product differentiation; smart execution wins
- **2:** Copyable but slower (competitor could match in 4-6 months)
- **1:** Trivially copyable; first-mover advantage only lasts 2-3 months
**Validation method:** Could Shopify build this natively? Could Klaviyo or Recharge add it as a feature? If yes, score 1-2. If requires specialized knowledge, score 4-5.
**Red flag:** If score <2 and you're bootstrapping, be prepared to sell or partner before a big competitor enters.
---
### Question 7: Gross Margin Math (Does the unit economics work?)
"Will you make money on each customer, accounting for payment processing, support, hosting?"
- **5:** Pricing >$50/month; COGS <20%; unit margins 70%+
- **4:** Pricing $25-50/month; COGS 20-30%; unit margins 50-70%
- **3:** Pricing $10-25/month; COGS 30-40%; unit margins 40-50%
- **2:** Pricing <$10/month or COGS >40%; unit margins <40%
- **1:** Pricing <$5/month or will require heavy support; unprofitable
**Validation method:**
- Payment processing: 3.5% (Stripe)
- Hosting/database: Estimate per customer
- Support: $1-5/month per customer
- Margins = (Price - COGS) / Price
- Rule: If <40% margin, you need 10k+ customers to be sustainable. Risky.
**Red flag:** If score <3, you're building a lifestyle business (not a venture). Know what you're signing up for.
---
## The 5-Merchant Interview Protocol
### Interview Script (20 minutes)
**Intro (2 min):**
"Hi [Name]. Thanks for 20 minutes. I'm researching how [niche] merchants solve [problem]. I'm not selling anything—just learning. Honest feedback helps me most."
**Problem Understanding (5 min):**
1. "How do you currently solve [problem]?"
2. "What's frustrating about your current approach?" (Listen for pain signals)
3. "How much time do you spend on this per week?"
**Willingness-to-Pay (5 min):**
4. "If an app solved this, how much would you pay monthly?" (Don't anchor; let them answer)
5. "What features would it need to be worth that price?" (Listen for must-haves)
**Retention Signal (4 min):**
6. "Would you need this app indefinitely, or is it one-time?" (Score retention here)
7. "Would you switch from [current solution] if this was 50% cheaper?" (Switching costs)
**Outro (4 min):**
8. "Can I send you a waitlist link in 2 weeks? I'll give early customers 50% off first year." (Pre-sale signal)
9. "Who else should I talk to in your niche?" (Warm intros > cold email)
### Interview Scoring Rubric
| Signal | Strong (5) | Moderate (3) | Weak (1) |
|---|---|---|---|
| Problem severity | Spends 5+ hrs/week; lost money | 2-4 hrs/week | <1 hr/week |
| Current solution fit | "It's broken, I hate it" | "Works but tedious" | "I'm fine with it" |
| Willingness-to-pay | "I'd pay $50/month" | "Maybe $20-25" | "Only if free" |
| Stated features needed | 2-3 specific, detailed | 1-2 generic features | Vague answers |
| Retention likelihood | "I'd need this forever" | "Maybe 6-12 months" | "One-time fix" |
| Switching cost | "Pain to change now" | "Could switch if easy" | "No switching cost" |
| Pre-sale signal | "Yes, send waitlist" | "Maybe, depends on price" | "Not interested" |
**Scoring:** Sum scores for all interviews. Target: 28+/35 (80%+) to proceed with build.
---
## Landing Page + Waitlist Test
### The Template (Webflow, no-code, <1 day to build)
```
# Headline: [Problem Result] in [Timeframe]
[Subheading: Benefit statement]
## The Problem
[2-3 sentences: How merchants currently lose money/time]
## The Solution
[Your app name]: [What it does]
### What You Get:
- Feature 1: [What it does + benefit]
- Feature 2: [What it does + benefit]
- Feature 3: [What it does + benefit]
## FAQ
**Q: How long does onboarding take?**
A: <5 minutes. Install, connect, start.
**Q: Can I try it free?**
A: Yes. [30-day free trial] with full features.
**Q: What if I don't like it?**
A: Cancel anytime. No questions asked.
## Join the Waitlist
Early access: 50% lifetime discount + priority support
[Email input + "Notify Me" CTA]
Launching: [Date 4 weeks from now]
```
### Waitlist Test Success Metrics
- **Goal:** 100 signups in 30 days (indicates strong demand)
- **Minimum:** 50 signups (problem is real, but demand is moderate)
- **Red flag:** <25 signups (either problem is unknown or niche is tiny)
### How to Drive Traffic (Organic)
1. **Reddit r/shopify** (2-3 posts per week)
- Share specific problem you're solving
- Never hard-sell; let conversation emerge
- Goal: 5-10 signups per post
2. **Shopify Community Forums** (1-2 relevant thread responses)
- Add to existing threads about the problem
- Link to waitlist only if on-topic
- Goal: 3-5 signups per thread
3. **Twitter** (Daily, if you're active)
- Share merchant pain points
- Ask for feedback on solution direction
- Link to waitlist in bio
- Goal: 2-3 signups per day if you have audience
4. **Cold email** (Optional, high-touch)
- 20 relevant store owners per week
- Subject: "Question for you: [problem]?"
- Goal: 10-20% reply rate, 5-10% signup rate
### Conversion Math
- 500 landing page visits → 50 signups = 10% conversion (strong)
- 500 visits → 25 signups = 5% conversion (okay)
- 500 visits → <10 signups = 2% conversion (rethink value prop)
---
## Pre-Sale Test (Highest Confidence Signal)
**Email to waitlist after 2 weeks:**
Subject: "50% lifetime discount ends soon [Your App Name]"
Body:
"We're launching [App Name] in 2 weeks. I'm offering the first 50 waitlist members 50% off forever as a thank you.
[Problem]: [2-sentence description]
[Solution]: [2-sentence description]
Pricing: $29/month ($14.50 with this offer)
[Link to pre-order/purchase]
No refunds needed—we'll set up your trial first."
### Pre-Sale Scoring
- **5+** customers commit = Demand is confirmed. Build it.
- **2-4** customers commit = Demand is moderate. Adjust positioning or pricing.
- **0-1** customers commit = Problem doesn't justify price. Kill idea or pivot.
---
## MVP Scope Definition (4-Week Target)
### Cut Ruthlessly
**REMOVE from MVP:**
- Multi-language support
- Admin dashboard analytics
- Webhooks to 5+ platforms
- Advanced permission systems
- Mobile app (web-responsive okay)
- Email notifications (basic only)
- API documentation
- Custom integrations
**KEEP for MVP:**
- Core workflow: 1 painful step, fully solved
- Core integrations: 1-2 essential APIs only
- Authentication: Shopify OAuth only
- Database: Simple schema, no complex queries
- UX: Functional, not beautiful
- Support: Email only
### Example MVP Scope: Dropshipping Order Reconciliation
**What to build:**
1. Dashboard showing all orders from connected supplier APIs (3 integrations: AliExpress, Shopee, Supplier 1)
2. Order status sync (manual refresh + 12-hour auto-sync)
3. Tracking number capture + auto-update to Shopify
4. Export orders as CSV
**What NOT to build:**
- Margin calculator
- Supplier rating system
- Batch order management
- Real-time webhooks
- Mobile app
- Advanced analytics
**Realistic 4-week timeline:**
- Week 1: Shopify OAuth + database schema + UI layout
- Week 2: AliExpress API integration
- Week 3: Status sync + tracking capture
- Week 4: Testing + Shopify review submission
---
## Kill Criteria: When to Abandon
**Stop if ANY of these are true:**
1. **Interview Score <20/35**
- Fewer than 3/5 merchants willing to pay at target price
- Action: Pivot to different problem or niche
2. **Waitlist Score <25 signups in 30 days**
- Insufficient demand signal
- Action: Either rethink marketing or kill idea
3. **Pre-Sale Score 0 commits in 2 weeks**
- Merchants won't pre-pay even with discount
- Action: Abandon or find different angle
4. **Build complexity >8 weeks solo**
- You can't ship in 90-day window
- Action: Simplify MVP or team up
5. **Gross margins <35%**
- Unit economics don't work at any reasonable scale
- Action: Raise prices or cut COGS; if neither possible, kill
6. **5+ competitors all with 4.5+ ratings**
- Market is won; differentiation is nearly impossible
- Action: Move to different niche
7. **Shopify is shipping this feature natively in Q2/Q3**
- You will be undercut before profitability
- Action: Kill and find different problem
8. **Churn will likely be >15%/month**
- Problem is nice-to-have, not must-have
- Action: Find stickier problem
---
## Prioritization Matrix (If You Have Multiple Ideas)
Score each idea 1-5 on each axis. Plot on matrix.
```
High Defensibility
^
|
LOW BUILD LOW BUILD HIGH BUILD HIGH BUILD
LOW DEMAND HIGH DEMAND LOW DEMAND HIGH DEMAND
|
__________|__________|__________
| | |
HIGH | [MAYBE] | [BUILD] |
| Build only | Build |
| if funded | first |
| | |
MARGIN|_____________________|___________|_________
| | |
LOW | [KILL] | [CONSIDER]|
| No point | If you |
| | have time |
|_____________________|___________|
[Build Complexity / Niche Size]
```
**Placement logic:**
- **High demand + Low build complexity + High defensibility** = BUILD FIRST
- **High demand + Low complexity + Low defensibility** = BUILD FAST (before copied)
- **High demand + High complexity + Low margins** = SKIP (unit economics fail)
- **Low demand** = KILL (waste of time)
---
## Output Format for Claude
When validating an idea, return this structure:
```
# App Idea Validation Report
## 7-Question Framework Scores
| Question | Score (1-5) | Evidence |
|---|---|---|
| Problem severity | [X] | [Quote from interviews / time spent] |
| Willingness-to-pay | [X] | [Price points mentioned] |
| Retention likelihood | [X] | [Usage frequency signal] |
| Build complexity vs skill | [X] | [Time estimate + experience] |
| Distribution path | [X] | [5 specific acquisition channels] |
| Defensibility | [X] | [Competitive advantage / moat] |
| Gross margin math | [X] | [Price - COGS calc] |
| **TOTAL SCORE** | **[X]/35** | **[% of max]** |
## Interview Summary
- **Merchants interviewed:** [Names/types]
- **Interview score:** [X/35]
- **Key pain signal:** [Most common complaint]
- **Willingness-to-pay:** $[Min]-$[Max]/month (median: $X)
- **Pre-sale commitment:** [X customers] at [Price]
## MVP Scope & Timeline
- **Core workflow:** [One sentence]
- **Essential integrations:** [1-2 APIs]
- **Realistic timeline:** [Weeks]
- **Build complexity vs your skill:** [Match/Gap]
## Recommendation
**[BUILD / PIVOT / KILL]**
**Reasoning:**
- [If BUILD] This scores [X/35]. Problem is acute ($[money]/week loss), merchants will pay $[price], and you can ship in 4 weeks. Proceed with MVP.
- [If PIVOT] Problem severity is low, but willingness-to-pay for angle [X] is stronger. Suggest pivoting to [alternative angle].
- [If KILL] [Reason: interview score / waitlist performance / build complexity / margin math / competition]. Recommend moving to idea [Next priority].
```
---
## Decision Tree: Should You Build This App?
```
START: You have an app idea
↓
Step 1: Run 5-question interviews with target merchants
Result: Interview score [X/35]
[Score >24] → Continue
[Score 15-24] → Consider pivoting problem angle
[Score <15] → KILL, move to next idea
↓
Step 2: Launch landing page + waitlist
Result: [X signups in 30 days]
[>50 signups] → Continue
[25-50 signups] → Proceed but expect slower traction
[<25 signups] → Rethink positioning or kill
↓
Step 3: Run pre-sale test
Result: [X customers pre-commit]
[>2 pre-sales] → BUILD
[0-1 pre-sales] → Either kill or pivot pricing/positioning
↓
Step 4: Validate MVP scope & timeline
Can you ship in 4 weeks? Build complexity vs skill match?
[YES] → BEGIN BUILD
[NO] → Simplify MVP or add co-founder
```
---
## Key Metrics Throughout Validation
- **Interview conversion:** (Willing to pay) / 5 merchants = demand %
- **Willingness-to-pay median:** Ask 5 merchants, find median price point
- **Waitlist conversion:** Signups / traffic = demand signal
- **Pre-sale conversion:** Pre-commits / waitlist = monetization confidence
- **Build time estimate:** Weeks to MVP (target: <4 weeks)
- **Churn prediction:** Daily/weekly/monthly usage = monthly retention rate
- **Gross margin:** (Price - COGS) / Price (target: >40%)
---
## Validation Timeline
**Week 1:** Recruit 5 merchants + run interviews
**Week 2:** Build landing page + start driving waitlist traffic
**Week 3:** Analyze waitlist conversion; if >50, launch pre-sale
**Week 4:** Decision: BUILD, PIVOT, or KILL
**Total validation time:** 4 weeks (before writing one line of code)
If validation passes all gates, you enter MVP build phase with 90+ days of runway.
b2b-markets33.4 KB
---
name: b2b-markets
description: "Use when building Shopify B2B catalogs, multi-storefront B2B, company location pricing, wholesale checkout, Shopify Markets (multi-region), multi-currency pricing with @inContext, currency formatting, market-specific catalogs, payment terms (NET 30/60), or anything involving B2B Plus / Markets / international commerce features. Triggers: B2B, wholesale, company, locations, customer accounts B2B, Markets, @inContext, country code, currency code, market, catalog, price list, payment terms, NET 30, draft order B2B, vaulted card, 'shopify b2b', 'shopify markets'."
---
# Shopify B2B + Markets
## When to use this skill
Call this skill when:
- Merchant is setting up wholesale, B2B, or bulk ordering
- Building company catalogs, price lists, or location-based pricing
- Implementing payment terms (NET 30, NET 60, etc.) or vaulted card checkout
- Handling Shopify Markets setup, multi-region, multi-currency, or multi-language storefronts
- Using @inContext directive for currency/language-specific Storefront API queries
- Displaying multi-currency prices in Liquid, handling exchange rates, or market-aware inventory
- Building customer account UI extensions for B2B contacts
- Webhook listeners for B2B events: company.created, companyLocation.created, companyContact.created, draftOrder.created
- Checking which features require B2B Plus plan vs Shopify Markets standard vs core Shopify
## B2B on Shopify: the fundamentals
Shopify B2B Plus enables merchants to:
- Create **companies** (e.g., "Acme Corp") with multiple **company locations** (warehouses, branches)
- Assign **catalogs** (product + pricing rules) to each location
- Set **price lists** with percentage or fixed discounts, currency-specific pricing, date ranges
- Define **payment terms** (NET 30, NET 60, custom) and enable **vaulted card** recurring payments
- Create **draft orders** for company contacts, convert to paid orders with payment terms
- Use **customer account B2B** login (company contact sign-in) with location-switching UI
**B2B Plus-only features:**
- Company object and CompanyLocation hierarchy
- PriceList and Catalog system
- PaymentTerms (NET 30+, custom days)
- DraftOrder for B2B checkout
- Customer Account B2B extensions (login UI, location switching)
**Core Shopify (all plans can do):**
- Multiple currencies (requires Shopify Markets)
- Shopify Markets regions, domains, subfolders
- @inContext for Storefront API (language/country override)
**When to recommend B2B Plus:**
- Merchant has >2 wholesale partners
- Needs tiered pricing by location or customer segment
- Wants self-service company portal (contact management, order history, payment vaults)
- Needs recurring NET-term invoicing (vaulted cards)
---
## Core B2B objects
### Company
A company record represents a wholesale customer (e.g., a distributor, retailer, or reseller).
```graphql
query {
companies(first: 10) {
edges {
node {
id # gid://shopify/Company/12345
name # "Acme Corp"
externalId # Sync ID to your CRM
metafields {
key
value
namespace
}
locations(first: 10) {
edges {
node {
id
name # "Acme - Chicago"
}
}
}
}
}
}
}
```
Mutations:
```graphql
mutation {
companyCreate(input: {
name: "Acme Corp"
externalId: "crm-12345"
}) {
company {
id
name
}
userErrors {
field
message
}
}
}
```
### CompanyLocation
Represents a specific address/warehouse for a company. Each location can have its own catalog + price list assignment.
```graphql
query {
companyLocationConnection(first: 5) {
edges {
node {
id
name
address {
address1
city
province
zip
country
}
contact {
title
lastName
firstName
email
phone
}
catalogs(first: 5) {
edges {
node {
id
title
}
}
}
}
}
}
}
```
Create a location:
```graphql
mutation {
companyLocationCreate(input: {
companyId: "gid://shopify/Company/12345"
name: "Acme - Chicago"
address: {
address1: "123 Main St"
city: "Chicago"
province: "IL"
zip: "60601"
country: "US"
}
}) {
companyLocation {
id
name
}
}
}
```
### CompanyContact
Represents a person at a company (authorized buyer, approver, etc.). They log in via Customer Account B2B to place orders.
```graphql
query {
companyContactConnection(first: 10) {
edges {
node {
id
email
firstName
lastName
role # ADMIN, BUYER, APPROVER
company {
id
name
}
companyLocations(first: 5) {
edges {
node {
id
name
}
}
}
}
}
}
}
```
Add a contact:
```graphql
mutation {
companyContactCreate(input: {
email: "buyer@acme.com"
firstName: "John"
lastName: "Doe"
companyId: "gid://shopify/Company/12345"
role: BUYER
}) {
companyContact {
id
email
}
}
}
```
### Catalog
A catalog is a collection of products available to a company location, with associated prices + discounts.
```graphql
query {
catalogs(first: 5) {
edges {
node {
id
title
description
publishedAt
status # ACTIVE, ARCHIVED, DRAFT
companyLocationCount
priceLists {
edges {
node {
id
name
}
}
}
}
}
}
}
```
Create a catalog:
```graphql
mutation {
catalogCreate(input: {
title: "Wholesale - Acme"
description: "Exclusive products and pricing for Acme Corp"
}) {
catalog {
id
title
}
}
}
```
Assign catalog to locations:
```graphql
mutation {
catalogPublish(input: {
catalogId: "gid://shopify/Catalog/999"
companyLocationIds: [
"gid://shopify/CompanyLocation/111"
"gid://shopify/CompanyLocation/222"
]
}) {
catalog {
id
publishedAt
}
}
}
```
### PriceList
Defines pricing rules for a catalog: fixed discounts, percentage reductions, or absolute prices. Currency-specific and date-range aware.
```graphql
query {
priceList(id: "gid://shopify/PriceList/555") {
id
name
currency # USD, CAD, EUR, etc.
parent {
__typename
}
prices(first: 10) {
edges {
node {
id
variantId: variantId
amount
isRelative # true = %, false = absolute
startsAt
endsAt
}
}
}
}
}
```
Create a price list:
```graphql
mutation {
priceListCreate(input: {
name: "10% Wholesale Discount - USD"
currency: USD
parent: {
catalogId: "gid://shopify/Catalog/999"
}
}) {
priceList {
id
name
}
}
}
```
Add prices to the list:
```graphql
mutation {
priceListPriceAddOrUpdate(input: {
priceListId: "gid://shopify/PriceList/555"
prices: [
{
variantId: "gid://shopify/ProductVariant/111"
amount: "-10"
isRelative: true # 10% discount
}
{
variantId: "gid://shopify/ProductVariant/222"
amount: "99.99"
isRelative: false # Absolute USD price
startsAt: "2026-06-01T00:00:00Z"
endsAt: "2026-06-30T23:59:59Z"
}
]
}) {
prices {
id
}
}
}
```
### PaymentTerms
Defines NET payment options (NET 30, NET 60, COD, etc.) available to a company.
```graphql
query {
paymentTerms(first: 5) {
edges {
node {
id
name # "NET 30"
translatedName
dueInDays: dueInDays # 30 for NET 30
standardTemplate
}
}
}
}
```
Assign payment terms to a company:
```graphql
mutation {
companyAssignPaymentTerms(input: {
companyId: "gid://shopify/Company/12345"
paymentTermsIds: [
"gid://shopify/PaymentTerms/NET_30"
"gid://shopify/PaymentTerms/NET_60"
]
}) {
company {
id
paymentTerms {
edges {
node {
id
name
}
}
}
}
}
}
```
### BuyerExperience
Defines checkout behavior (payment methods, vaulted cards, etc.) for B2B customers.
```graphql
query {
buyerExperience(companyLocationId: "gid://shopify/CompanyLocation/111") {
id
paymentMethods # Vaulted cards, NET terms enabled
checkoutTouchPoint
}
}
```
---
## Catalog + price list patterns
### Multi-location pricing
Assign the same catalog to multiple locations, each with its own price list (same products, different discounts per location):
```graphql
mutation {
# Create base catalog
catalogCreate(input: {
title: "Premium Products"
}) {
catalog {
id
}
}
}
# Then create 2 price lists (one per currency or region)
mutation {
priceListCreate(input: {
name: "Premium - USD (5% off)"
currency: USD
parent: { catalogId: "gid://shopify/Catalog/BASE" }
}) {
priceList { id }
}
}
mutation {
priceListCreate(input: {
name: "Premium - CAD (3% off)"
currency: CAD
parent: { catalogId: "gid://shopify/Catalog/BASE" }
}) {
priceList { id }
}
}
# Assign catalog, but each location sees its own currency
mutation {
catalogPublish(input: {
catalogId: "gid://shopify/Catalog/BASE"
companyLocationIds: [
"gid://shopify/CompanyLocation/US_HQ"
"gid://shopify/CompanyLocation/CA_HQ"
]
}) {
catalog { id }
}
}
```
**Key insight:** PriceList is currency-scoped. If a company location operates in CAD, query the CAD price list. The catalog is location-scoped but currency-agnostic; pricing is attached via the price list.
### Percentage + tiered discounts
Use `isRelative: true` for percentage, `isRelative: false` for absolute:
```graphql
mutation {
priceListPriceAddOrUpdate(input: {
priceListId: "gid://shopify/PriceList/555"
prices: [
# Tier 1: 5% off
{ variantId: "v111", amount: "-5", isRelative: true }
# Tier 2: 10% off (active June 1 - 30)
{
variantId: "v111"
amount: "-10"
isRelative: true
startsAt: "2026-06-01T00:00:00Z"
endsAt: "2026-06-30T23:59:59Z"
}
# Absolute wholesale price
{ variantId: "v222", amount: "49.99", isRelative: false }
]
}) {
prices { id }
}
}
```
---
## B2B mutations: draft orders & payment terms
### Create a draft order for a company contact
```graphql
mutation {
draftOrderCreate(input: {
lineItems: [
{
variantId: "gid://shopify/ProductVariant/111"
quantity: 100
customAttributes: [
{
key: "special_instructions"
value: "Ship to warehouse"
}
]
}
]
companyContactId: "gid://shopify/CompanyContact/buyer1"
billingAddress: {
firstName: "John"
lastName: "Doe"
address1: "123 Main St"
city: "Chicago"
province: "IL"
zip: "60601"
country: "US"
}
shippingAddress: {
firstName: "Warehouse"
lastName: "Receiving"
address1: "456 Dock St"
city: "Chicago"
province: "IL"
zip: "60602"
country: "US"
}
customAttributes: [
{
key: "po_number"
value: "PO-2026-001"
}
]
}) {
draftOrder {
id
lineItemsCount
subtotalPrice {
amount
currencyCode
}
}
}
}
```
### Complete draft order with payment terms
```graphql
mutation {
draftOrderComplete(input: {
id: "gid://shopify/DraftOrder/12345"
paymentPending: false # Set true for NET terms (invoice model)
paymentTerms: {
paymentTermsId: "gid://shopify/PaymentTerms/NET_30"
dueAt: "2026-07-01T23:59:59Z"
}
}) {
draftOrder {
id
completedAt
order {
id
name
paymentTerms {
paymentTermsTemplate
dueAt
}
}
}
}
}
```
### Order with vaulted card (recurring payment)
```graphql
mutation {
orderCreate(input: {
lineItems: [
{
variantId: "gid://shopify/ProductVariant/111"
quantity: 50
}
]
customer: {
id: "gid://shopify/Customer/cust123"
}
paymentTerms: {
paymentTermsId: "gid://shopify/PaymentTerms/NET_30"
}
billingAddress: {
firstName: "John"
lastName: "Doe"
address1: "123 Main"
city: "Chicago"
province: "IL"
country: "US"
}
shippingAddress: {
firstName: "Warehouse"
lastName: "Receiving"
address1: "456 Dock"
city: "Chicago"
province: "IL"
country: "US"
}
}) {
order {
id
name
paymentTerms {
dueAt
paymentTermsTemplate
}
}
}
}
```
---
## Customer Account B2B
Shopify provides **Customer Account B2B** login UI extension that lets company contacts:
1. Sign in with email + password
2. Switch between assigned company locations
3. View order history and payment status
4. Place orders with location-specific catalogs
### Implementation
Install the `customer-account-ui-extensions` API and define a location switcher:
```javascript
// extensions/location-switcher/src/index.tsx
import { useCustomer } from "@shopify/customer-account-ui-extensions-react";
export default function LocationSwitcher() {
const { customer } = useCustomer();
if (!customer?.companyContact) {
return <p>Not a B2B contact</p>;
}
return (
<div>
<h2>Company: {customer.companyContact.company.name}</h2>
<select onChange={(e) => switchLocation(e.target.value)}>
{customer.companyContact.assignedLocations.map((loc) => (
<option key={loc.id} value={loc.id}>
{loc.name}
</option>
))}
</select>
</div>
);
}
```
The Customer Account context automatically adjusts catalog visibility + pricing based on selected location.
---
## Shopify Markets overview
Shopify Markets enables multi-region, multi-currency, multi-language, and multi-domain storefronts from a single product catalog.
**Markets vs separate stores:**
- **One storefront, multiple markets:** Single catalog, region-specific domains/currencies/languages
- **Separate stores:** Isolated inventory, multiple checkouts, more operational overhead
- **Recommendation:** Use Markets if you want to share inventory and simplify management; use separate stores if you need full isolation per region
**Key capabilities:**
- Define regions (US, Canada, EU, Asia, etc.)
- Assign currencies, languages, and tax settings per region
- Publish to multiple domains or use subfolders (/en, /fr, etc.)
- Automatic currency conversion or fixed exchange rates
- Region-specific shipping and payment methods
---
## Markets data model
### Market
Represents a regional storefront (e.g., "Europe - EUR - DE/FR/IT").
```graphql
query {
markets(first: 5) {
edges {
node {
id
name
handle
enabled
primary
regions {
edges {
node {
id
name
}
}
}
webPresences {
edges {
node {
id
domain
subfolderRoot
}
}
}
}
}
}
}
```
Create a market:
```graphql
mutation {
marketCreate(input: {
name: "Europe"
handle: "europe"
regions: {
DE: "Germany"
FR: "France"
IT: "Italy"
}
}) {
market {
id
name
}
}
}
```
### MarketRegion
A country or region within a market (e.g., Germany, France).
```graphql
query {
marketRegion(id: "gid://shopify/MarketRegion/DE") {
id
name
country
currency
taxIncluded
shippingZones {
id
name
}
}
}
```
### MarketWebPresence
Maps a market to a domain or subfolder (e.g., example.eu for Europe, example.com/fr for France).
```graphql
query {
marketWebPresences(first: 10) {
edges {
node {
id
domain # example.eu
subfolderRoot # /en, /fr (if subfolderRoot is set, ignores domain)
market {
id
name
}
published
}
}
}
}
```
Create web presence:
```graphql
mutation {
marketWebPresenceCreate(input: {
marketId: "gid://shopify/Market/EU"
domain: "example.eu"
}) {
marketWebPresence {
id
domain
}
}
}
```
### MarketCurrencySettings
Configure auto-conversion or fixed exchange rates.
```graphql
query {
marketCurrencySettingsByCountry(countryCode: "DE") {
id
currency # EUR
market { id }
exchangeRateMode # AUTO, FIXED
exchangeRate # Only set if FIXED
}
}
```
Set fixed exchange rate:
```graphql
mutation {
marketCurrencySettingsUpdate(input: {
marketId: "gid://shopify/Market/EU"
countryCode: "DE"
exchangeRateMode: FIXED
exchangeRate: 0.92 # 1 USD = 0.92 EUR
}) {
marketCurrencySetting {
id
exchangeRate
}
}
}
```
### MarketLocalization
Sets language preferences per market/region.
```graphql
query {
marketLocalizations(first: 10) {
edges {
node {
id
market { id }
locale # de, fr, it
language # German, French, Italian
default
}
}
}
}
```
---
## @inContext directive: Storefront API currency + language override
When querying the Storefront API (client-side), use `@inContext` to fetch prices and content in a specific market/currency/language.
**Exact syntax:**
```graphql
query {
products(first: 10) @inContext(country: DE, language: DE) {
edges {
node {
id
title
variants(first: 5) {
edges {
node {
id
price {
amount
currencyCode # EUR (because country=DE)
}
}
}
}
}
}
}
}
```
**Valid country codes:** US, CA, DE, FR, IT, JP, AU, etc. (ISO 3166-1 alpha-2)
**Valid language codes:** EN, DE, FR, IT, JA, etc.
**What @inContext changes:**
- Product prices (converted to region currency)
- Shipping rates (region-specific)
- Tax calculations
- Content translations (if available)
**When NOT to use @inContext:**
- Admin API queries (use normal queries; Admin already knows store context)
- Server-side commerce context (use currency/country from request headers)
**When to use:**
- Storefront API (client-side, JavaScript/GraphQL)
- Multiple currencies on same domain (auto-detect from request headers)
- Multi-region storefronts (subfolder or domain-based)
Example: auto-detect country from geolocation and fetch prices:
```javascript
// client-side
const country = geoip.country(request.ip); // "DE"
const language = country === "DE" ? "DE" : "EN";
const query = `
query {
products(first: 10) @inContext(country: ${country}, language: ${language}) {
edges {
node {
variants {
priceV2 {
amount
currencyCode
}
}
}
}
}
}
`;
```
---
## Currency handling in Shopify
### MoneyV2 format
All prices return as `MoneyV2` objects:
```graphql
{
amount: "99.99"
currencyCode: "EUR"
}
```
The `amount` is always a string (to preserve decimal precision). Always parse as `BigDecimal` or similar; avoid floating-point math.
### Liquid money filters
In theme Liquid templates:
```liquid
{{ product.variants[0].price | money }}
{# Output: $99.99 (uses store currency) #}
{{ product.variants[0].price | money_with_currency }}
{# Output: $99.99 USD #}
{{ product.variants[0].price | money_without_currency }}
{# Output: 99.99 #}
{{ product.variants[0].price | money_without_trailing_zeros }}
{# Output: 99.99 (or 100 if ends in .00) #}
```
### Shopify auto-conversion behavior
If a market is set to **AUTO** exchange rate mode:
- Shopify fetches real-time rates daily
- Prices are converted automatically for display
- Transactions settle in store currency; customer is charged in market currency
- Conversion loss/gain is absorbed by merchant
If **FIXED** exchange rate:
- You set the rate (e.g., 1 USD = 0.92 EUR)
- Shopify uses only that rate (no real-time updates)
- Useful for stability over accuracy
### Currency in apps
If you're building a Shopify app (not a theme):
1. **Fetch shop currency** from shop object:
```graphql
query {
shop {
currency
currencyCode # USD, EUR, etc.
}
}
```
2. **For multi-currency stores**, query available currencies:
```graphql
query {
shop {
currencies {
isoCode
name
weight
}
}
}
```
3. **Charge app fees** in shop currency only:
```graphql
mutation {
appSubscriptionCreate(input: {
returnUrl: "https://example.com/..."
name: "Silver Plan"
price: {
amount: "29.99"
currencyCode: USD # Always shop currency
}
trialDays: 7
}) {
appSubscription {
id
}
}
}
```
4. **In webhooks**, check `currencyCode` on prices and amounts. Multi-currency stores may send prices in different currencies depending on market context.
---
## i18n in Liquid
### Translation files
Store translations in `/locales/` (theme root):
```
locales/
en.default.json
de.json
fr.json
```
**en.default.json:**
```json
{
"header": {
"menu": "Menu",
"search": "Search"
},
"product": {
"add_to_cart": "Add to cart",
"price": "Price: {{ price }}"
}
}
```
**de.json:**
```json
{
"header": {
"menu": "Menü",
"search": "Suche"
},
"product": {
"add_to_cart": "In den Warenkorb",
"price": "Preis: {{ price }}"
}
}
```
### Using translations
```liquid
{{ 'header.menu' | t }}
{# Output: "Menu" (if English) or "Menü" (if German) #}
{{ 'product.price' | t: price: product.variants[0].price | money }}
{# Output: "Price: $99.99" or "Preis: 99,99€" #}
```
### Detecting language
Shopify reads `request.locale` from URL or browser headers:
```liquid
{% if request.locale.iso_code == 'de' %}
<p>Willkommen</p>
{% else %}
<p>Welcome</p>
{% endif %}
```
### Fallback language
If no translation exists in the requested locale, Shopify falls back to the default language (en.default.json). Ensure all keys exist in the default file.
---
## Markets-aware checkout
### Server-side: detect market from request
```javascript
// Node.js / Next.js
export default async function handler(req, res) {
const domain = req.headers.host; // example.eu or example.com
const country = req.headers['cloudflare-ipcountry'] || 'US';
// Query market + currency for this domain
const market = await getMarketByDomain(domain);
const currency = market?.currency || 'USD';
// Pass to Storefront API with @inContext
const products = await storefrontQuery({
country: market?.countryCode,
language: market?.locale,
currency
});
res.json({ products, currency });
}
```
### Client-side: @inContext + checkout
```javascript
// Fetch cart with market context
const cartQuery = `
query Cart($cartId: ID!) @inContext(country: ${countryCode}, language: ${language}) {
cart(id: $cartId) {
cost {
totalAmount {
amount
currencyCode
}
}
lines {
merchandise {
price {
amount
currencyCode
}
}
}
}
}
`;
```
### Payment methods per market
In checkout settings, assign payment methods per region:
- US: Stripe, PayPal, Shopify Payments
- EU: Stripe, SEPA Direct Debit, iDEAL, Sofort
- JP: Stripe, Konbini, Bank Transfer
Shopify handles filtering automatically based on request origin (Cloudflare geo-IP).
---
## Common gotchas
### Unfulfillable orders due to wrong catalog
**Problem:** Company contact places order for a product that isn't in their assigned catalog.
**Cause:** Catalog assignment is stale; location's price list doesn't include the variant.
**Fix:** Always validate against the location's active catalog before showing checkout:
```graphql
query {
companyLocationCatalogAssignments(companyLocationId: "loc123") {
catalogs {
id
priceLists {
prices(variantId: "var456") {
id
amount
}
}
}
}
}
```
### Currency rounding
Shopify uses **bankers' rounding** for some internal calculations (round-to-even). If you're doing your own math:
- Store amounts as strings or BigDecimal, NOT floats
- 99.995 rounds to 100.00 (round-to-even)
- Always respect the 2-decimal-place format; don't truncate
### Missing payment_terms webhooks
**Problem:** You set `paymentTerms` on an order, but no webhook fires.
**Cause:** The `orders/create` webhook doesn't always include `payment_terms` in the payload.
**Fix:** Query the order directly to get payment terms:
```graphql
query {
order(id: "gid://shopify/Order/123") {
id
paymentTerms {
paymentTermsId
paymentTermsTemplate
dueAt
}
}
}
```
### Scope requirements for B2B
Your app must request these scopes:
```toml
# shopify.app.toml
scopes = [
"write_products",
"read_companies", # Read B2B company data
"write_companies", # Create/update companies
"read_orders",
"write_draft_orders",
"read_customer_payment_methods"
]
```
Without `read_companies` / `write_companies`, you cannot access B2B objects.
### Markets webfront detection
If a request comes from your Markets storefront, Shopify adds a header:
```
X-Shop-Market-Id: gid://shopify/Market/eu-market
```
Use this to detect which market the user is browsing and apply locale/currency accordingly.
---
## Decision tree
| Merchant says... | Do this |
|---|---|
| "I need to sell wholesale at different prices by location" | Set up Company > CompanyLocation > Catalog > PriceList hierarchy. Assign same catalog, different price lists per currency/location. |
| "I want NET 30 invoicing for my B2B customers" | Enable B2B Plus, assign PaymentTerms to Company, use draftOrderComplete with paymentTerms (paymentPending: true). |
| "I sell in 5 countries with different currencies" | Use Shopify Markets. Create one Market per region, assign currencies + domains/subfolders. Use @inContext in Storefront queries. |
| "My theme shows wrong prices for different visitors" | Add @inContext(country: AUTO_DETECT) to Storefront queries, or detect country server-side and pass to Storefront API. |
| "I need my B2B contacts to log in and switch locations" | Install Customer Account B2B extension. It automatically detects companyContact + assigned locations and provides UI for switching. |
| "Exchange rates are wrong; I want to lock them" | Set MarketCurrencySettings to FIXED mode and specify the exchange rate. Shopify won't auto-convert. |
| "I'm getting duplicate products in my Markets setup" | Markets share the same product catalog. If a product has variant overrides per market, use PriceList (not separate SKUs). |
| "Vaulted card payments aren't working for my B2B customer" | Ensure customer has a stored payment method (vault) + company has PaymentTerms assigned + draftOrder is completed with paymentTerms payload. |
---
## Worked recipes
### Recipe 1: Show wholesale-only products to logged-in B2B contacts
**Scenario:** You have a "Wholesale Only" collection that should only be visible to company contacts.
```graphql
# Liquid template
{% if customer and customer.email %}
{% assign is_b2b = customer.metafields.custom.is_company_contact.value %}
{% if is_b2b %}
<!-- Show wholesale collection -->
{% for product in collections.wholesale_only.products %}
<div>
<h3>{{ product.title }}</h3>
<p>{{ product.variants[0].price | money }}</p>
</div>
{% endfor %}
{% else %}
<p>This collection is for wholesale partners only. Contact us for access.</p>
{% endif %}
{% else %}
<p>Please sign in to view wholesale pricing.</p>
{% endif %}
```
**In the app:** Set the metafield when a company contact is created:
```graphql
mutation {
companyContactCreate(input: {
email: "buyer@acme.com"
companyId: "gid://shopify/Company/12345"
metafields: [
{
namespace: "custom"
key: "is_company_contact"
value: "true"
type: "boolean"
}
]
}) {
companyContact {
id
metafields {
value
}
}
}
}
```
### Recipe 2: Multi-currency price display using @inContext
**Scenario:** Single domain, multiple currencies. Detect visitor country and show prices in their currency.
**Storefront API query (client-side):**
```javascript
const detectCountry = async (request) => {
const cfCountry = request.headers.get('cf-ipcountry') || 'US';
return cfCountry;
};
const fetchProducts = async (countryCode) => {
const query = `
query {
products(first: 10) @inContext(country: ${countryCode}) {
edges {
node {
id
title
variants(first: 3) {
edges {
node {
id
title
price {
amount
currencyCode
}
}
}
}
}
}
}
}
`;
const response = await fetch('https://example.myshopify.com/api/2026-01/graphql.json', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': 'your-token'
},
body: JSON.stringify({ query })
});
return response.json();
};
// On page load
const country = await detectCountry(request);
const { data } = await fetchProducts(country);
// data.products[0].variants[0].price will be in the country's currency
```
**Liquid (fallback):**
```liquid
<div class="product">
<h2>{{ product.title }}</h2>
<p class="price">
{{ product.variants[0].price | money }}
<span class="currency">({{ shop.currency }})</span>
</p>
</div>
```
### Recipe 3: Webhook listener for new company contacts; provision in your app
**Scenario:** When a company contact is created in Shopify, you want to sync them to your CRM or user system.
**Webhook registration (in your app setup):**
```graphql
mutation {
webhookSubscriptionCreate(topic: COMPANY_CONTACT_CREATED, webhookSubscription: {
callbackUrl: "https://yourapp.com/webhooks/company-contact-created"
format: JSON
}) {
webhookSubscription {
id
topic
endpoint {
__typename
}
}
}
}
```
**Webhook handler (Node.js):**
```javascript
import crypto from 'crypto';
export default async function handler(req, res) {
const { body } = req;
const hmac = req.headers['x-shopify-hmac-sha256'];
const topic = req.headers['x-shopify-topic'];
// Verify webhook authenticity
const message = body;
const hash = crypto
.createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)
.update(message, 'utf8')
.digest('base64');
if (hash !== hmac) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (topic === 'company_contacts/create') {
const companyContact = JSON.parse(body);
// Provision in your CRM
await provisionInCRM({
email: companyContact.email,
firstName: companyContact.first_name,
lastName: companyContact.last_name,
companyId: companyContact.company_id,
shopifyId: companyContact.id
});
// Send welcome email
await sendWelcomeEmail(companyContact.email, {
company: companyContact.company,
role: companyContact.role
});
}
res.json({ ok: true });
}
```
**Related webhooks to subscribe:**
- `company_contacts/create`
- `company_contacts/update`
- `companies/create`
- `company_locations/create`
- `draft_orders/create` (for order tracking)
- `orders/create` (to capture payment terms settlement)
---
## Resources and references
- **B2B Plus overview:** https://shopify.dev/docs/apps/b2b-beyond (via Shopify Admin)
- **Company GraphQL:** https://shopify.dev/docs/api/admin-graphql/2026-01/objects/Company
- **PriceList API:** https://shopify.dev/docs/api/admin-graphql/2026-01/objects/PriceList
- **Draft Orders:** https://shopify.dev/docs/api/admin-graphql/2026-01/mutations/draftOrderCreate
- **Shopify Markets:** https://shopify.dev/docs/apps/markets (multi-region setup)
- **@inContext directive:** https://shopify.dev/docs/api/storefront/2026-01#directive-inContext
- **Customer Account B2B:** https://shopify.dev/docs/apps/customer-accounts/b2b
- **Liquid i18n:** https://shopify.dev/docs/themes/architecture/localization
- **Webhook topics:** https://shopify.dev/docs/api/admin-rest/2026-01#webhook_topics
- **Admin API scopes:** https://shopify.dev/docs/api/admin-rest/2026-01#api_access_scopes
---
## Summary for app developers
**B2B Plus + Markets = powerful international B2B:**
1. Use **Companies + Locations + Catalogs + PriceLists** for location-aware wholesale pricing
2. Use **PaymentTerms + vaulted cards** for NET invoicing and recurring payments
3. Use **Shopify Markets** for multi-region, multi-currency, multi-language support
4. Use **@inContext** in Storefront API queries to fetch prices in the customer's market currency
5. Use **Customer Account B2B** extensions for company contact login + location switching
6. Listen to B2B webhooks to keep your CRM, ERP, and fulfillment systems in sync
7. Always validate catalogs before fulfillment; currency mismatches and rounding errors are the main gotchas
This skill covers the full B2B + Markets stack as of May 2026. Refer to https://shopify.dev for the latest API versions and feature updates.built-for-shopify-standards41.9 KB
---
name: built-for-shopify-standards
description: "Prepare a Shopify app for Built for Shopify status. Covers current quality criteria, performance, accessibility, Shopify admin integration, compliance webhooks, observability, support, application readiness, and ongoing eligibility."
---
# Built for Shopify Standards
The official quality bar for Shopify App Store apps. Qualifying apps receive the Built for Shopify highlight and badge, additional App Store promotion, eligibility for promotion on other merchant surfaces, and priority review for future app submissions. BFS does not create a separate revenue-share tier.
This skill is the full requirement matrix as of May 2026, with rejection patterns, a 50-item self-check, and a decision tree to tell you whether you're ready to apply.
---
## 1. When to use this skill
Trigger this skill when:
- You're planning to submit an app for the Built for Shopify (BFS) designation
- You got a "fails to meet Built for Shopify standards" rejection and want to fix it
- You're scoping a v1 app and want to build to BFS from the start (cheaper than retrofitting)
- A merchant or partner mentioned your app "isn't Built for Shopify yet"
- You're doing a 90-day audit before reapplying after a previous BFS failure
- You're deciding whether the promotional benefits justify the implementation and ongoing-compliance work
- You want to know exactly what "integration depth" means in BFS-speak (it's specific)
- You're comparing your app against a competitor that has the badge and want to close the gap
- You're hitting LCP, INP, CLS, or API p95 problems and want to know the actual gate numbers
- You're wiring up GDPR webhooks and want to confirm BFS still requires all three
Don't use this skill for:
- General App Store submission (different bar — that's `app-validation`)
- Performance debugging itself (that's `app-performance` — this skill references those thresholds but doesn't reteach them)
- Polaris implementation details (that's a separate UI/design skill)
- Listing copy or screenshots (that's `app-listing-optimization`)
- Pricing strategy (that's `app-pricing-strategy`)
This skill is the audit framework. The fix-it skills live elsewhere.
---
## 2. What Built for Shopify actually is
### 2.1 It's a designation, not a category
A common misunderstanding: founders think "Built for Shopify" is a category they apply to and join. It isn't. Every BFS app stays in its primary category (Marketing, Fulfillment, etc.) and additionally earns a **badge** that displays on the App Store listing and in search results.
The badge says, in effect: "Shopify has tested this app against a defined quality bar and it currently passes." It's not a permanent stamp — Shopify re-evaluates apps continuously and apps **lose BFS status** if they fall below thresholds (e.g., LCP regresses, support response time slips, a customer complaint storm hits, you start failing accessibility checks).
### 2.2 Why merchants notice the badge
Shopify documents these benefits:
- A Built for Shopify highlight on the app listing
- A badge on App Store cards, including search results and category pages
- A merchant-facing search filter for Built for Shopify apps
- Additional promotion in the Shopify App Store and eligibility for promotion on other key merchant surfaces
- Priority review for future app submissions from BFS developers
Treat any claim about conversion lift, install velocity, ranking weight, or guaranteed featuring as an experiment to measure for your app, not as an official BFS guarantee.
### 2.3 The revenue share angle (verify the exact terms before relying on it)
Shopify's revenue share for App Store developers has shifted multiple times. As of the most recent published policy update:
- **First $1M USD in lifetime gross app revenue earned from January 1, 2025**: 0% revenue share (you keep 100%)
- **Above $1M lifetime**: 15% revenue share (you keep 85%)
Critical clarifications:
- Earnings before Jan 1, 2025 don't count toward the $1M threshold.
- Revenue is aggregated across apps and associated developer accounts. Associated accounts must be declared; don't split related accounts to avoid the threshold.
- All billing is also subject to a 2.9% processing fee, applicable sales tax, and possible regional fees.
- Very large developers have separate eligibility rules, so verify Shopify's current policy for your organization.
- This 0%/15% structure applies whether or not you have BFS — BFS does **not** currently grant a special revenue share tier on top of this (an older "Built for Shopify gets 15%/20% while non-BFS gets nothing" model existed in some historical content; that's outdated)
**Action**: Before submitting financial projections or making a strategic decision based on revenue share, re-read `https://shopify.dev/docs/apps/launch/distribution/revenue-share` and the latest changelog entry. Shopify has changed this policy three times in the last four years.
### 2.4 What BFS does NOT do
Useful to know so you don't oversell internally:
- It does **not** guarantee installs (the badge helps; it doesn't replace marketing)
- It does **not** exempt you from App Store guideline violations (you can still get pulled for trademark issues, security failures, etc.)
- It does **not** give you direct contact with Shopify reviewers (you still go through standard channels)
- It does **not** make your app eligible for the Shopify Plus Certified App Program (that's a separate, much higher bar)
- It is **not** the same as the Shopify Plus Technology Partner badge
---
## 3. Full requirement matrix (May 2026)
Three pillars: **Performance**, **Design**, **Integration**. Plus operational gates: support, observability, compliance. All must be met simultaneously and continuously.
### 3.1 Performance pillar
| Requirement | Threshold | Measurement | Window |
|---|---|---|---|
| **LCP** (Largest Contentful Paint) — admin embedded | ≤ 2.5s at p75 | Web Vitals API via App Bridge | 28 days, min 100 launches |
| **INP** (Interaction to Next Paint) — admin embedded | ≤ 200ms at p75 | Web Vitals API via App Bridge | 28 days, min 100 launches |
| **CLS** (Cumulative Layout Shift) — admin embedded | ≤ 0.1 at p75 | Web Vitals API via App Bridge | 28 days, min 100 launches |
| **API p95 latency** (your server endpoints) | < 500ms | Shopify Partner dashboard / your APM | 28 days |
| **API failure rate** | < 0.1% | Shopify Partner dashboard / your APM | 28 days, min 1000 requests |
| **Storefront Lighthouse delta** (if you ship theme app extensions) | ≤ 10 points reduction | Lighthouse run with/without your extension | Spot check |
| **Storefront JS budget per surface** (theme app extensions) | ≤ 50KB gzipped (rule of thumb) | Your bundle audit | Per release |
| **Webhook ACK latency** | < 5s (target < 2s) | Your monitoring + Shopify retry logs | Continuous |
See `../app-performance/SKILL.md` for the fix-it playbook for every metric above. This skill enforces the gate while the companion skill provides the remediation workflow.
### 3.2 Design pillar
| Requirement | Standard |
|---|---|
| **Polaris adherence** | Use Polaris components for all admin UI; deviations must match Polaris visual language |
| **App Bridge version** | Latest App Bridge (currently 4.x, loaded via CDN script in `<head>`, before your bundle) |
| **Embedded experience** | App renders embedded in Shopify admin by default; primary workflows complete inside the admin |
| **Mobile responsive** | All admin views functional on Shopify mobile admin app (iOS + Android) |
| **Polaris CSS loaded exactly once** | At the root, no duplicate imports, no per-route loading |
| **Accessibility** | WCAG 2.1 Level AA (matches Polaris's own target) |
| **Dark mode** | Polaris components handle automatically; custom UI must support it |
| **Internationalization** | Use Polaris's i18n system; locale awareness for all merchant-facing text |
| **Visual consistency** | Spacing, typography, color tokens come from Polaris; no ad-hoc CSS for these |
| **Loading states** | Skeletons match final dimensions to prevent CLS |
| **Empty states** | Provided for every list, table, and dashboard view |
| **Error states** | Human-readable error messages, never raw error codes or stack traces |
### 3.3 Integration pillar
This is the BFS-specific one most apps fail. "Integration depth" means: **your app feels like a native part of Shopify, not a third-party iframe**. The reviewer is checking that you've used at least one — and often several — of these surfaces:
| Integration surface | What it does | When BFS reviewers expect it |
|---|---|---|
| **Admin Action extensions** | Buttons in the admin's resource pages (Orders, Products, Customers) that invoke your app | If your app operates on Orders/Products/Customers, expected |
| **Admin Block extensions** | Inline blocks that render your app's UI inside the admin's native resource pages | Strong differentiator; expected for "deep" integrations |
| **Admin Link extensions** | Navigation links that appear in the admin sidebar pointing to your embedded app | Baseline; almost every BFS app has these |
| **ResourcePicker API** | Native Shopify picker for products, collections, variants — instead of you building your own | Required if your app needs merchants to select these |
| **Bulk action support** | Selecting many resources at once and invoking your app on the batch | Expected for any app that operates on lists of resources |
| **Theme App Extensions (App Blocks)** | Storefront UI that merchants add to themes via the theme editor | Required if your app touches the storefront |
| **Shopify Functions** | Server-side logic that runs in Shopify's infra (discounts, delivery customization, payment customization) | Required if your app modifies checkout/discount/delivery logic |
| **Web Pixels** | Customer behavior tracking that respects the consent layer | Required for any storefront analytics/tracking app |
| **Checkout UI Extensions** | UI in checkout (Plus/Shopify Payments only) | Required for checkout-modifying apps |
| **Metafields + Metaobjects** | First-class storage of your app's data on Shopify's side | Strongly preferred over a private DB for merchant-visible data |
| **Flow connectors / triggers / actions** | Integration with Shopify Flow | Expected if your app has events worth automating |
**Rule of thumb**: A BFS app uses at least 3 of these. An app that ships only the embedded admin and reads/writes via GraphQL with no other surfaces will frequently get "lacks integration depth" feedback.
### 3.4 Compliance pillar
| Requirement | Detail |
|---|---|
| **Mandatory privacy webhooks** | All three handlers implemented and returning 200: `customers/data_request`, `customers/redact`, `shop/redact` |
| **Privacy webhook HMAC verification** | Every webhook handler verifies the `X-Shopify-Hmac-Sha256` header before processing |
| **Privacy policy** | Public URL, no login required, mentions GDPR + CCPA + data retention period |
| **Terms of Service** | Public URL, includes app limitations and refund policy |
| **OAuth scopes minimal** | Request only scopes you actually use; reviewers compare requested vs. observed |
| **OAuth flow standard** | Use Shopify's official OAuth flow — no custom session schemes |
| **Session tokens (not third-party cookies)** | For embedded apps; third-party cookies in 2026 are blocked by every modern browser anyway |
| **HTTPS everywhere** | Valid SSL on every endpoint, including webhook handlers and the support email's domain |
| **Sensitive data encryption** | Access tokens, API keys, customer PII encrypted at rest |
| **Data residency** | Honor merchant region preferences if you store EU customer data |
| **Uninstall hygiene** | App deletes all merchant data on `shop/redact` and removes any theme code it injected |
### 3.5 Support pillar
| Requirement | Standard |
|---|---|
| **Support email** | Functional, monitored, on your own domain (not gmail.com) |
| **Public help center / docs** | Searchable, covers setup + top 10 questions |
| **Response time SLA** | Public commitment; BFS reviewers expect **≤ 24 business hours** as the floor |
| **Live chat or messaging channel** | Not strictly required but strongly preferred for higher-tier BFS visibility |
| **Status page** | Public uptime + incident history (StatusPage.io free tier or similar) |
| **In-app help** | Contextual help links from inside your app to the relevant doc |
| **Onboarding** | Merchant reaches first value in < 2 minutes from install |
### 3.6 Observability pillar
| Requirement | Why |
|---|---|
| **Web Vitals API wired up** | BFS literally cannot measure your app without this. App Bridge ships an API; you call `shopify.webVitals.onReport(...)` and forward to your monitoring |
| **Error tracking** | Sentry, Datadog, Axiom, etc. Track every server error and unhandled client exception |
| **APM** | Per-route latency p50/p95/p99 for every loader and action |
| **Webhook delivery monitoring** | Track ACK time and failure rate; alert when retries spike |
| **GraphQL throttle monitoring** | Alert when `extensions.cost.actualQueryCost` consistently exceeds 50% of bucket |
| **Audit log retention** | At minimum 30 days of structured logs for support debugging |
---
## 4. Performance gates — exact numbers
These are the BFS gates as of May 2026. They are p75 (75th percentile of merchant launches) over a rolling 28-day window. You need a minimum sample size before BFS can measure you at all, so a brand-new app needs to run for ~30 days with real merchants before it's even eligible.
### 4.1 Core Web Vitals (admin embedded)
| Metric | Pass | Window | Min sample |
|---|---|---|---|
| LCP | ≤ 2.5s p75 | 28 days | 100 launches |
| INP | ≤ 200ms p75 | 28 days | 100 launches |
| CLS | ≤ 0.1 p75 | 28 days | 100 launches |
**Important**: Google has been increasing the weight of real-user (CrUX) data over lab data in its own ranking. Shopify follows suit — Lighthouse scores on your raw URL are advisory, not the gate. The gate is what App Bridge's Web Vitals API reports from actual merchant sessions. If you haven't wired that up, you can't be measured, you can't pass, you can't get BFS.
### 4.2 Server / API
| Metric | Pass | Window |
|---|---|---|
| API p95 latency | < 500ms | 28 days |
| API failure rate | < 0.1% | 28 days, min 1000 requests |
Build a cushion: target p95 ≤ 400ms and failure rate ≤ 0.05% so a single bad week doesn't sink you.
### 4.3 Storefront impact (theme app extensions only)
| Metric | Pass |
|---|---|
| Lighthouse performance delta | ≤ 10 points reduction with your extension installed |
| Per-surface JS budget | ≤ 50KB gzipped (rule of thumb) |
Measure delta by running Lighthouse on a clean test theme, then installing your extension to the same theme and re-running. The difference must be < 10 points.
### 4.4 Webhook latency
| Metric | Pass |
|---|---|
| Webhook handler ACK | < 5s (Shopify retries at 5s) |
| Target | < 2s with heavy work queued |
The way to pass: HMAC verify, persist the job to a queue, return 200. A worker handles the actual processing.
---
## 5. Accessibility gates — WCAG 2.1 AA
Polaris components already meet WCAG 2.1 AA by default. So if you use Polaris everywhere and don't add custom UI, you get this for free. The places apps trip up:
### 5.1 Where apps lose accessibility points
| Failure | Fix |
|---|---|
| **Custom forms not using Polaris `<TextField>`, `<Select>`, etc.** | Replace with Polaris components |
| **Missing `<label>` association on form inputs** | Polaris does this automatically; custom inputs need `htmlFor` + matching `id` |
| **Color contrast below 4.5:1 for text** (or 3:1 for large text) | Use Polaris color tokens; never set custom hex colors for text |
| **Focus not visible on keyboard tab** | Don't override Polaris focus rings; if you must, ensure visible outline at 2px+ |
| **Modals trap focus poorly** | Use Polaris `<Modal>`, not a hand-rolled overlay; it manages focus correctly |
| **Images missing `alt`** | Every `<img>` has `alt` (empty string for decorative is fine) |
| **Icon-only buttons missing `accessibilityLabel`** | Polaris `<Button>` requires this; set it on every icon-only button |
| **Dynamic content not announced** | Use `<Toast>` or `<Banner>` from Polaris; they handle ARIA live regions |
| **Tables without proper `<th>` scope** | Use Polaris `<IndexTable>` or `<DataTable>` |
| **Custom dropdowns without keyboard nav** | Use Polaris `<Popover>` or `<Combobox>` |
| **Form errors not associated with fields** | Polaris `<TextField error="...">` handles this; custom forms need `aria-describedby` |
| **Skip links missing on long pages** | Add a "Skip to main content" link as the first focusable element |
| **Heading order broken** (`<h1>` → `<h3>` skipping `<h2>`) | Audit with axe DevTools |
### 5.2 Required keyboard interactions
Every interactive element must be reachable and operable by keyboard alone:
- Tab — moves to next focusable element
- Shift+Tab — previous
- Enter — activates buttons and links
- Space — activates buttons (not links), toggles checkboxes
- Arrow keys — navigate within composite widgets (menus, tabs, listboxes)
- Escape — closes modals, popovers, dropdowns
Test this manually: unplug your mouse and try to do every primary workflow. If you can't, you don't pass.
### 5.3 Screen reader testing
Test with at least one screen reader before submission:
- **VoiceOver** (macOS, free, built-in) — Cmd+F5 to enable
- **NVDA** (Windows, free) — most-used in real-world WCAG audits
- **JAWS** (Windows, paid) — enterprise reality
Walk through onboarding + primary workflows. Every screen should be understandable from audio alone.
### 5.4 Automated audit tools
Run all three on every release:
- **axe DevTools** (browser extension) — catches ~40% of issues
- **Lighthouse** accessibility section
- **WAVE** (browser extension) — visual annotations of issues
These tools combined catch maybe 50-60% of WCAG violations. The other 40% require manual testing.
---
## 6. UX / Polaris compliance
### 6.1 The Polaris rule
If Polaris has a component for it, use Polaris's component. Don't build your own button, your own modal, your own form field. Reviewers explicitly look for this. Custom components that visually match Polaris are not enough — they're trying to verify that you've adopted Polaris's accessibility, dark mode, i18n, and responsive behavior.
### 6.2 App Bridge 4.x requirement
All BFS apps must use the **latest version of App Bridge**. As of May 2026, that's App Bridge 4.x.
Migration from earlier versions:
- **App Bridge 1.x / 2.x** — fully deprecated; rewrite required
- **App Bridge 3.x** — supported but not BFS-eligible; upgrade
- **App Bridge 4.x** — the current target; loaded via CDN script
How to load App Bridge 4.x correctly:
```html
<head>
<!-- This MUST come before your bundle -->
<meta name="shopify-api-key" content="YOUR_API_KEY" />
<script src="https://cdn.shopify.com/shopifycloud/app-bridge.js"></script>
<!-- Then your app bundle -->
<script type="module" src="/build/entry.js"></script>
</head>
```
The script tag is non-negotiable. Bundling App Bridge into your app bundle (via npm) instead of loading it from Shopify's CDN will fail BFS — Shopify needs to control which version runs so they can ship security patches without your redeploy.
### 6.3 Mobile responsive
Shopify's mobile admin app (iOS + Android) renders your embedded app in a webview. Your UI must work at:
- 375px wide (iPhone SE / small Android) — minimum
- 768px wide (iPad portrait)
- 1024px+ wide (desktop)
Polaris components handle this automatically. Custom layouts must use CSS Grid / Flexbox responsively.
### 6.4 Embedded by default
Your app must open inside the Shopify admin's iframe. Opening into a new browser tab, or requiring the merchant to leave the admin to use your app, is a BFS fail.
Exceptions:
- Non-Shopify-related external integrations (e.g., connecting your Stripe account for billing) can open in a new tab
- Long-running file downloads can navigate the parent
---
## 7. Integration depth — what counts
This section deserves its own treatment because "lacks integration depth" is the single most common qualitative rejection.
### 7.1 What reviewers are checking
The reviewer asks: **"Does this app feel like a native part of Shopify, or like a third-party tool with a Shopify OAuth wrapper?"**
Signals of "native":
- Merchant can do most of their work without leaving the admin
- The app shows up in contextual locations (Order detail, Product detail, etc.)
- The app uses Shopify's native UI primitives (ResourcePicker, IndexTable, AppBridge actions) instead of building parallel versions
- The app extends Shopify's surfaces (Functions, Theme App Extensions, Flow) where relevant
Signals of "wrapper":
- Merchant has to leave the admin or open external tabs frequently
- The app builds its own product picker instead of using ResourcePicker
- The app dumps merchants into a generic dashboard with no contextual entry points from Orders/Products/Customers
- The app reads/writes via GraphQL but ships no extensions
### 7.2 Integration surfaces ranked by impact
From most to least impactful for "integration depth" feedback:
1. **Admin Block extensions** — render your UI inside a native resource page. Strongest signal of native integration.
2. **Admin Action extensions** — buttons in the admin that invoke your app. Strong signal.
3. **Theme App Extensions (App Blocks)** — merchant adds your storefront UI via the theme editor. Required for storefront-touching apps.
4. **Shopify Functions** — server-side logic in Shopify infra. Strong signal for checkout/discount/delivery apps.
5. **ResourcePicker API** — using Shopify's native picker instead of your own. Cheap to implement, big signal.
6. **Bulk action handling** — invoking your app on a multi-select of resources. Cheap to implement.
7. **Metafields / Metaobjects** — store data on the Shopify side rather than only in your DB. Makes data visible in admin contexts.
8. **Flow connectors** — exposing triggers and actions to Shopify Flow.
9. **Web Pixels** — for any tracking/analytics apps.
10. **Admin Link extensions** — sidebar nav entries. Baseline; everyone has these.
**Target**: Implement at least 3 of these. Apps with 1-2 frequently get "lacks integration depth." Apps with 5+ rarely do.
### 7.3 GraphQL Admin API expectations
Use the latest stable API version. Shopify versions are supported for a limited window. When this release was audited, the current stable version was `2026-07`; verify the version schedule before each release.
BFS apps must use a non-deprecated version. Don't ship pinned to `2024-04` and expect to pass.
### 7.4 REST Admin API is legacy
Shopify classified the REST Admin API as legacy on October 1, 2024, and new public apps have been required to use GraphQL exclusively since April 1, 2025. Shopify has not announced a blanket end-of-2026 shutdown for every REST resource. Existing integrations should track their API versions and migrate resource by resource.
---
## 8. GDPR webhooks — non-negotiable
All three must be implemented, working, HMAC-verified, and responsive within 30 seconds (Shopify's compliance webhook timeout is more lenient than regular webhooks but still finite).
### 8.1 The three handlers
#### `customers/data_request`
Sent when a merchant or customer requests all customer data your app holds for a specific customer.
Payload includes:
```json
{
"shop_id": 123,
"shop_domain": "example.myshopify.com",
"orders_requested": [123, 456],
"customer": { "id": 789, "email": "customer@example.com", "phone": "+1..." },
"data_request": { "id": 999 }
}
```
Your handler: collect all data you store about this customer, return it in a format you can deliver to the merchant (email a JSON file, generate a download link, etc.). The webhook itself just needs to return 200; the data delivery happens out-of-band.
#### `customers/redact`
Sent 10 days after a customer's deletion request (Shopify's mandatory waiting period).
Payload:
```json
{
"shop_id": 123,
"shop_domain": "example.myshopify.com",
"customer": { "id": 789, "email": "customer@example.com", "phone": "+1..." },
"orders_to_redact": [123, 456]
}
```
Your handler: delete or anonymize all data you hold for this customer. Cascade through related records. Log the deletion for compliance audit.
#### `shop/redact`
Sent 48 hours after a merchant uninstalls your app.
Payload:
```json
{
"shop_id": 123,
"shop_domain": "example.myshopify.com"
}
```
Your handler: delete all data you hold for this shop. This is your `DROP TABLE WHERE shop = X` moment. After 48 hours of uninstall, you have no legitimate reason to retain merchant data.
### 8.2 Implementation requirements
- HMAC verify on the raw body, not parsed JSON
- Return 200 within 30 seconds (push to queue if work is heavy)
- Idempotent on `X-Shopify-Webhook-Id`
- Logged for audit purposes
- Tested with Shopify's webhook testing tool before submission
### 8.3 Common compliance webhook fails
- Returning 200 but doing nothing (Shopify spot-checks; will fail you)
- HMAC verification skipped or wrong
- Handler timeout (work too heavy, no queue)
- Webhook URL on HTTP not HTTPS
- Privacy policy doesn't mention the data retention timeline
---
## 9. Observability requirements
BFS reviewers don't just check that your app passes today — they check that you can observe and respond when it doesn't. The expectation:
### 9.1 Web Vitals API wired
```ts
import { shopify } from "@shopify/app-bridge-react";
shopify.webVitals.onReport((metric) => {
fetch("/api/web-vitals", {
method: "POST",
body: JSON.stringify(metric),
});
});
```
Without this, your app appears in BFS dashboards with "no data" — you'll be told to wire it up before further review.
### 9.2 Error tracking
- Server errors → Sentry / Datadog / equivalent
- Client errors → same
- Alert thresholds defined (e.g., >0.1% error rate)
- On-call rotation if you have a team
### 9.3 APM
- Per-route p50/p95/p99 latency
- Alert when p95 crosses 80% of the BFS gate (e.g., 400ms when the gate is 500ms)
### 9.4 Audit logs
- Structured logs (JSON, not plain text)
- 30+ day retention
- Search by shop, merchant, webhook ID, request ID
### 9.5 Status page
- Public uptime over last 90 days
- Incident history
- Subscribe-for-updates link
---
## 10. Support requirements
### 10.1 Response time SLA
BFS reviewers expect you to publicly commit to **≤ 24 business hours** as the floor. Most successful BFS apps publish 12 business hours; the best publish 4 business hours.
Where to display:
- App Store listing description
- Your help center home page
- In-app help link
### 10.2 Help center
- Public, no login required
- Searchable
- Covers setup + top 10 FAQ
- Includes a contact form or email link prominently
- Updated within 24h when features ship
Hosted options: Intercom, HelpScout, Zendesk Guide, Crisp, GitBook, or even a well-structured Notion page (with custom domain).
### 10.3 In-app help
Contextual help links from inside the embedded app to the relevant docs. Example: a "Help with this page" link in every primary view that deep-links to the matching help article.
### 10.4 Live channel (preferred, not required)
Live chat or messaging shifts perception of support quality substantially. BFS reviewers note it. If you can't staff live chat, async via Crisp / Intercom / Drift with a clear "we respond within X" badge is fine.
---
## 11. Revenue share specifics (verify before relying)
Re-stating the current state for clarity. **Verify against Shopify's revenue share docs before doing financial modeling — this policy has changed multiple times.**
### 11.1 Current structure (post-Jan 2025 update)
| Lifetime revenue | Revenue share | You keep |
|---|---|---|
| First $1M USD (lifetime gross app revenue earned since Jan 1, 2025) | 0% | 100% |
| Above $1M lifetime | 15% | 85% |
### 11.2 What changed
- Prior policy: $1M was an **annual** exemption that reset each calendar year. A successful app could collect $1M every year and pay 0% on all of it.
- Updated policy: the first $1M in qualifying lifetime gross app revenue earned since Jan 1, 2025 is subject to 0% revenue share; revenue above the threshold is subject to 15%.
- This applies to **all** App Store apps — BFS or not. BFS does not currently grant an additional revenue share break on top of this.
- A separate 2.9% processing fee, taxes, and possible regional fees still apply.
- Developers above Shopify's stated annual app-earnings or company-revenue thresholds don't qualify for the $1M exemption.
### 11.3 Partner-level aggregation
Revenue from multiple apps and all associated developer accounts is aggregated. Shopify requires developers to disclose associated accounts, while payouts remain separate at the Partner account level.
### 11.4 Strategic implications
- Evaluate BFS using its documented highlight, badge, search-filter, promotional, and priority-review benefits
- The lifetime $1M means high-MRR apps hit 15% faster than they used to; factor that into pricing
- Never create or omit associated accounts to evade revenue aggregation; follow the Partner Program Agreement
---
## 12. Application / audit process
### 12.1 Eligibility
You can't apply until Shopify's automated checks confirm you meet baseline. The Partner Dashboard shows which prerequisites you've passed and which you haven't. Apply only when everything green-lights.
Prerequisites that get auto-checked:
- Listing meets App Store guidelines
- App Bridge 4.x detected on your embedded URL
- GDPR webhooks present and returning 200 in test calls
- API version current (non-deprecated)
- Web Vitals data flowing
- 100+ launches over 28 days
- API p95 within threshold
### 12.2 The application
When eligible, the Partner Dashboard surfaces a "Apply for Built for Shopify" button. The application asks:
- Confirm support SLA (with public-facing URL)
- Confirm privacy policy URL
- Confirm uninstall hygiene (data deletion within 48h of `shop/redact`)
- Demonstrate integration surfaces used
- Demo video of primary workflows (recommended)
- Test store credentials (for the human reviewer to validate)
### 12.3 Review timeline
Typical: **2-4 weeks** for a human reviewer to walk through your app, test workflows, run accessibility checks, validate integration depth, and either approve or send feedback.
### 12.4 Feedback cycle
If you don't pass, you get specific feedback. Common feedback categories:
- "Performance below threshold on X" — re-measure after fixing, re-apply
- "Lacks integration depth" — add 1-2 more integration surfaces
- "Accessibility issue: keyboard navigation broken in modal Z" — fix and re-apply
- "Support SLA not publicly visible" — publish and re-apply
- "Polaris compliance: custom UI in section X" — refactor
You can re-apply once you've addressed feedback. There's no penalty for multiple cycles, but each cycle re-starts the 2-4 week review.
### 12.5 Continuous evaluation
Once you have BFS, you don't keep it forever. Shopify re-evaluates continuously:
- Performance regression for 2+ weeks → warning
- Performance regression sustained → BFS revoked, you can re-earn it after fixing
- Support complaints accumulating → review
- Major guideline violation → BFS revoked
---
## 13. Top 15 BFS rejection reasons
Ranked by how often they appear in real rejection feedback. Fix all of these proactively before applying.
| # | Rejection reason | Fix |
|---|---|---|
| 1 | **Web Vitals API not wired** | Implement `shopify.webVitals.onReport()` and send to monitoring; wait 28 days for data |
| 2 | **App Bridge not 4.x or loaded incorrectly** | Load from CDN in `<head>` before bundle; remove npm-bundled App Bridge |
| 3 | **Performance below threshold** (LCP, INP, CLS, or API p95) | See app-performance skill; address cold starts, bundle size, GraphQL serial awaits, N+1 |
| 4 | **GDPR webhooks missing or non-functional** | All three implemented, HMAC-verified, return 200 within 30s |
| 5 | **Lacks integration depth** (admin-only iframe, no extensions) | Add Admin Block + Action + ResourcePicker, minimum 3 surfaces total |
| 6 | **OAuth scopes excessive** | Audit; remove every scope you don't use in the last 30 days of logs |
| 7 | **Accessibility failures** (custom UI breaks keyboard nav or screen readers) | Replace custom UI with Polaris components; audit with axe DevTools |
| 8 | **Theme injection without cleanup** | Use Theme App Extensions (App Blocks); they're auto-removed on uninstall |
| 9 | **Support SLA not publicly committed** | Publish 24-hour SLA on listing + help center + in-app |
| 10 | **Privacy policy hidden or missing GDPR mention** | Public URL, explicit GDPR + CCPA + retention period |
| 11 | **Storefront performance impact > 10 Lighthouse points** | Audit theme app extension; trim JS, lazy-load, async-load |
| 12 | **API version deprecated** | Bump to the latest supported stable version; remove pinned unsupported versions |
| 13 | **Onboarding > 2 minutes to first value** | Cut steps; pre-fill defaults; show immediate value (sample data, preview) |
| 14 | **Embedded app opens in new tab** | Render inside admin iframe by default; only external for things like Stripe Connect |
| 15 | **Help docs missing or stale** | Build help center; cover top 10 FAQ; link in-app contextually |
---
## 14. Pre-audit self-check (50 items)
Run this before clicking the Apply button. Every item must be checked. If any are unchecked, fix first.
### Performance (15)
- [ ] App Bridge Web Vitals API wired and sending to monitoring
- [ ] 100+ launches recorded over 28-day window
- [ ] LCP p75 ≤ 2.5s in Web Vitals dashboard
- [ ] INP p75 ≤ 200ms in Web Vitals dashboard
- [ ] CLS p75 ≤ 0.1 in Web Vitals dashboard
- [ ] API p95 latency ≤ 400ms (cushion below 500ms gate)
- [ ] API failure rate ≤ 0.05% (cushion below 0.1% gate)
- [ ] Initial route bundle ≤ 350KB gzipped
- [ ] All internal `<Link>` use `prefetch="intent"`
- [ ] Loaders use `defer` for non-critical data
- [ ] Cache-Control headers on stable loader responses
- [ ] Server runs on always-on infrastructure (no scale-to-zero)
- [ ] DB connection pooling enabled (Hyperdrive, PgBouncer, or Accelerate)
- [ ] No N+1 in any loader (verified via Prisma query logs)
- [ ] Storefront extensions ≤ 50KB gzipped per surface and ≤ 10 Lighthouse point delta
### Design / Polaris (10)
- [ ] App Bridge 4.x loaded from CDN in `<head>` before bundle
- [ ] Polaris CSS imported exactly once at root
- [ ] All admin UI uses Polaris components, not custom equivalents
- [ ] `<AppProvider>` and `<Frame>` mounted once at root
- [ ] Dark mode works (or doesn't break) across all views
- [ ] Internationalization wired (Polaris i18n provider)
- [ ] Mobile responsive at 375px, 768px, 1024px+
- [ ] Skeletons match final dimensions (no CLS on data load)
- [ ] Empty states present on every list / table / dashboard
- [ ] Error states present and human-readable on every async operation
### Accessibility (8)
- [ ] All form inputs use Polaris components (label association handled)
- [ ] Color contrast ≥ 4.5:1 for text (Polaris tokens guarantee this)
- [ ] Keyboard navigation works for all primary workflows (test mouse-unplugged)
- [ ] Focus visible on every interactive element
- [ ] Icon-only buttons have `accessibilityLabel`
- [ ] Screen reader test passed (VoiceOver or NVDA) on onboarding + primary workflow
- [ ] axe DevTools shows zero violations on every route
- [ ] Heading order valid (no skipped levels)
### Integration depth (7)
- [ ] At least 3 integration surfaces used (Admin Action, Admin Block, Theme App Extension, Functions, ResourcePicker, etc.)
- [ ] ResourcePicker used for any product/collection/variant selection (not custom picker)
- [ ] Bulk actions supported if app operates on lists of resources
- [ ] Theme App Extensions used if app touches storefront (no Liquid injection)
- [ ] Shopify Functions used if app modifies discounts/delivery/payments
- [ ] Metafields/Metaobjects used for merchant-visible data
- [ ] Current supported stable API version — no deprecated versions
### Compliance (5)
- [ ] All three GDPR webhooks implemented: `customers/data_request`, `customers/redact`, `shop/redact`
- [ ] All webhook handlers HMAC-verified on raw body
- [ ] Privacy policy public, mentions GDPR + CCPA + retention period
- [ ] Terms of Service public, includes app limitations
- [ ] OAuth scopes minimal — every requested scope is used
### Support + observability (5)
- [ ] Support email on own domain, functional, monitored
- [ ] Public help center with top 10 FAQ + search
- [ ] Response SLA publicly committed (≤ 24 business hours)
- [ ] Sentry / Datadog / equivalent tracking all errors
- [ ] Status page public (uptime + incident history)
If 50/50 checked, you're ready to apply. If any unchecked, fix first — re-applying is cheap, but the 28-day measurement window is expensive.
---
## 15. Decision tree — am I ready to apply
```
Should I apply for Built for Shopify?
│
├── Has the app been live with real merchants for 30+ days?
│ ├── No → Wait. You need 28 days of Web Vitals + API data.
│ │ Even if every fix is in place, Shopify can't measure you yet.
│ └── Yes → Continue
│
├── Is App Bridge Web Vitals API wired and reporting data?
│ ├── No → Wire it up first. Without data, you're invisible to BFS.
│ │ Then wait 28 days for data to accumulate.
│ └── Yes → Continue
│
├── Are all three Web Vitals (LCP, INP, CLS) within threshold in the Partner Dashboard?
│ ├── No → Run app-performance skill. Fix what's failing.
│ │ Re-measure after 14-28 days of the fix being live.
│ └── Yes → Continue
│
├── Is API p95 ≤ 400ms and failure rate ≤ 0.05% over the last 28 days?
│ ├── No → Fix server perf or stability. See app-performance skill.
│ │ Re-measure after fixes.
│ └── Yes → Continue
│
├── Are all three GDPR webhooks implemented, HMAC-verified, returning 200?
│ ├── No → Implement them. Non-negotiable.
│ └── Yes → Continue
│
├── Are at least 3 integration surfaces used (Admin Action, Block, Functions, etc.)?
│ ├── No → Add more. "Lacks integration depth" is the #5 rejection reason.
│ │ Pick the 1-2 cheapest surfaces for your app type and ship them.
│ └── Yes → Continue
│
├── Does the app pass automated accessibility audits (axe DevTools) on every route?
│ ├── No → Fix violations. Most are 5-minute Polaris component swaps.
│ └── Yes → Continue
│
├── Have you done a manual keyboard-only walk-through of onboarding + primary workflow?
│ ├── No → Do it. Today. It will reveal issues automated tools miss.
│ └── Yes → Continue
│
├── Is your support SLA (≤ 24 business hours) publicly committed in 2+ places?
│ ├── No → Publish on listing, help center, in-app help.
│ └── Yes → Continue
│
├── Have you tested uninstall hygiene — does shop/redact actually delete all data within 48h?
│ ├── No → Test it. Install, populate data, uninstall, wait 48h, verify deletion.
│ └── Yes → Continue
│
├── Is the API version current and supported?
│ ├── No → Upgrade. Old versions are an auto-reject.
│ └── Yes → Continue
│
├── Have you walked through the 50-item self-check and checked every box?
│ ├── No → Walk through it. Don't apply with unchecked items.
│ └── Yes → Apply.
│
└── APPLY
└── Expect 2-4 weeks. If approved, badge appears immediately.
If rejected, you'll get specific feedback. Fix, re-apply.
Each cycle is 2-4 weeks. Don't argue with feedback.
```
---
## 16. After approval — maintenance
Getting BFS isn't a one-time event. Keep it by:
- **Watching Web Vitals weekly** in the Partner Dashboard. Two-week regressions trigger warnings.
- **Watching API p95 daily** in your APM. Alert when it crosses 400ms.
- **Auditing bundle size in CI**. Don't let a regression ship.
- **Monitoring support response time**. If it slips past 24h, hire or automate.
- **Re-running accessibility audits** on every release. Polaris updates can introduce regressions in your custom layers.
- **Reviewing API version annually**. Bump to the current version before yours deprecates.
- **Re-validating GDPR webhooks quarterly**. Send a test request, verify response.
BFS is a flywheel — apps that maintain it get more visibility, more installs, better reviews, which compounds. Apps that lose it usually do so quietly (a perf regression, a slow support quarter) and never notice until ranking drops 60%.
---
## Closing principle
Built for Shopify isn't a marketing badge. It's a contract: you commit to a quality bar, Shopify points merchants at you in exchange. The hard part isn't passing the audit once — it's continuing to pass it while you ship features, grow merchants, and the gates inch tighter each year.
Build the foundation right (Polaris everywhere, App Bridge 4.x, performance budget in CI, GDPR webhooks day one, observability before launch) and BFS is a 30-day formality. Try to retrofit it later and you'll spend three months refactoring around tech debt that should never have shipped.
The earliest your app can be ready for BFS is **day 1 of design**. The latest it can be ready is **never**, because the bar moves.
Pick day 1.
Sources:
- [Built for Shopify requirements (Shopify)](https://shopify.dev/docs/apps/launch/built-for-shopify/requirements)
- [About Built for Shopify (Shopify)](https://shopify.dev/docs/apps/launch/built-for-shopify)
- [Revenue share for Shopify App Store developers (Shopify)](https://shopify.dev/docs/apps/launch/distribution/revenue-share)
- [Update to Shopify's app developer revenue share (Shopify changelog)](https://shopify.dev/changelog/update-to-shopifys-app-developer-revenue-share)
- [Latest version of App Bridge required for Built for Shopify (Shopify changelog)](https://shopify.dev/changelog/latest-version-of-app-bridge-required-for-built-for-shopify)
- [Polaris Accessibility (Shopify)](https://polaris-react.shopify.com/foundations/accessibility)
- [Privacy law compliance (Shopify)](https://shopify.dev/docs/apps/build/compliance/privacy-law-compliance)
- [Common app rejections (Shopify)](https://shopify.dev/docs/apps/store/common-rejections)
- [App Bridge documentation (Shopify)](https://shopify.dev/docs/api/app-bridge-library)
dev-troubleshooting41.2 KB
---
name: dev-troubleshooting
description: "Use when a Shopify dev workflow is failing — `shopify app dev` cryptic errors, Cloudflare tunnel not starting, App Bridge v3→v4 migration 'No AppBridge context provided', GraphQL 200 OK with throttle errors, webhook 401, double-subscribed webhooks, app proxy 404, REST 302 loops, Rust function wasm-validator errors, session token 24h expiry, X-Frame-Options blocking iframe, dev store billing fakeouts, app review SLA blown. Triggers: 'shopify app dev failing', 'tunnel won't start', 'no app bridge context', 'throttle error 200', 'webhook 401', 'wasm validator error', 'session token expired', 'frame ancestors', 'remix template auth broken', 'billing test charge', 'shopify cli error'."
---
# Shopify Dev Troubleshooting — Triage First, Fix Fast
A symptom-driven triage skill for Shopify app developers. When your dev loop hits a wall, start here. Each pain below is sourced from the most-reported Reddit, Shopify Developer Community (community.shopify.dev), Shopify Community (community.shopify.com), and GitHub Shopify/* issues as of 2026-05-15.
Use this skill **first** when:
- `shopify app dev` exits with cryptic errors
- The browser shows "No AppBridge context provided", 401, 302 loops, or 404 on `/auth/login`
- GraphQL "works" but data is missing in production
- Webhooks fire twice, never, or your handler keeps timing out
- A Function deploys fail with `[wasm-validator error]`
- App Review SLA is blown and you don't know what to do
- A dev-store billing test charge silently fails
If the symptom matches a row in the Diagnostic Table, jump straight to that fix. If not, work through the Decision Tree at the bottom.
---
## 1. When to Use This Skill
This is the **entry point** for any "something is broken in my Shopify dev workflow" question. It is not the place to learn how to build new features — that's what the other `shopify-app-builder` skills are for. This skill is the ER, not the gym.
Surface this skill when the user says any of:
- "shopify app dev failing / not working / errors"
- "tunnel won't start", "cloudflared", "max retries reached"
- "App Bridge migration", "v3 to v4", "No AppBridge context provided"
- "graphql throttled", "200 OK error", "currentlyAvailable"
- "webhook 401", "double webhook", "duplicate webhook", "webhook retry"
- "remix auth broken", "/auth/login 404", "nested route login"
- "wasm-validator error", "wasm-opt failed", "Rust function deploy"
- "session token expired", "24 hour", "iframe redirect blocked"
- "X-Frame-Options", "frame-ancestors", "DENY"
- "billing test charge", "Apps without public distribution"
- "app review", "Built for Shopify rejected", "SLA blown"
- "shopify cli error", "cannot read properties of null"
If multiple symptoms match, run them in order of blast radius: production data corruption > auth break > review block > dev-loop friction.
---
## 2. The Diagnostic Table
Find your symptom in column 1. Apply the fix in column 3.
| # | Symptom (verbatim or close) | Likely cause | Exact fix |
|---|---|---|---|
| 1 | `shopify app dev` returns 403 "Cannot find a valid organization associated to this shop" | Stale auth or wrong org logged in | `shopify auth logout && shopify auth login` against the org-owning account; verify with `shopify app config link` |
| 2 | "Cannot read properties of null (reading 'X')" on CLI start | Corrupted `.shopify` cache or stale config | Back up the caches with `node <plugin-root>/scripts/reset-shopify-cache.mjs`, then run `shopify app dev --reset` |
| 3 | "Could not start Cloudflare tunnel: max retries reached" | Leftover `~/.cloudflared/config.yaml` from another project | `mv ~/.cloudflared/config.yaml ~/.cloudflared/config.yaml.bak`, or `shopify app dev --use-localhost` (CLI 3.80+) |
| 4 | Tunnel URL doesn't update in Partner Dashboard | CLI 3.x bug | `shopify app dev --reset --tunnel-url <fresh>` or upgrade CLI to latest |
| 5 | "Issues with valid certificates after the recent update" | Self-signed cert rotation on `--use-localhost` | `shopify app dev --use-localhost --localhost-port 3000` and re-trust the cert in Chrome (chrome://flags allow-insecure-localhost) |
| 6 | Hot reload broken for extensions | Known regression post new-Dev-Platform migration | `shopify app dev --reset`; if still broken, downgrade CLI one minor version |
| 7 | `shopify app dev` doesn't work with Plus dev stores | Known incompatibility, no fix | Create a Partner dev store for daily dev; only test Plus features manually on the Plus store |
| 8 | "No AppBridge context provided" on `<Modal>` or `<Titlebar>` | App Bridge v4 React Provider removed | Use as web components (`<ui-modal>`) **or** call `useAppBridge()` then `shopify.modal.show('id')` |
| 9 | App Bridge v4 fetch sending expired/undefined tokens | Browser caching old token; `X-Shopify-Retry-Invalid-Session-Request` not recovering | Replace custom `fetch` wrapper with App Bridge v4 `authenticatedFetch`; remove manual token storage |
| 10 | GraphQL returns 200 OK but data is missing | Throttling — body contains `errors[].extensions.code === "THROTTLED"` | Parse `extensions.cost.throttleStatus`; sleep `(requestedQueryCost - currentlyAvailable) / restoreRate` seconds; retry |
| 11 | `currentlyAvailable` dropped from 10000 to <100 unexpectedly | Bucket exhausted by previous expensive query | Add cost-budget middleware in front of every GraphQL client; never assume bucket state |
| 12 | Webhook handler returning 200 but Shopify keeps retrying | Took >5s to ACK | Move work to a queue (Inngest/SQS/BullMQ). ACK immediately after HMAC verify |
| 13 | Same `orders/create` webhook fires twice | Subscribed in both `shopify.app.toml` AND `shopifyApp({ webhooks })` | Pick one (toml is canonical in CLI 3.50+). Delete programmatic subs. `shopify app deploy`. Then `webhookSubscriptions(first: 250)` → delete orphans |
| 14 | Webhook signature verification fails | Express `body-parser` mutating raw body | `express.raw({ type: 'application/json' })` on webhook routes; in Remix, use `authenticate.webhook(request)` |
| 15 | Webhook returns 401 immediately | HMAC computed on parsed JSON instead of raw bytes | Compute HMAC on the raw request body buffer, not on `JSON.stringify(body)` |
| 16 | Remix `/auth/login` 404 on App Proxy calls | Wrong authenticate helper | Use `authenticate.public.appProxy(request)`, not `authenticate.admin(request)` |
| 17 | Login form appears on nested routes inside the embedded app | `authenticate.admin(request)` only called in root loader | Call `authenticate.admin(request)` in **every** loader/action, including children |
| 18 | REST API returns 401 then 302 loop after reinstall | Stale `Session` row with old scopes | Delete sessions for that shop; trigger reinstall; or use Token Exchange auth |
| 19 | App Store submission rejected: "Not authenticating with session tokens" | App still on redirect-based OAuth | Switch to Managed Install + Token Exchange; remove all `Redirect.dispatch` OAuth flows |
| 20 | Embedded app session breaks after ~24 hours | Session token expired; iframe can't redirect (X-Frame-Options: DENY) | Use App Bridge v4 `authenticatedFetch` (auto-refresh via Token Exchange); on 401 do `window.top.location.href`, not `window.location.href` |
| 21 | `[wasm-validator error in function 0]` on `shopify app deploy` (Rust) | `wasm-opt` choking on newer Rust features | Pin Rust to 1.84; init `shopify_function_wasm_api::init_panic_handler()` early; run `wasm-snip --snip-rust-panicking-code`; pre-run `wasm-opt -Os` locally |
| 22 | Vitest WASM tests fail; `dist/index.wasm` is base64 text not binary | CLI 3.93.0 regression | Downgrade to CLI 3.92.x or pin to a version after the fix; tracked in community.shopify.dev/t/33061 |
| 23 | Function fails silently on large carts | Hit 5ms / 20kb / determinism ceiling | Reduce input query fields; split into multiple functions; remove any non-deterministic calls |
| 24 | `appSubscriptionCreate` errors "Apps without a public distribution cannot use the Billing API" | Custom or unlisted draft | Set `distribution = "app_store"` in `shopify.app.toml`; create a draft listing (doesn't have to publish); redeploy |
| 25 | Billing test charge not approvable on Plus dev store | Known Plus-dev-store bug | Test billing on a Partner (non-Plus) dev store; document Plus-only flows separately |
| 26 | Polaris CSS missing after upgrade (e.g., `Polaris-TopBar__SearchField`) | Tree-shaker dropped `styles.css` | `import '@shopify/polaris/build/esm/styles.css'` exactly once at app root, in a non-shaken entry |
| 27 | Polaris tooltips broken | Polaris web components version mismatch | Pin to the Polaris version your app was built against; don't mix React Polaris and web-components Polaris |
| 28 | App proxy returns 200 but Shopify shows "Liquid error" | App proxy response not setting `Content-Type: application/liquid` | Set header `Content-Type: application/liquid` and return raw Liquid as string |
| 29 | App Review past 10-day SLA, no reviewer assigned | Known SLA slip in 2026 | Open Partner Support ticket; quote SLA from policy page; resubmit only if reviewer never assigned after 21 days |
| 30 | Rejected for "performance" with no specifics | Reviewer skim-rejection | Reply requesting specific repro steps with timestamps; attach Lighthouse scores from a Plus dev store |
---
## 3. Top 10 Dev Pains — Deep Dive
### 3.1 `shopify app dev` 403 org / tunnel failures
**Symptom (verbatim):** "After upgrading the CLI my `shopify app dev` returns 403 'Cannot find a valid organization associated to this shop' for multiple dev stores." (community.shopify.dev/t/34202)
Plus the cluster: "Could not start Cloudflare tunnel: max retries reached", "Issues with valid certificates after the recent update", "shopify app dev provides cryptic error message and fails" (github.com/Shopify/cli/issues/6522).
**Root cause:** The new Dev Platform migration changed how CLI links shops to organizations. Stale `~/.config/shopify/` state, an old `.shopify` directory in the project, a leftover `~/.cloudflared/config.yaml` from another project, or expired CLI auth all surface the same generic error.
**Exact fix sequence:**
```bash
# 1. Nuke stale local state
node <plugin-root>/scripts/reset-shopify-cache.mjs
# 2. Re-auth against the org that owns the dev store
shopify auth logout
shopify auth login
# 3. Re-link the app to confirm org
shopify app config link
# 4. If tunnel was the issue, side-step Cloudflare
mv ~/.cloudflared/config.yaml ~/.cloudflared/config.yaml.bak 2>/dev/null
shopify app dev --use-localhost
# 5. If certificate complaints persist on --use-localhost
shopify app dev --use-localhost --localhost-port 3000
# Then in Chrome: chrome://flags → enable "Allow invalid certificates for resources loaded from localhost"
```
**Prevention:** Pin CLI version per-project via `package.json`:
```json
"devDependencies": { "@shopify/cli": "3.92.0", "@shopify/app": "3.92.0" }
```
Don't upgrade CLI mid-sprint. Wait until a feature branch's merge window. Sources: github.com/Shopify/cli/issues/6522, community.shopify.dev/t/22830, community.shopify.dev/t/23075.
---
### 3.2 App Bridge v3 → v4 Re-architecture
**Symptom (verbatim):** "Uncaught Error: No AppBridge context provided — happens with `<Modal>` and `<Titlebar>` as React components, works fine when used as HTML tags." (github.com/Shopify/shopify-app-bridge/issues/340)
And: "App Bridge will no longer be offered via npm. There doesn't seem to be any mention of React compatibility... feels like a really big shift away from the React-first implementation." (github.com/Shopify/shopify-app-bridge/issues/219)
**Root cause:** v4 deleted the React Provider, removed most hooks, removed npm distribution. App Bridge is now a CDN-loaded global object. The React package still exists but is a thin shim around web components. `<Modal>` and `<Titlebar>` work as web components (`<ui-modal>`, `<ui-title-bar>`) without any Provider — but the React imports throw without it.
**Exact fix:**
1. Remove the v3 Provider entirely:
```tsx
// REMOVE
import { Provider } from '@shopify/app-bridge-react';
<Provider config={{ apiKey, host }}>...</Provider>
// REMOVE the npm dependency
// "@shopify/app-bridge": "3.x"
```
2. Add the CDN script tag to your root document (Remix `app/root.tsx`, Next.js `app/layout.tsx`):
```tsx
<script
src="https://cdn.shopify.com/shopifycloud/app-bridge.js"
data-api-key={process.env.SHOPIFY_API_KEY}
/>
```
3. Replace removed APIs with the `shopify` global:
```tsx
// v3
const app = useAppBridge();
const redirect = Redirect.create(app);
redirect.dispatch(Redirect.Action.REMOTE, url);
// v4
const shopify = useAppBridge();
shopify.toast.show('Saved');
shopify.modal.show('my-modal-id');
open(url, '_top'); // for top-level redirects
```
4. For modals/titlebars in React, use them as web components:
```tsx
<ui-modal id="confirm">
<p>Are you sure?</p>
<ui-title-bar title="Confirm">
<button variant="primary" onClick={() => shopify.modal.hide('confirm')}>OK</button>
</ui-title-bar>
</ui-modal>
```
5. Replace custom fetch wrappers with `authenticatedFetch`:
```tsx
const res = await shopify.fetch('/api/data'); // auto-refreshes via Token Exchange
```
**Prevention:** When App Bridge announces a major, freeze your version, build a migration branch, run the codemod, deploy to a staging app. Don't take a major mid-release. Source: shopify.dev/docs/api/app-bridge/migration-guide-react.
---
### 3.3 GraphQL Throttling Returns 200 OK (The Silent Prod Corruption Case)
**Symptom (verbatim):** "When your app spends more than it has, Shopify returns a 200 OK with a THROTTLED error in the response body. Yes — a 200, not a 429." (letstalkshop.com/blog/shopify-admin-graphql-rate-limits-2026)
Plus: "GraphQL Admin API rate limits — limits per query is 1000 but I have 10000 cost available, why does my query fail?" (community.shopify.com/t/192109)
**Root cause:** Shopify's GraphQL Admin API uses a bucket-based cost system, not a request-per-second rate. Every query has a cost. When you exceed the bucket you get HTTP 200 with `errors[].extensions.code === "THROTTLED"`. Your monitoring that alerts on 4xx/5xx never fires. Data silently goes missing. Downstream code thinks the API returned an empty result.
**Per-plan budgets:**
| Plan | Max bucket | Restore rate |
|---|---|---|
| Standard | 1000 | 50/sec |
| Advanced | 2000 | 100/sec |
| Plus | 10000 | 500/sec |
`first: 250` on a flat resource is cheap. `first: 250` with nested connections multiplies cost — sometimes 1000+ per query.
**Exact fix:** Wrap every GraphQL call with a cost-aware middleware:
```ts
async function shopifyGql<T>(client, query, variables): Promise<T> {
const res = await client.request(query, { variables });
// Check for throttling in the body (NOT status code)
const throttled = res.errors?.some(e => e.extensions?.code === 'THROTTLED');
if (throttled) {
const cost = res.extensions?.cost;
const wait = cost
? Math.ceil((cost.requestedQueryCost - cost.throttleStatus.currentlyAvailable) / cost.throttleStatus.restoreRate)
: 2;
await sleep(wait * 1000);
return shopifyGql(client, query, variables); // retry once
}
// Pre-emptive backoff: if we're <2x next query cost, slow down
const status = res.extensions?.cost?.throttleStatus;
if (status && status.currentlyAvailable < res.extensions.cost.requestedQueryCost * 2) {
await sleep(1000);
}
return res.data as T;
}
```
For bulk reads (>1000 items), don't paginate — use `bulkOperationRunQuery`:
```graphql
mutation {
bulkOperationRunQuery(query: """
{ products { edges { node { id title } } } }
""") { bulkOperation { id status } }
}
```
Then poll `currentBulkOperation` until `status: COMPLETED`, download the JSONL file from `url`.
**Prevention:** Log `extensions.cost` from every response. Alert on `currentlyAvailable < 20% of max`. Never trust HTTP status for Shopify GraphQL. Sources: shopify.engineering/rate-limiting-graphql-apis-calculating-query-complexity, shopify.dev/docs/api/usage/limits.
---
### 3.4 Webhook At-Least-Once + Double-Subscription Footgun
**Symptom (verbatim):** "Orders/Create webhook missing address field, and Shopify sends duplicate events on order/create." (community.shopify.com/t/306917)
And: "Duplicate webhook subscriptions commonly happen with Shopify embedded apps when webhooks are defined in both shopify.app.toml and programmatically via shopifyApp() — each subscription triggers a separate delivery." (hookdeck.com/webhooks/platforms/shopify-embedded-app-webhook-configuration)
**Root cause:** Two compounding problems:
1. **Delivery model:** Shopify is at-least-once, never exactly-once. Network blips trigger retries (8 retries over ~4 hours). Same event arrives 2-9 times.
2. **Configuration drift:** Devs declare webhooks in `shopify.app.toml` AND register them programmatically via `shopifyApp({ webhooks: { ORDERS_CREATE: { ... } } })`. Shopify treats these as separate subscriptions. Two records, two deliveries per real event.
Plus the 5-second ACK trap: handlers that block on DB writes get retried while the first call is still running.
**Exact fix — dedupe sources:**
1. Pick one source. In CLI 3.50+ the toml is canonical:
```toml
# shopify.app.toml
[[webhooks.subscriptions]]
topics = ["orders/create"]
uri = "https://myapp.com/webhooks/orders/create"
```
2. Remove every programmatic `webhookSubscriptions` from your `shopifyApp({...})` config.
3. Run `shopify app deploy` to sync.
4. Audit existing subscriptions and delete orphans:
```graphql
query { webhookSubscriptions(first: 250) { edges { node { id topic endpoint { __typename ... on WebhookHttpEndpoint { callbackUrl } } } } } }
```
For any duplicates: `webhookSubscriptionDelete(id: "...")`.
**Exact fix — survive at-least-once:**
```ts
// Express
app.post('/webhooks/orders/create',
express.raw({ type: 'application/json' }), // RAW body for HMAC
async (req, res) => {
// 1. Verify HMAC on raw body
const hmac = req.headers['x-shopify-hmac-sha256'];
const computed = crypto.createHmac('sha256', SECRET).update(req.body).digest('base64');
if (hmac !== computed) return res.status(401).end();
// 2. ACK immediately
res.status(200).end();
// 3. Idempotency on X-Shopify-Webhook-Id
const webhookId = req.headers['x-shopify-webhook-id'] as string;
const seen = await redis.set(`wh:${webhookId}`, '1', 'NX', 'EX', 86400 * 7);
if (seen !== 'OK') return; // already processed
// 4. Push to queue
await queue.add('orders/create', JSON.parse(req.body.toString()));
}
);
```
**Reconciliation cron:** Webhooks lie. Run a daily delta:
```graphql
query { orders(first: 250, query: "updated_at:>=YYYY-MM-DDTHH:MM:SSZ") { ... } }
```
Compare to your local DB. Backfill anything missing.
**Prevention:** One source of truth (toml). Idempotency key from `X-Shopify-Webhook-Id`. ACK before work. Queue everything. Source: shopify.dev/docs/apps/build/webhooks/best-practices, shopify.dev/docs/apps/build/webhooks/ignore-duplicates.
---
### 3.5 Remix Template Nested-Route Auth Break
**Symptom (verbatim):** "Authentication issues when navigating to nested routes — the login form is displayed even though navigation should work." (github.com/Shopify/shopify-app-template-remix/issues/599)
Plus: "Shopify Remix app in Production environment embedded issue — embedded app roots load but any sub-page selected via the side menu wants re-authentication." (community.shopify.com/t/382024) and "`shopify.authenticate` for Remix App Proxy kicking to an /auth/login 404." (issues/747)
**Root cause:** The default Remix template calls `authenticate.admin(request)` once in `app/routes/app.tsx` loader. Nested routes don't automatically inherit it. When the user navigates client-side via React Router, the child route's loader runs without the auth. The redirect to `/auth/login` happens — but for App Proxy routes there is no `/auth/login`, so you get 404.
**Exact fix:**
1. Call `authenticate.admin(request)` in **every** loader and action, not just the parent:
```tsx
// app/routes/app.products.tsx
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin } = await authenticate.admin(request);
// ... your loader logic
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { admin } = await authenticate.admin(request);
// ... your action logic
};
```
2. For App Proxy routes, use the public helper, not admin:
```tsx
// app/routes/proxy.coupon.tsx (mapped to App Proxy URL)
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { liquid, session } = await authenticate.public.appProxy(request);
return liquid('<p>Hello {{ shop.name }}</p>');
};
```
3. For checkout extension routes:
```tsx
const { sessionToken } = await authenticate.public.checkout(request);
```
4. For 302 loops after reinstall (scope mismatch):
```sql
DELETE FROM Session WHERE shop = 'my-shop.myshopify.com';
```
Then visit the app — Shopify will re-OAuth with current scopes.
5. For App Store submission rejection on "session tokens": switch to Managed Install + Token Exchange. In `shopify.app.toml`:
```toml
[access_scopes]
scopes = "read_products,write_orders"
use_legacy_install_flow = false
[auth]
redirect_urls = ["https://myapp.com/api/auth/callback"]
```
**Prevention:** Treat every loader/action as untrusted. Auth-call it explicitly. Don't trust inheritance. Source: github.com/Shopify/shopify-app-template-remix issues 432, 599, 747, 797, 993.
---
### 3.6 Rust Function wasm-validator + CLI 3.93.0 base64 Regression
**Symptom (verbatim):** "[wasm-validator error in function 0] — happens during the wasm-opt optimization step after I try to deploy my Rust function." (community.shopify.com/t/223885)
Plus: "CLI 3.93.0 regression: vitest WASM tests fail for Rust function extensions — base64 written to dist/index.wasm instead of raw binary." (community.shopify.dev/t/33061)
**Root cause:** Two separate but co-occurring issues:
1. `wasm-opt` (the optimizer Shopify runs before deploy) doesn't understand all features of newer Rust toolchains. Especially panic infrastructure compiled in by default.
2. CLI 3.93.0 changed the WASM output pipeline; for a brief window the build wrote base64-encoded text to `dist/index.wasm` instead of binary bytes, breaking vitest's WASM tests and breaking deploys.
**Exact fix:**
1. Pin Rust toolchain:
```toml
# rust-toolchain.toml at function root
[toolchain]
channel = "1.84"
targets = ["wasm32-wasip1"]
```
2. Init the Shopify-provided panic handler early in `main`:
```rust
use shopify_function_wasm_api::init_panic_handler;
#[shopify_function]
fn function(input: input::ResponseData) -> Result<output::FunctionResult> {
init_panic_handler();
// ... your logic
}
```
3. Strip Rust panic code (cuts WASM by ~30-50%):
```bash
cargo install wasm-snip
cargo build --target wasm32-wasip1 --release
wasm-snip target/wasm32-wasip1/release/your_function.wasm \
-o target/wasm32-wasip1/release/your_function.wasm \
--snip-rust-panicking-code
```
4. Run `wasm-opt -Os` yourself before `shopify app deploy` so errors surface locally:
```bash
wasm-opt -Os target/wasm32-wasip1/release/your_function.wasm \
-o target/wasm32-wasip1/release/your_function.wasm
```
5. For the CLI 3.93.0 regression: downgrade to 3.92.x or upgrade to the post-fix version:
```bash
npm install --save-dev @shopify/cli@3.92.0 @shopify/app@3.92.0
```
6. Verify your `dist/index.wasm` is binary, not text:
```bash
file dist/index.wasm # should say "WebAssembly (wasm) binary module"
```
**Prevention:** Pin Rust toolchain, pin CLI version, run `wasm-opt` locally, ship a CI step that runs `function-runner` on a fixture before deploy. Sources: community.shopify.com/t/223885, community.shopify.dev/t/33061, docs.rs/shopify_function_wasm_api.
---
### 3.7 Function 5ms / 20kb / Deterministic Ceiling
**Symptom (verbatim):** "Can the instruction and input size limits be raised for large orders?" — answer: no. (github.com/Shopify/function-examples/discussions/329)
Plus the cluster of "my function works in dev but fails silently on stores with 100k SKUs."
**Root cause:** Shopify Functions are hard-capped:
- **5ms** execution time per invocation
- **20kb** output JSON size
- **25** active discount functions per store (combined limit)
- **Deterministic**: no network, no clock, no randomness, no env reads
- Input query has a complexity ceiling — large catalogs explode silently
Devs hit these because they treat Functions like serverless lambdas. They aren't. They're WASM running in a sandbox.
**Exact fix:**
1. Minimize the input query — only request fields you'll branch on:
```graphql
# BAD — pulls everything
query Input { cart { lines { merchandise { ... on ProductVariant { product { ... } } } } } }
# GOOD — only the fields you need
query Input { cart { lines { id quantity merchandise { ... on ProductVariant { id } } } } }
```
2. Profile output size. If approaching 20kb, batch or split:
```rust
// Bad: returning 1000 separate discount applications
// Good: one ProductDiscountApplication with multiple variants
```
3. Determinism checklist:
- No `std::time::Instant::now()` — use timestamps from the input
- No `rand::random()` — seed from a deterministic value (e.g., order ID hash)
- No HTTP calls — pre-load via input query
- No `std::env` — Shopify won't expose env to functions
4. For data your function needs but can't fit in input: stash it in metafields on the store/product, request via input query.
5. Test before you deploy:
```bash
shopify app function run --input fixtures/large-cart.json
```
6. If you genuinely can't fit logic in 5ms/20kb, split into multiple Functions (cart, shipping, payment) — each gets its own budget.
**Prevention:** Build the test fixture for your worst-case cart on day 1. Run it in CI. If it fails the ceiling, you redesign now, not at launch. Source: shopify.dev/docs/api/functions/latest/discount, gadget.dev/blog/understanding-shopify-functions-part-2.
---
### 3.8 Billing API Fakeouts on Dev Stores
**Symptom (verbatim):** "Unable to approve Billing API test charges on Plus Development Stores — this issue appears specific to Plus Dev Stores created from the Dev Dashboard." (community.shopify.dev/t/23258)
Plus: `appSubscriptionCreate` returns "Apps without a public distribution cannot use the Billing API" (community.shopify.com/m-p/1757459) and "negative-duration billing cycle for subscription" (t/25346) and double-charge UI bugs.
**Root cause:** Billing API has several hardcoded preconditions that aren't documented in one place:
- App must have `distribution = "app_store"` set
- A draft listing must exist (doesn't have to be published)
- Plus dev stores have a specific bug approving test charges
- Custom-distribution apps can't use Billing API at all
**Exact fix:**
1. In `shopify.app.toml`:
```toml
[build]
include_config_on_deploy = true
[application]
distribution = "app_store"
```
2. In Partner Dashboard → your app → App listing → create a draft. Don't publish; just save.
3. Redeploy:
```bash
shopify app deploy
```
4. Test on a **Partner dev store**, not a Plus dev store. Create one specifically for billing flows:
```bash
shopify app dev --store=billing-test.myshopify.com
```
5. When creating subscriptions, set `test: true` so charges don't actually capture:
```graphql
mutation {
appSubscriptionCreate(
name: "Pro Plan"
returnUrl: "https://myapp.com/billing/callback"
test: true
lineItems: [{
plan: { appRecurringPricingDetails: { price: { amount: 29.99, currencyCode: USD }, interval: EVERY_30_DAYS } }
}]
) { confirmationUrl userErrors { field message } }
}
```
6. Handle the negative-duration edge case server-side:
```ts
const cycleEnd = new Date(subscription.currentPeriodEnd);
const cycleStart = new Date(subscription.currentPeriodStart);
if (cycleEnd < cycleStart) {
// Known Shopify bug. Use cycleStart + 30 days instead.
cycleEnd.setDate(cycleStart.getDate() + 30);
}
```
**Prevention:** Two dev stores: Partner non-Plus (daily dev + billing tests), Plus dev (Plus-specific feature checks only). Never test billing on the Plus one. Source: community.shopify.dev/t/23258, community.shopify.com/m-p/1757459.
---
### 3.9 Session Token 24h Expiry + Iframe Redirect Block
**Symptom (verbatim):** "I have an embedded app where after about 24 hours of having it opened, the session token expires and the app needs to be reopened." (community.shopify.com/c/shopify-apps/managing-embedded-app-user-session-lost/td-p/1038381)
Plus: "You can't perform a redirect from inside an iframe in the Shopify admin, due to X-Frame-Options: DENY restrictions on Shopify admin pages." (shopify.dev docs, quoted in dozens of threads)
**Root cause:** Embedded apps run in an iframe inside Shopify admin. Session tokens (JWTs) expire roughly daily. When they expire:
1. Your `fetch` returns 401
2. You try to redirect to OAuth to re-auth
3. Shopify admin sends `X-Frame-Options: DENY` on its OAuth endpoints
4. Browser blocks the iframe redirect
5. App appears frozen, user has to close and reopen
**Exact fix:**
1. Use App Bridge v4 `authenticatedFetch` — it auto-refreshes via Token Exchange:
```tsx
const shopify = useAppBridge();
const res = await shopify.fetch('/api/data');
// Behind the scenes: if token expired, fetches a new one via Token Exchange, retries
```
2. If you must implement yourself, on a 401, do a **top-level** redirect, not an iframe redirect:
```tsx
// WRONG — blocked by X-Frame-Options
window.location.href = '/auth/login';
// RIGHT — breaks out of iframe
if (window.top) {
window.top.location.href = '/auth/login';
} else {
window.location.href = '/auth/login';
}
```
3. Or use App Bridge's `Redirect` action which handles this for you:
```tsx
import { Redirect } from '@shopify/app-bridge/actions';
const app = createApp({...});
Redirect.create(app).dispatch(Redirect.Action.REMOTE, '/auth/login');
```
4. For App Store submission requirement of session-token auth: confirm Token Exchange is wired in your backend:
```ts
// Remix
const { admin, session } = await authenticate.admin(request);
// `session` was obtained via Token Exchange if Managed Install is on
```
5. Verify your CSP allows Shopify framing (Shopify auto-injects but check):
```
Content-Security-Policy: frame-ancestors https://*.myshopify.com https://admin.shopify.com;
```
**Prevention:** Default to App Bridge v4 `authenticatedFetch`. Never hand-roll session token storage in localStorage. Always test the 24h scenario explicitly with `Date.now() + 25h` mocking. Source: shopify.dev/docs/apps/build/authentication-authorization/session-tokens/set-up-session-tokens, community.shopify.dev/t/32004.
---
### 3.10 App Review SLA Blown — What to Do
**Symptom (verbatim):** "I submitted my app for review in January 2026 when Shopify's posted SLA was 8–10 days. I did not get any update until over 30 days after submission (3x the SLA)." (community.shopify.dev/t/32259)
Plus: "Frustration with app review process — reviewers taking 2+ weeks just to get assigned, then long back-and-forth where they don't read emails." (community.shopify.dev/t/31784) and "Application review rejected — but I can't tell why." (community.shopify.dev/t/17950)
**Root cause:** Shopify's app review queue has been backed up since early 2026. Posted SLA is 8-10 days; actual is 21-45 days. Reviewers skim-reject for vague reasons. Replies often go unread for a week.
**Exact action plan:**
1. **Before submission — pre-flight the Top 10 rejection reasons** (shopify.dev/docs/apps/store/common-rejections):
- GDPR mandatory webhooks present and responding 200: `customers/data_request`, `customers/redact`, `shop/redact`
- Session token auth (not redirect OAuth) — required since 2024
- Embedded apps must use App Bridge v4
- Listing screenshots exactly 1600×900, no Shopify logos, no competitor names
- Pricing page shows actual prices, not "Contact us"
- Demo video shows install → core flow → uninstall in <3 minutes
- Privacy policy URL responds 200 and matches what's in the app
- Performance: app must score 70+ on Lighthouse Performance for embedded admin
- All scopes used; remove unused scopes from `shopify.app.toml`
- Onboarding has clear next-step CTA after install
2. **At submission:** Record a reviewer-facing screencast (3-5 min) that walks the reviewer through install, core feature, uninstall. Caption every step. Reviewers skim — make the value un-missable.
3. **If past 14 days with no reviewer assigned:** Open a Partner Support ticket. Subject: "App review past SLA — request reviewer assignment". Body: app handle, submission date, SLA reference. Don't ask twice; once is enough.
4. **If past 21 days:** Escalate via the Partner Slack (if you're in it) or Partner Success Manager (if you have one). If neither, post on the dev forum at community.shopify.dev — Shopify staff monitor it.
5. **If rejected with vague reason** ("performance issues" with no specifics):
```
Hi [reviewer], thanks for the review. Could you share specific repro steps?
For "performance issues" I'd appreciate:
- The exact admin page where you saw the issue
- Browser + screen size
- Time of day (UTC)
- Network conditions if applicable
I'll fix and resubmit within 48 hours of your response.
```
Don't argue. Don't restate features. Ask for specifics. Wait for response.
6. **If rejected for a real reason:** Fix, document the fix in your resubmission notes, attach a video of the fix in action.
7. **Common silent-killers most devs miss:**
- GDPR webhooks return 500 (not implemented at all). This is the #1 silent reject reason. Verify with `shopify webhook trigger customers/data_request --address=https://yourapp.com/webhooks/gdpr/customers_data_request`.
- Privacy policy URL 404s in production
- Demo video unlisted but URL doesn't work for reviewer
- Test charge required but billing not set up (see §3.8)
**Prevention:** Submit on a Tuesday morning UTC (highest reviewer activity), submit with a perfect screencast, and have a 48-hour SLA on your end for responding to reviewer feedback. Source: community.shopify.dev/t/32259, community.shopify.dev/t/31784, shopify.dev/docs/apps/store/common-rejections.
---
## 4. Decision Tree
Run this in order. Stop at the first match.
```
START
│
├─ Is the user blocked from any progress (CLI won't start)?
│ │
│ ├─ "Cannot find a valid organization" or 403?
│ │ → §3.1 (auth logout/login + reset state)
│ │
│ ├─ "Could not start Cloudflare tunnel" or tunnel URL stale?
│ │ → §3.1 tunnel section (rename ~/.cloudflared/config.yaml or --use-localhost)
│ │
│ ├─ "Cannot read properties of null" or generic CLI crash?
│ │ → back up caches with reset-shopify-cache.mjs, then run shopify app dev --reset
│ │
│ └─ Plus dev store specific?
│ → §3.1 Plus section (use Partner dev store for dev loop)
│
├─ Is auth broken in the running app?
│ │
│ ├─ "No AppBridge context provided"?
│ │ → §3.2 (v3→v4 migration, remove Provider, use CDN + global)
│ │
│ ├─ Token expired after ~24h, app frozen?
│ │ → §3.9 (authenticatedFetch + top-level redirect)
│ │
│ ├─ /auth/login 404 on App Proxy?
│ │ → §3.5 (use authenticate.public.appProxy)
│ │
│ ├─ Login form on nested routes?
│ │ → §3.5 (call authenticate.admin in every loader)
│ │
│ └─ 302 loop after reinstall?
│ → §3.5 (delete sessions for shop, re-OAuth)
│
├─ Is production data going missing or being duplicated?
│ │
│ ├─ GraphQL "succeeds" with 200 but data is absent?
│ │ → §3.3 (parse extensions.cost, detect THROTTLED in body)
│ │
│ ├─ Webhook handler running twice per event?
│ │ → §3.4 (dedupe sources, idempotency key, queue offload)
│ │
│ └─ Webhook returning 401, all rejected?
│ → Diagnostic row 14-15 (HMAC on raw body)
│
├─ Is a Function failing to deploy or running wrong?
│ │
│ ├─ [wasm-validator error] on deploy?
│ │ → §3.6 (pin Rust 1.84, init panic handler, wasm-snip)
│ │
│ ├─ CLI 3.93.0, base64 in dist/index.wasm?
│ │ → §3.6 (downgrade CLI to 3.92.x)
│ │
│ └─ Function fails on large carts but works on small?
│ → §3.7 (5ms/20kb/deterministic — reduce input query, split functions)
│
├─ Is a Billing test charge failing?
│ │
│ ├─ "Apps without a public distribution cannot use the Billing API"?
│ │ → §3.8 (set distribution=app_store, create draft listing)
│ │
│ ├─ Plus dev store specifically?
│ │ → §3.8 (test on Partner dev store instead)
│ │
│ └─ Negative-duration billing cycle?
│ → §3.8 (clamp client-side: cycleEnd = cycleStart + 30 days)
│
├─ Is App Store review the blocker?
│ → §3.10 (pre-flight Top-10, ask for specifics, escalate at 21d)
│
└─ None of the above — drop to row scan in §2 Diagnostic Table.
```
---
## 5. Quick-Reference Commands
```bash
# Nuke and restart
node <plugin-root>/scripts/reset-shopify-cache.mjs && shopify app dev --reset
# Bypass Cloudflare tunnel
mv ~/.cloudflared/config.yaml ~/.cloudflared/config.yaml.bak
shopify app dev --use-localhost
# Re-auth
shopify auth logout && shopify auth login && shopify app config link
# Pin CLI version
npm install --save-dev @shopify/cli@3.92.0 @shopify/app@3.92.0
# List webhook subscriptions (to find duplicates)
shopify app generate extension # or via GraphiQL → webhookSubscriptions(first: 250)
# Run a Function locally with a fixture
shopify app function run --input fixtures/cart.json
# Trigger a webhook locally for testing
shopify webhook trigger orders/create --address=http://localhost:3000/webhooks/orders/create
# Build + strip a Rust function before deploy
cargo build --target wasm32-wasip1 --release
wasm-snip target/wasm32-wasip1/release/fn.wasm -o target/wasm32-wasip1/release/fn.wasm --snip-rust-panicking-code
wasm-opt -Os target/wasm32-wasip1/release/fn.wasm -o target/wasm32-wasip1/release/fn.wasm
```
---
## 6. Sources (URLs Cited Inline Above)
CLI / Dev loop:
- github.com/Shopify/cli/issues/6522
- community.shopify.dev/t/shopify-app-dev-returns-403-cannot-find-a-valid-organization-associated-to-this-shop-for-multiple-dev-stores/34202
- community.shopify.dev/t/shopify-cli-reloading-broken-after-migration-to-new-dev-platform/22830
- community.shopify.dev/t/issues-with-valid-certificates-after-the-recent-update/23075
- community.shopify.dev/t/shopify-app-dev-doesnt-work-with-plus-development-stores/23471
Cloudflare tunnel:
- community.shopify.dev/t/cloudflare-tunnel-shows-healthy-but-shopify-app-wont-load/9865
- community.shopify.dev/t/cloudflare-tunnel-error-when-running-shopify-app-dev-persistent-since-1-week/24200
- community.shopify.dev/t/shopify-app-dev-doest-update-cloudflare-tunnel-url-on-dev-partner-dashboard/22315
- shopify.dev/docs/apps/build/cli-for-apps/networking-options
App Bridge:
- github.com/Shopify/shopify-app-bridge/issues/340
- github.com/Shopify/shopify-app-bridge/issues/219
- community.shopify.dev/t/app-bridge-v4-cdn-automatic-fetch-authorization-sends-expired-undefined-tokens-x-shopify-retry-invalid-session-request-doesnt-recover/32004
- shopify.dev/docs/api/app-bridge/migration-guide-react
Remix auth:
- github.com/Shopify/shopify-app-template-remix/issues/599
- github.com/Shopify/shopify-app-template-remix/issues/747
- github.com/Shopify/shopify-app-template-remix/issues/797
- github.com/Shopify/shopify-app-template-remix/issues/993
- community.shopify.com/t/shopify-remix-app-in-production-environment-embedded-issue/382024
GraphQL throttling:
- letstalkshop.com/blog/shopify-admin-graphql-rate-limits-2026
- community.shopify.com/t/graphql-admin-api-rate-limits-limits-per-query-is-1000-but-i-have-10000-cost-available/192109
- shopify.dev/docs/api/usage/limits
- shopify.engineering/rate-limiting-graphql-apis-calculating-query-complexity
Webhooks:
- hookdeck.com/webhooks/platforms/how-to-handle-duplicate-shopify-webhook-events
- hookdeck.com/webhooks/platforms/shopify-embedded-app-webhook-configuration
- shopify.dev/docs/apps/build/webhooks/best-practices
- shopify.dev/docs/apps/build/webhooks/ignore-duplicates
- community.shopify.com/t/orders-create-webhook-missing-address-field-and-shopify-send-duplicate-events-on-order-create/306917
Rust + Functions:
- community.shopify.com/t/cant-deploy-shopify-function-written-in-rust/223885
- community.shopify.dev/t/cli-3-93-0-regression-vitest-wasm-tests-fail-for-rust-function-extensions-base64-written-to-dist-index-wasm-instead-of-raw-binary/33061
- community.shopify.dev/t/shopify-functions-and-rust-1-84/5570
- docs.rs/shopify_function_wasm_api
- github.com/Shopify/function-examples/discussions/329
Billing:
- community.shopify.dev/t/unable-to-approve-billing-api-test-charges-on-plus-development-stores/23258
- community.shopify.com/c/technical-q-a/apps-without-a-public-distribution-cannot-use-the-billing-api/m-p/1757459
- community.shopify.dev/t/possible-bug-negative-duration-billing-cycle-for-subscription/25346
Session tokens / iframe:
- community.shopify.com/c/shopify-apps/managing-embedded-app-user-session-lost/td-p/1038381
- shopify.dev/docs/apps/build/authentication-authorization/session-tokens/set-up-session-tokens
App Review:
- community.shopify.dev/t/warning-shopify-app-store-review-process/32259
- community.shopify.dev/t/frustration-with-app-review-process/31784
- community.shopify.dev/t/application-review-rejected/17950
- shopify.dev/docs/apps/store/common-rejections
hydrogen-storefront24.3 KB
---
name: hydrogen-storefront
description: "Use this skill for Hydrogen 2026 Storefront Framework. Triggers include: 'hydrogen storefront', 'hydrogen 2026', 'hydrogen remix', 'shopify hydrogen framework', 'hydrogen setup scaffold', 'hydrogen useCart hook', 'hydrogen createCartHandler', 'hydrogen Customer Account API', 'hydrogen caching strategies', 'hydrogen oxygen deployment', 'hydrogen cli commands', 'hydrogen storefront client', 'hydrogen product page', 'hydrogen collection page', 'hydrogen checkout', 'hydrogen admin api', 'hydrogen queueApi', 'hydrogen analytics', 'hydrogen search implementation', 'hydrogen variants and options', 'hydrogen localization i18n', 'hydrogen performance optimization', 'hydrogen seo structured data', 'hydrogen third party scripts', 'hydrogen css styling tailwind', 'hydrogen testing'."
---
# Hydrogen 2026 Storefront Framework
Hydrogen is Shopify's Remix-based framework for building fast, custom storefronts. This skill covers Hydrogen 2026 project setup, the Storefront API client, cart management with hooks, Customer Account API, caching strategies, Oxygen deployment, CLI commands, and real-world PDP and collection page patterns.
## When Asked
**When asked to scaffold a new Hydrogen storefront:**
Provide the npm create @shopify/hydrogen command, explain project structure, and show how to configure .env with Storefront API credentials.
**When asked to fetch and display products:**
Use the Storefront API via the storefront client (GraphQL query), show how to load product details, variants, and media; explain caching strategies.
**When asked to implement a shopping cart:**
Use createCartHandler for backend cart mutations, useCart hook for frontend state, and explain line item management, discounts, and checkout flow.
**When asked about Customer Account API:**
Explain how to enable, authenticate with OAuth, and use for customer login, order history, account details, and profile updates.
**When asked to optimize performance and caching:**
Discuss Cache-Control headers, Oxygen runtime caching, request coalescing, and streaming SSR; provide cache strategy examples.
**When asked to deploy to Oxygen:**
Show CLI deployment steps (hydrogen deploy), environment variable setup, monitoring, and rollback procedures.
**When asked to implement search or filtering:**
Use Hydrogen Search API, faceting, filtering by attribute, and show autocomplete and collection filter patterns.
---
## Hydrogen 2026 Architecture Overview
Hydrogen provides:
- **Remix Framework**: Server-side rendering (SSR), file-based routing, loader/action functions, Remix utilities
- **Storefront API Client**: GraphQL client for fetching product, collection, cart, order, and customer data
- **useCart Hook**: Client-side cart state management with add-to-cart, update, remove operations
- **createCartHandler**: Server-side cart mutations (create, update, discount, checkout)
- **Customer Account API**: OAuth-based customer login, order history, account endpoints
- **Caching**: Cache-Control headers, Oxygen response caching, request deduplication
- **Oxygen Platform**: Serverless execution environment with global edge cache
- **CLI**: `hydrogen dev`, `hydrogen preview`, `hydrogen deploy` commands
- **Analytics & Reporting**: Built-in Oxygen analytics, custom event tracking
### Installation & Project Setup
```bash
npm create @shopify/hydrogen@latest my-store -- --language TypeScript
cd my-store
npm install
npm run dev
```
This generates:
```
my-store/
├── app/
│ ├── components/ # Reusable React components
│ ├── routes/ # File-based routing (Remix)
│ ├── lib/ # Utility functions, API clients
│ └── root.tsx # Root layout
├── public/
├── .env # Storefront API token, store domain
├── hydrogen.config.ts # Hydrogen config
├── remix.config.js # Remix config (SSR, build)
├── package.json
└── tsconfig.json
```
### Environment Setup
```bash
# .env
PRIVATE_STOREFRONT_API_TOKEN=your_token_here
PUBLIC_STORE_DOMAIN=your-store.myshopify.com
SESSION_SECRET=random_string_min_32_chars
PRIVATE_CUSTOMER_ACCOUNT_API_TOKEN=customer_token
PUBLIC_CUSTOMER_ACCOUNT_API_URL=https://shopifyid.com/oauth/authorize
```
---
## Storefront API Client
### Initialize & Query
```typescript
// app/lib/shopify.server.ts
import { createStorefrontClient } from '@shopify/hydrogen';
export const storefront = createStorefrontClient({
apiUrl: `https://${process.env.PUBLIC_STORE_DOMAIN}/api/2024-01/graphql.json`,
apiVersion: '2024-01',
privateStorefrontToken: process.env.PRIVATE_STOREFRONT_API_TOKEN!,
});
```
### Fetch Product Details
```typescript
// app/routes/products/$handle.tsx
import { json, type LoaderFunctionArgs } from '@shopify/remix-oxygen';
import { useLoaderData } from '@remix-run/react';
import { storefront } from '~/lib/shopify.server';
const PRODUCT_QUERY = `
query getProduct($handle: String!) {
product(handle: $handle) {
id
title
description
handle
vendor
priceRange {
minVariantPrice {
amount
currencyCode
}
maxVariantPrice {
amount
currencyCode
}
}
variants(first: 250) {
edges {
node {
id
title
availableForSale
selectedOptions {
name
value
}
priceV2 {
amount
currencyCode
}
image {
url
altText
}
}
}
}
images(first: 10) {
edges {
node {
url
altText
}
}
}
}
}
`;
export async function loader({ params, context }: LoaderFunctionArgs) {
const { product } = await storefront.query(PRODUCT_QUERY, {
variables: { handle: params.handle },
cache: context.storefront.CacheShort(),
});
if (!product) {
throw new Response('Product not found', { status: 404 });
}
return json({ product });
}
export default function ProductPage() {
const { product } = useLoaderData<typeof loader>();
return (
<div>
<h1>{product.title}</h1>
<p>{product.description}</p>
<div className="price-range">
${product.priceRange.minVariantPrice.amount} -
${product.priceRange.maxVariantPrice.amount}
</div>
{product.images.edges.map(({ node: image }) => (
<img key={image.url} src={image.url} alt={image.altText} />
))}
</div>
);
}
```
### Fetch Collections
```typescript
const COLLECTIONS_QUERY = `
query getCollections($first: Int!) {
collections(first: $first) {
edges {
node {
id
title
handle
image {
url
altText
}
}
}
}
}
`;
export async function loader({ context }: LoaderFunctionArgs) {
const { collections } = await storefront.query(COLLECTIONS_QUERY, {
variables: { first: 20 },
cache: context.storefront.CacheLong(),
});
return json({ collections });
}
```
---
## Cart Management
### useCart Hook (Client-Side)
```typescript
// app/hooks/useCart.ts
import { useContext } from 'react';
import { CartContext } from '~/context/CartContext';
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within CartProvider');
}
return context;
}
// Usage in component
import { useCart } from '~/hooks/useCart';
export function AddToCartButton({ variantId, quantity = 1 }) {
const { addToCart, isLoading } = useCart();
const handleClick = async () => {
await addToCart({
variantId,
quantity,
});
// Show success toast
};
return (
<button onClick={handleClick} disabled={isLoading}>
{isLoading ? 'Adding...' : 'Add to Cart'}
</button>
);
}
```
### createCartHandler (Server-Side)
```typescript
// app/lib/cart.server.ts
import { createCartHandler } from '@shopify/hydrogen';
import { storefront } from './shopify.server';
const CREATE_CART_MUTATION = `
mutation createCart($input: CartInput!) {
cartCreate(input: $input) {
cart {
id
checkoutUrl
lines(first: 10) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
priceV2 {
amount
currencyCode
}
}
}
}
}
}
}
}
}
`;
export const cartHandler = createCartHandler({
storefront,
getCartId: async (request) => {
// Retrieve cart ID from session or cookie
const cartId = request.headers.get('x-cart-id');
return cartId;
},
setCartId: async (request, cartId) => {
// Store cart ID in session/cookie
// This is called after cart is created
},
cartQueryFragment: `
fragment CartApiFragment on Cart {
id
checkoutUrl
totalQuantity
cost {
totalAmount {
amount
currencyCode
}
subtotalAmount {
amount
}
totalTaxAmount {
amount
}
totalDutyAmount {
amount
}
}
lines(first: $lineLimit) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
}
}
merchandise {
... on ProductVariant {
id
title
sku
priceV2 {
amount
}
image {
url
altText
}
}
}
}
}
}
}
`,
});
// Usage in action handler
export async function action({ request, context }: ActionFunctionArgs) {
const cart = await cartHandler.queryCart(request, {
variables: { lineLimit: 100 },
});
if (request.method === 'POST') {
const formData = await request.formData();
const variantId = formData.get('variantId');
const quantity = parseInt(formData.get('quantity') || '1', 10);
const updatedCart = await cartHandler.addToCart(request, {
lines: [{ merchandiseId: variantId, quantity }],
});
return json({ cart: updatedCart });
}
return json({ cart });
}
```
### Cart Context Provider
```typescript
// app/context/CartContext.tsx
import { createContext, ReactNode, useState } from 'react';
interface CartContextType {
cart: any | null;
addToCart: (args: any) => Promise<void>;
removeFromCart: (lineId: string) => Promise<void>;
updateQuantity: (lineId: string, quantity: number) => Promise<void>;
isLoading: boolean;
}
export const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [cart, setCart] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const addToCart = async ({ variantId, quantity }: any) => {
setIsLoading(true);
const response = await fetch('/cart', {
method: 'POST',
body: JSON.stringify({ variantId, quantity }),
});
const { cart: newCart } = await response.json();
setCart(newCart);
setIsLoading(false);
};
const removeFromCart = async (lineId: string) => {
setIsLoading(true);
const response = await fetch('/cart', {
method: 'DELETE',
body: JSON.stringify({ lineId }),
});
const { cart: newCart } = await response.json();
setCart(newCart);
setIsLoading(false);
};
return (
<CartContext.Provider
value={{ cart, addToCart, removeFromCart, isLoading }}
>
{children}
</CartContext.Provider>
);
}
```
---
## Customer Account API
### Enable & Setup
```typescript
// .env
PUBLIC_CUSTOMER_ACCOUNT_API_URL=https://shopifyid.com/oauth/authorize
PRIVATE_CUSTOMER_ACCOUNT_API_TOKEN=your_token
```
### Customer Login & Auth
```typescript
// app/routes/account/login.tsx
import { redirect } from '@shopify/remix-oxygen';
export async function loader({ context }: LoaderFunctionArgs) {
const customerAccessToken = await getCustomerAccessToken(context);
if (customerAccessToken) {
return redirect('/account/profile');
}
return null;
}
export async function action({ request, context }: ActionFunctionArgs) {
if (request.method === 'POST') {
const formData = await request.formData();
const email = formData.get('email');
const password = formData.get('password');
const { customerAccessToken, customerUserErrors } =
await context.storefront.mutate(CUSTOMER_LOGIN_MUTATION, {
variables: { email, password },
});
if (customerAccessToken?.accessToken) {
// Store token in session
const session = await getSession(request.headers.get('cookie'));
session.set('customerAccessToken', customerAccessToken.accessToken);
return redirect('/account/profile', {
headers: { 'Set-Cookie': await commitSession(session) },
});
}
return json({ errors: customerUserErrors });
}
}
export default function LoginPage() {
return (
<form method="post">
<input type="email" name="email" placeholder="Email" required />
<input
type="password"
name="password"
placeholder="Password"
required
/>
<button type="submit">Sign In</button>
</form>
);
}
const CUSTOMER_LOGIN_MUTATION = `
mutation customerAccessTokenCreate(
$input: CustomerAccessTokenCreateInput!
) {
customerAccessTokenCreate(input: $input) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`;
```
### Fetch Customer Profile
```typescript
// app/routes/account/profile.tsx
const CUSTOMER_QUERY = `
query getCustomer($customerAccessToken: String!) {
customer(customerAccessToken: $customerAccessToken) {
id
email
firstName
lastName
phone
defaultAddress {
id
formatted
address1
address2
city
province
country
zip
}
orders(first: 10) {
edges {
node {
id
orderNumber
processedAt
totalPriceSet {
shopMoney {
amount
currencyCode
}
}
lineItems(first: 5) {
edges {
node {
title
quantity
originalTotalSet {
shopMoney {
amount
}
}
}
}
}
}
}
}
}
}
`;
export async function loader({ request, context }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('cookie'));
const customerAccessToken = session.get('customerAccessToken');
if (!customerAccessToken) {
return redirect('/account/login');
}
const { customer } = await context.storefront.query(CUSTOMER_QUERY, {
variables: { customerAccessToken },
cache: context.storefront.CacheShort(),
});
return json({ customer });
}
export default function ProfilePage() {
const { customer } = useLoaderData<typeof loader>();
return (
<div>
<h1>Welcome, {customer.firstName}</h1>
<p>Email: {customer.email}</p>
<p>Phone: {customer.phone}</p>
<h2>Recent Orders</h2>
<ul>
{customer.orders.edges.map(({ node: order }) => (
<li key={order.id}>
Order #{order.orderNumber} - $
{order.totalPriceSet.shopMoney.amount}
</li>
))}
</ul>
</div>
);
}
```
---
## Caching Strategies
### Cache-Control Headers
```typescript
// app/lib/cache.server.ts
export const CACHE_SHORT = () => ({
'Cache-Control': 'public, max-age=3600, s-maxage=3600', // 1 hour
});
export const CACHE_LONG = () => ({
'Cache-Control': 'public, max-age=86400, s-maxage=86400', // 24 hours
});
export const CACHE_NONE = () => ({
'Cache-Control': 'no-cache, no-store, must-revalidate',
});
// Usage in loader
export async function loader({ context }: LoaderFunctionArgs) {
const { product } = await storefront.query(PRODUCT_QUERY, {
variables: { handle },
cache: context.storefront.CacheShort(),
});
return json(
{ product },
{
headers: CACHE_SHORT(),
}
);
}
```
### Request Coalescing
Hydrogen automatically deduplicates identical requests made within the same render, preventing unnecessary API calls:
```typescript
// Both calls return same result without extra API calls
const [product1, product2] = await Promise.all([
storefront.query(PRODUCT_QUERY, { variables: { handle: 'widget-a' } }),
storefront.query(PRODUCT_QUERY, { variables: { handle: 'widget-a' } }),
]);
```
### Stale-While-Revalidate
```typescript
export const CACHE_SWR = () => ({
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
});
```
---
## Oxygen Deployment
### Build & Deploy
```bash
# Build locally
npm run build
# Deploy to Oxygen
hydrogen deploy
# Deploy with custom environment name
hydrogen deploy --env=staging
# View deployment logs
hydrogen deploy --logs
```
### Configuration
```typescript
// hydrogen.config.ts
import { defineConfig } from '@shopify/hydrogen/config';
export default defineConfig({
storefront: {
id: 'your-storefront-id',
title: 'My Store',
apiUrl: 'https://your-store.myshopify.com/api/2024-01/graphql.json',
},
oxygen: {
preloadRequestCookie: [],
},
});
```
### Environment Variables in Oxygen
Set via dashboard or CLI:
```bash
hydrogen deploy --set PRIVATE_STOREFRONT_API_TOKEN=token_value
```
---
## Hydrogen CLI Commands
```bash
# Local development
hydrogen dev # Start dev server (localhost:3000)
hydrogen dev --port 8080 # Custom port
# Preview production build
hydrogen preview # Simulate production locally
# Build
npm run build # Build for production
# Deploy
hydrogen deploy # Deploy to Oxygen
hydrogen deploy --env=staging # Deploy to named environment
hydrogen deploy --logs # Show deployment logs
# Analytics
hydrogen analyze # Analyze bundle size and performance
# Pull remote config
hydrogen config pull # Fetch Storefront API config from dashboard
```
---
## Worked Example: Product Details Page (PDP)
```typescript
// app/routes/products/$handle.tsx
import { json, type LoaderFunctionArgs } from '@shopify/remix-oxygen';
import { useLoaderData } from '@remix-run/react';
import { useState } from 'react';
import { storefront } from '~/lib/shopify.server';
import { AddToCartButton } from '~/components/AddToCartButton';
const PRODUCT_QUERY = `
query getProduct($handle: String!) {
product(handle: $handle) {
id
title
description
handle
vendor
priceRange {
minVariantPrice { amount currencyCode }
maxVariantPrice { amount currencyCode }
}
options {
name
values
}
variants(first: 250) {
edges {
node {
id
title
availableForSale
selectedOptions {
name
value
}
priceV2 {
amount
currencyCode
}
image {
url
altText
width
height
}
}
}
}
images(first: 20) {
edges {
node {
url
altText
width
height
}
}
}
seo {
title
description
}
}
}
`;
export async function loader({ params, context }: LoaderFunctionArgs) {
const { product } = await storefront.query(PRODUCT_QUERY, {
variables: { handle: params.handle },
cache: context.storefront.CacheShort(),
});
if (!product) {
throw new Response('Not Found', { status: 404 });
}
return json(
{ product },
{
headers: {
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
},
}
);
}
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
{ title: data?.product?.seo?.title || data?.product?.title },
{ name: 'description', content: data?.product?.seo?.description },
];
};
export default function ProductPage() {
const { product } = useLoaderData<typeof loader>();
const [selectedVariant, setSelectedVariant] = useState(
product.variants.edges[0].node
);
const [selectedOptions, setSelectedOptions] = useState<
Record<string, string>
>({});
const handleOptionChange = (optionName: string, value: string) => {
const updated = { ...selectedOptions, [optionName]: value };
setSelectedOptions(updated);
// Find matching variant
const matchingVariant = product.variants.edges.find(({ node }) =>
node.selectedOptions.every(
(opt) => updated[opt.name] === opt.value
)
)?.node;
if (matchingVariant) {
setSelectedVariant(matchingVariant);
}
};
return (
<main className="product-page">
<section className="gallery">
<img
src={selectedVariant.image?.url}
alt={selectedVariant.image?.altText}
width={selectedVariant.image?.width}
height={selectedVariant.image?.height}
/>
</section>
<section className="details">
<h1>{product.title}</h1>
<p className="vendor">{product.vendor}</p>
<div className="price">
<span className="amount">${selectedVariant.priceV2.amount}</span>
</div>
<div className="description">
{product.description}
</div>
{product.options.map((option) => (
<fieldset key={option.name}>
<legend>{option.name}</legend>
<div className="options">
{option.values.map((value) => (
<label key={value}>
<input
type="radio"
name={option.name}
value={value}
checked={selectedOptions[option.name] === value}
onChange={() => handleOptionChange(option.name, value)}
/>
{value}
</label>
))}
</div>
</fieldset>
))}
<AddToCartButton
variantId={selectedVariant.id}
disabled={!selectedVariant.availableForSale}
/>
</section>
</main>
);
}
```
---
## Performance Optimization Tips
- Use `CacheShort()` for product data (1 hour), `CacheLong()` for collections (24 hours)
- Enable streaming SSR for faster Time to First Byte (TTFB)
- Use `defer()` for non-critical data (recommendations, related products)
- Optimize images with responsive srcset and lazy loading
- Use Code Splitting for route components
- Monitor Core Web Vitals in Oxygen Analytics dashboard
- Use `<Image>` component from Hydrogen for automatic optimization
---
## Testing
```typescript
// app/__tests__/routes/products/$handle.test.tsx
import { loader } from '~/routes/products/$handle';
describe('Product Page Loader', () => {
it('fetches product data', async () => {
const mockContext = {
storefront: {
query: vi.fn().mockResolvedValue({
product: { id: '123', title: 'Test Product' },
}),
},
};
const result = await loader({
params: { handle: 'test-product' },
context: mockContext,
});
expect(result).toBeDefined();
expect(mockContext.storefront.query).toHaveBeenCalled();
});
});
```
---
## Common Patterns Checklist
- [ ] Initialize Storefront API client with token and domain
- [ ] Use `CacheShort()` / `CacheLong()` for loader queries
- [ ] Fetch product variants and options for variant selection UI
- [ ] Implement cart add/update/remove via createCartHandler
- [ ] Wrap cart functionality with useCart hook
- [ ] Enable Customer Account API for customer login
- [ ] Set Cache-Control headers on response.json()
- [ ] Use file-based routing (Remix conventions)
- [ ] Test queries locally before deploying
- [ ] Monitor Oxygen Analytics for performance
- [ ] Use hydrogen preview to test production build locally
- [ ] Set environment variables via hydrogen deploy --set
liquid-themes24.1 KB
---
name: liquid-themes
description: "Use this skill for Liquid Theme Development (Online Store 2.0). Triggers include: 'liquid theme development', 'shopify theme online store 2.0', 'liquid section schema', 'liquid blocks', 'liquid filters', 'liquid objects', 'json template shopify', 'theme preset', 'liquid include snippet', 'theme-check linting', 'liquid forloop iteration', 'liquid if conditions', 'liquid assign variable', 'shopify theme app extension', 'liquid capture variable', 'theme metafields', 'liquid date filter', 'liquid money filter', 'liquid array filters', 'liquid string manipulation', 'liquid product page', 'liquid collection page', 'liquid cart page', 'liquid header footer', 'liquid for loop break continue', 'liquid unless statement', 'liquid case when'."
---
# Liquid Theme Development (Online Store 2.0)
Liquid is Shopify's templating language for building themes and sections on Online Store 2.0. This skill covers Liquid syntax, section schema definition, JSON templates, blocks, filters (20+ common), objects, theme file structure, presets, and real-world section and template patterns.
## When Asked
**When asked to build a Liquid section with settings:**
Provide a complete section file with schema (settings array, presets, locales), CSS, and JavaScript; explain how settings map to template variables.
**Before returning any section, app block, or theme schema:**
Run the Shopify schema dedupe check mentally or with `scripts/validate-shopify-schema-ids.mjs <theme-or-file>`. Every `settings[].id` must be unique within its own schema scope, every block's `settings[].id` must be unique inside that block type, and every `blocks[].type` should be unique unless you are intentionally defining different block entries. Do not paste settings from multiple examples until you merge and dedupe IDs.
**When asked to implement product filters or sorting:**
Use the collection product pagination, filters by attribute, sorting with Liquid forloop and if/case statements; explain faceting.
**When asked to add custom fields to products:**
Use metafields in Liquid (product.metafields.namespace.key), explain metafield definitions, and show rendering in sections.
**When asked to create reusable components:**
Use include/render snippets with passed parameters; explain snippet vs. render differences and variable scope.
**When asked about Liquid filters and string manipulation:**
Provide examples of 20+ filters (split, join, size, capitalize, downcase, strip_html, truncate, money, date, etc.) with use cases.
**When asked to optimize theme performance:**
Discuss lazy loading, critical CSS, reducing render-blocking resources, and using theme-check for linting.
---
## Liquid & Online Store 2.0 Architecture
Liquid provides:
- **Section-Based Editing**: Drag-and-drop sections with live customization
- **Schema System**: Settings (text, select, checkbox, range, color), preset configurations
- **Blocks**: Repeatable content blocks within sections (e.g., carousel slides, testimonials)
- **JSON Templates**: Structured page templates with sections array
- **Filters**: 50+ filters for formatting (string, date, money, array, math)
- **Objects**: Global objects (shop, page, product, collection, cart, customer)
- **Include & Render**: Reusable snippets with parameter passing
- **Forloop & Control**: for, if, unless, case/when, break, continue statements
- **Metafields**: Custom product/collection/order data fields
### Schema Dedupe Guardrail
Shopify section schemas use arrays, so JSON itself will not protect you from duplicated visible inputs. A duplicated setting object with the same `id` can make Theme Editor show repeated controls or make generated Liquid read the wrong setting.
Hard rules before emitting schema:
- Keep section-level `settings[].id` unique.
- Keep `blocks[].type` unique within a section or app block.
- Keep each block's `settings[].id` unique inside that block.
- In `config/settings_schema.json`, keep setting IDs unique across theme setting groups unless Shopify's own template intentionally scopes them.
- If you copy a "padding", "heading", "text", "image", "color", or "button_label" setting from another example, rename it by purpose: `hero_padding`, `card_padding`, `button_label_primary`, not another generic `padding`.
- Preset `settings` keys must match real setting IDs. Delete stale preset keys after renaming.
- After generation, run:
```bash
node <plugin-root>/scripts/validate-shopify-schema-ids.mjs path/to/theme-or-section.liquid
```
Treat any duplicate as a blocker before handoff.
### Theme Directory Structure
```
theme/
├── assets/ # CSS, JS, images, fonts
│ ├── base.css
│ ├── custom.js
│ └── logo.png
├── config/
│ ├── settings_schema.json # Theme-wide settings
│ └── settings_data.json # Store settings values
├── layout/
│ ├── theme.liquid # Root layout
│ └── password.liquid # Password-protected layout
├── sections/ # Customizable sections
│ ├── product.liquid
│ ├── collection.liquid
│ ├── hero.liquid
│ └── newsletter.liquid
├── snippets/ # Reusable components
│ ├── product-card.liquid
│ ├── breadcrumbs.liquid
│ └── pagination.liquid
├── templates/ # Page templates (JSON)
│ ├── product.json
│ ├── collection.json
│ ├── index.json
│ ├── page.json
│ ├── cart.json
│ └── 404.json
└── locales/
├── en.json # English translations
└── fr.json # French translations
```
---
## Liquid Syntax Fundamentals
### Variables & Output
```liquid
{{ product.title }}
{{ product.price | money }}
{{ 'Hello World' | upcase }}
{{ section.settings.text_field }}
```
### Assign & Capture
```liquid
{% assign name = 'John' %}
Hello {{ name }}!
{% assign price_times_two = product.price | times: 2 %}
{% capture my_variable %}
I am being captured.
{% endcapture %}
{{ my_variable }}
```
### Forloop
```liquid
{% for product in collection.products %}
<div class="product-card">
<h3>{{ product.title }}</h3>
<p>${{ product.price | money }}</p>
</div>
{% endfor %}
{% for i in (1..5) %}
Item {{ i }}
{% endfor %}
{% for item in array %}
{% if forloop.first %}
<p>First item: {{ item }}</p>
{% elsif forloop.last %}
<p>Last item: {{ item }}</p>
{% else %}
<p>Item {{ forloop.index }} of {{ forloop.length }}: {{ item }}</p>
{% endif %}
{% endfor %}
{% for item in array limit: 5 %}
{{ item }}
{% endfor %}
{% for item in array offset: 10 %}
Item number {{ forloop.index }}
{% endfor %}
```
**Forloop Properties:**
- `forloop.index` — 1-based position
- `forloop.index0` — 0-based position
- `forloop.first`, `forloop.last` — boolean
- `forloop.length` — total items
- `forloop.rindex` — reverse index
### Conditionals
```liquid
{% if customer %}
Hello, {{ customer.first_name }}!
{% elsif customer.email %}
Hello, {{ customer.email }}!
{% else %}
Hello, guest!
{% endif %}
{% unless product.available %}
<p>Out of stock</p>
{% endunless %}
{% case handle %}
{% when 'electronics' %}
<p>Electronics category</p>
{% when 'clothing' %}
<p>Clothing category</p>
{% else %}
<p>Other category</p>
{% endcase %}
{% if product.available and product.price > 100 %}
Premium item in stock
{% endif %}
{% if product.available or customer %}
Available or customer logged in
{% endif %}
```
---
## Filters (20+ Common)
### String Filters
```liquid
{{ 'hello world' | upcase }} # HELLO WORLD
{{ 'HELLO' | downcase }} # hello
{{ 'hello' | capitalize }} # Hello
{{ 'hello world' | replace: 'world', 'liquid' }} # hello liquid
{{ 'hello world' | split: ' ' | join: '-' }} # hello-world
{{ 'hello world' | slice: 0, 5 }} # hello
{{ product.title | truncate: 20, '...' }} # Truncates to 20 chars with ellipsis
{{ '<p>hello</p>' | strip_html }} # hello
```
### Math Filters
```liquid
{{ 16 | plus: 4 }} # 20
{{ 16 | minus: 4 }} # 12
{{ 4 | times: 5 }} # 20
{{ 16 | divided_by: 4 }} # 4
{{ 5.4 | ceil }} # 6
{{ 5.4 | floor }} # 5
{{ 5.4 | round }} # 5
{{ 4 | modulo: 2 }} # 0
```
### Money Filter
```liquid
{{ product.price | money }} # $29.99 (formatted per shop currency)
{{ product.price | money_with_currency }} # $29.99 USD
{{ 10 | times: product.price | money }} # $299.90
```
### Array Filters
```liquid
{{ array | size }} # Length of array
{{ array | first }} # First element
{{ array | last }} # Last element
{{ array | join: ', ' }} # 'item1, item2, item3'
{{ array | reverse | join: ', ' }} # Reverse order
{{ array | sort | join: ', ' }} # Sort array
{{ array | uniq | join: ', ' }} # Remove duplicates
{{ array | map: 'title' | join: ', '}} # Extract 'title' from each item
{% assign sorted = collection.products | sort: 'price' %}
```
### Date Filters
```liquid
{{ 'now' | date: '%Y-%m-%d' }} # 2024-05-04
{{ product.created_at | date: '%B %d, %Y' }} # May 04, 2024
{{ order.created_at | date: '%I:%M %p' }} # 03:45 PM
```
---
## Liquid Objects
### Shop Object
```liquid
{{ shop.name }} # Store name
{{ shop.currency }} # USD
{{ shop.url }} # mystore.myshopify.com
{{ shop.email }} # contact@mystore.com
{{ shop.phone }} # +1-800-123-4567
{{ shop.customer_accounts_enabled }} # true/false
```
### Product Object
```liquid
{{ product.id }} # Shopify product ID
{{ product.title }} # Product name
{{ product.description }} # Product description
{{ product.price }} # Price (in cents)
{{ product.price | money }} # Formatted: $29.99
{{ product.compare_at_price | money }} # Original price
{{ product.available }} # true/false
{{ product.variants.size }} # Number of variants
{{ product.featured_image.src }} # Image URL
{{ product.handle }} # URL slug
{{ product.type }} # Product type
{{ product.vendor }} # Brand/vendor
{{ product.tags | join: ', '}} # CSV tags
{% for variant in product.variants %}
<p>{{ variant.title }} - {{ variant.price | money }}</p>
{% endfor %}
```
### Collection Object
```liquid
{{ collection.id }}
{{ collection.title }}
{{ collection.url }}
{{ collection.description }}
{{ collection.image.src }}
{{ collection.products_count }}
{{ collection.handle }}
{% for product in collection.products %}
{% include 'product-card', product: product %}
{% endfor %}
{% if collection.previous_product %}
<a href="{{ collection.previous_product.url }}">← {{ collection.previous_product.title }}</a>
{% endif %}
{% if collection.next_product %}
<a href="{{ collection.next_product.url }}">{{ collection.next_product.title }} →</a>
{% endif %}
```
### Cart Object
```liquid
{{ cart.item_count }} # Total items in cart
{{ cart.total_price | money }} # Cart subtotal
{{ cart.total_price | money_with_currency }}
{% for item in cart.items %}
<p>{{ item.title }} x {{ item.quantity }} = {{ item.final_line_price | money }}</p>
{% endfor %}
{{ cart.note }} # Cart note/comments
```
### Customer Object
```liquid
{% if customer %}
Hello, {{ customer.first_name }}!
Email: {{ customer.email }}
Phone: {{ customer.phone }}
{{ customer.orders_count }} orders
Loyal since: {{ customer.created_at | date: '%B %Y' }}
{% endif %}
```
---
## Section Schema & Settings
### Basic Section with Settings
```liquid
{%- stylesheet %}
.section-hero {
background: {{ section.settings.bg_color }};
padding: {{ section.settings.padding }}px;
}
.hero-title {
color: {{ section.settings.title_color }};
font-size: {{ section.settings.title_size }}px;
}
{%- endstylesheet %}
<section class="section-hero">
<h1 class="hero-title">{{ section.settings.heading }}</h1>
<p>{{ section.settings.description }}</p>
{% if section.settings.show_button %}
<a href="{{ section.settings.button_link }}" class="button">
{{ section.settings.button_text }}
</a>
{% endif %}
</section>
{% schema %}
{
"name": "Hero Banner",
"settings": [
{
"type": "text",
"id": "heading",
"label": "Heading",
"default": "Welcome to our store"
},
{
"type": "textarea",
"id": "description",
"label": "Description",
"default": "Share your store's story"
},
{
"type": "color",
"id": "bg_color",
"label": "Background Color",
"default": "#ffffff"
},
{
"type": "color",
"id": "title_color",
"label": "Title Color",
"default": "#000000"
},
{
"type": "range",
"id": "padding",
"min": 0,
"max": 100,
"step": 5,
"label": "Padding (px)",
"default": 40
},
{
"type": "range",
"id": "title_size",
"min": 24,
"max": 72,
"label": "Title Size (px)",
"default": 48
},
{
"type": "checkbox",
"id": "show_button",
"label": "Show Button",
"default": true
},
{
"type": "text",
"id": "button_text",
"label": "Button Text",
"default": "Shop Now"
},
{
"type": "url",
"id": "button_link",
"label": "Button Link"
},
{
"type": "select",
"id": "alignment",
"label": "Alignment",
"options": [
{ "value": "left", "label": "Left" },
{ "value": "center", "label": "Center" },
{ "value": "right", "label": "Right" }
],
"default": "center"
}
]
}
{% endschema %}
```
### Section with Blocks
```liquid
<section class="testimonials">
<h2>What Our Customers Say</h2>
<div class="testimonials-grid">
{%- for block in section.blocks -%}
<div class="testimonial" {{ block.shopify_attributes }}>
<p class="quote">{{ block.settings.quote }}</p>
<p class="author">— {{ block.settings.author }}</p>
<div class="rating">
{%- for i in (1..block.settings.stars) -%}
⭐
{%- endfor -%}
</div>
</div>
{%- endfor -%}
</div>
</section>
{% schema %}
{
"name": "Testimonials",
"blocks": [
{
"type": "testimonial",
"name": "Testimonial",
"settings": [
{
"type": "textarea",
"id": "quote",
"label": "Quote",
"default": "Great product!"
},
{
"type": "text",
"id": "author",
"label": "Author",
"default": "John Smith"
},
{
"type": "range",
"id": "stars",
"min": 1,
"max": 5,
"label": "Rating",
"default": 5
}
]
}
]
}
{% endschema %}
```
### Presets (Default Configuration)
```liquid
{% schema %}
{
"name": "Featured Product",
"settings": [...],
"presets": [
{
"name": "Featured Product",
"settings": {
"product": "gid://shopify/Product/123456",
"show_rating": true,
"show_reviews": true
}
},
{
"name": "Featured Product - Minimal",
"settings": {
"show_rating": false,
"show_reviews": false
}
}
]
}
{% endschema %}
```
---
## Snippets & Reusable Components
### Include vs. Render
```liquid
{%- include 'product-card', product: product -%}
```
vs.
```liquid
{%- render 'product-card', product: product -%}
```
**Include:** Shares scope with parent template (slower, backwards-compat)
**Render:** Isolated scope, faster, recommended for components
### Product Card Snippet
```liquid
{%- comment -%}
Reusable product card component
Usage: {% render 'product-card', product: product, show_price: true %}
{%- endcomment -%}
<div class="product-card">
<a href="{{ product.url }}" class="product-image">
{% if product.featured_image %}
<img
src="{{ product.featured_image.src | img_url: '300x300' }}"
alt="{{ product.featured_image.alt }}"
loading="lazy"
/>
{% endif %}
</a>
<h3 class="product-title">
<a href="{{ product.url }}">{{ product.title }}</a>
</h3>
<p class="product-vendor">{{ product.vendor }}</p>
{% if show_price %}
<div class="product-price">
{% if product.compare_at_price > product.price %}
<span class="original-price">{{ product.compare_at_price | money }}</span>
<span class="sale-price">{{ product.price | money }}</span>
{% else %}
<span class="price">{{ product.price | money }}</span>
{% endif %}
</div>
{% endif %}
{% if product.available %}
<button class="btn-add-to-cart" data-product-id="{{ product.id }}">
Add to cart
</button>
{% else %}
<p class="out-of-stock">Out of Stock</p>
{% endif %}
</div>
<style>
.product-card {
border: 1px solid #ddd;
padding: 16px;
border-radius: 8px;
}
.product-title a {
text-decoration: none;
color: #000;
}
</style>
<script>
document.querySelectorAll('.btn-add-to-cart').forEach(btn => {
btn.addEventListener('click', function() {
const productId = this.dataset.productId;
fetch('/cart/add.js', {
method: 'POST',
body: JSON.stringify({ id: productId, quantity: 1 })
});
});
});
</script>
```
---
## JSON Templates
### Product Page Template
```json
{
"sections": {
"main": {
"type": "product",
"settings": {
"enable_sticky_add_to_cart": true,
"gallery_layout": "thumbnail",
"media_size": "medium",
"image_zoom": true,
"show_vendor": true,
"show_sku": true,
"enable_video_looping": false
}
},
"product-recommendations": {
"type": "product-recommendations",
"settings": {
"heading": "You might also like",
"products_per_row": 4
}
}
},
"order": ["main", "product-recommendations"]
}
```
### Collection Page Template
```json
{
"sections": {
"collection-header": {
"type": "collection-header",
"settings": {
"show_image": true,
"show_description": true
}
},
"collection-filters": {
"type": "collection-filters",
"settings": {
"enable_filters": true,
"enable_sorting": true
}
},
"collection-products": {
"type": "collection-products",
"settings": {
"products_per_page": 12,
"columns_desktop": 4,
"columns_mobile": 2
}
}
},
"order": ["collection-header", "collection-filters", "collection-products"]
}
```
---
## Metafields in Liquid
### Displaying Metafields
```liquid
{%- if product.metafields.custom.care_instructions -%}
<div class="care-instructions">
<h3>Care Instructions</h3>
<p>{{ product.metafields.custom.care_instructions.value }}</p>
</div>
{%- endif -%}
{%- if product.metafields.custom.size_chart -%}
<img src="{{ product.metafields.custom.size_chart.value }}" alt="Size Chart" />
{%- endif -%}
{%- for item in product.metafields.custom.ingredients.value -%}
<li>{{ item }}</li>
{%- endfor -%}
```
### Defining Metafields (in Admin or via API)
```
Namespace: custom
Key: care_instructions
Type: single_line_text_field
Namespace: custom
Key: ingredients
Type: list.single_line_text_field
```
---
## Worked Example: Product Section
```liquid
<section class="section-product" id="product-{{ section.id }}">
<div class="product-container">
<div class="product-gallery">
{%- for image in product.images -%}
<img
src="{{ image.src | img_url: '500x500' }}"
alt="{{ image.alt }}"
loading="{{ 'lazy' if forloop.index > 1 else 'eager' }}"
class="product-image"
/>
{%- endfor -%}
</div>
<div class="product-info">
<h1 class="product-title">{{ product.title }}</h1>
{% if section.settings.show_vendor %}
<p class="product-vendor">{{ product.vendor }}</p>
{% endif %}
<div class="product-price">
{% if product.compare_at_price > product.price %}
<span class="original-price">{{ product.compare_at_price | money }}</span>
<span class="sale-badge">Sale</span>
{% endif %}
<span class="price">{{ product.price | money }}</span>
</div>
<p class="product-description">{{ product.description }}</p>
<form action="/cart/add" method="post">
{%- for option in product.options -%}
<fieldset>
<legend>{{ option.name }}</legend>
<select name="options[{{ option.name }}]" required>
{%- for value in option.values -%}
<option value="{{ value }}">{{ value }}</option>
{%- endfor -%}
</select>
</fieldset>
{%- endfor -%}
<input type="hidden" name="id" value="{{ product.variants.first.id }}" />
<input
type="number"
name="quantity"
value="1"
min="1"
max="{{ product.selected_or_first_available_variant.inventory_quantity }}"
class="quantity-input"
/>
<button type="submit" class="btn-add-to-cart">
{% if product.available %}
Add to cart
{% else %}
Out of stock
{% endif %}
</button>
</form>
{% if section.settings.show_reviews %}
<div class="product-reviews">
<p>⭐⭐⭐⭐⭐ ({{ product.reviews_count }} reviews)</p>
</div>
{% endif %}
</div>
</div>
</section>
{%- stylesheet %}
.section-product {
padding: {{ section.settings.padding }}px;
}
.product-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
}
.product-price {
font-size: 20px;
font-weight: bold;
}
.sale-badge {
background: red;
color: white;
padding: 4px 8px;
border-radius: 4px;
margin-left: 8px;
}
@media (max-width: 768px) {
.product-container {
grid-template-columns: 1fr;
}
}
{%- endstylesheet %}
{% schema %}
{
"name": "Product",
"settings": [
{
"type": "checkbox",
"id": "show_vendor",
"label": "Show vendor",
"default": true
},
{
"type": "checkbox",
"id": "show_reviews",
"label": "Show reviews",
"default": true
},
{
"type": "range",
"id": "padding",
"min": 0,
"max": 100,
"step": 5,
"label": "Padding (px)",
"default": 40
}
]
}
{% endschema %}
```
---
## Theme Linting with theme-check
```bash
npm install -g @shopify/theme-check
theme-check --help
theme-check . # Lint entire theme
```
Common rules:
- Unused snippets
- Missing alt text on images
- Deprecated Liquid tags
- Missing schema translations
- Performance issues (large assets, render-blocking)
---
## Performance & Optimization Tips
- Use `{{ image.src | img_url: '300x300' }}` for responsive images
- Set `loading="lazy"` on below-fold images
- Minimize CSS in sections (use scoped `{% stylesheet %}`)
- Defer non-critical JS with `defer` attribute
- Use `capture` and `assign` to avoid multiple API calls
- Lazy load sections with `{% section 'footer' %}`
- Cache computed values: `{% assign sorted = array | sort %}`
- Use `unless` instead of `{% if not %}` for readability
- Limit forloop iterations with `limit` and `offset`
- Use theme-check to identify performance issues
---
## Common Patterns Checklist
- [ ] Define section schema with at least 3 settings
- [ ] Use `section.settings` to access customizable values
- [ ] Include blocks for repeating content (testimonials, gallery)
- [ ] Use `render` instead of `include` for components
- [ ] Pass parameters to snippets: `render 'card', item: product`
- [ ] Add `.shopify_attributes` to block divs for editor selection
- [ ] Use filters for formatting (money, date, string manipulation)
- [ ] Check product.available before showing "Add to cart"
- [ ] Use `img_url` filter for responsive image sizing
- [ ] Add `loading="lazy"` to below-fold images
- [ ] Define presets with sensible defaults
- [ ] Add translations (JSON files in locales/)
- [ ] Use theme-check to lint for errors
- [ ] Test with product variants, multiple images, long titles
merchant-pain-prevention45 KB
---
name: merchant-pain-prevention
description: "Use when designing, building, reviewing, or shipping a Shopify app to avoid the patterns that get merchants angry (1-2 star reviews, uninstalls, churn). Covers theme injection / leftover code on uninstall, surprise billing, fake urgency, slow scripts, cancel friction, scope creep, bot-only support, broken on platform updates, locale/checkout breakage. Triggers: 'merchant complaint', 'avoid bad app review', 'shopify app uninstall hygiene', 'leftover code in theme', 'surprise charge', 'shopify app dark pattern', 'billing after uninstall', 'cancel friction', 'shopify app churn', '1 star review', 'billed after trial', 'shopify app cleanup'. MUST trigger on any pre-ship review or quality check."
---
# Merchant Pain Prevention
Every 1-star review on the Shopify App Store is a merchant who trusted you and felt betrayed. This skill is the inverse of that experience — a hard-coded inventory of the patterns that cause the betrayal, with the structural fixes.
The merchant pain ecosystem is small and loud. Stores talk. Subreddits index. Community threads outrank the App Store on Google for the phrase "billed for uninstalled app" — there are at least eight active threads on that exact phrase. If you ship a billable app and get one of those patterns wrong, you will not just lose that merchant; you will repel the next 50 who Google your name.
This is not a style guide. It is a survival document. Follow it.
---
## 1. When to use
Invoke this skill whenever you are:
- **Designing** a new Shopify app or extension — before architecture is locked, before scopes are declared, before billing is wired.
- **Reviewing a PR** that touches: theme app extensions, ScriptTag, billing API, webhook handlers, scopes (`shopify.app.toml`), onboarding flow, cancellation flow, email triggers, popups, banners, countdown timers, "social proof" widgets.
- **Pre-ship audit** — the 48 hours before you submit to the App Store or release a new version.
- **Triaging a merchant complaint** — public 1-star, support ticket, refund request, chargeback, BBB complaint, Trustpilot drop, Reddit post that mentions your app by name.
- **Annual reaudit** — quarterly is better. Scopes accrete. Pricing pages drift. Anti-patterns sneak back in when feature flags collide.
- **Investigating churn** — a sudden uninstall spike usually maps to one of the cardinal pains.
If anyone on your team says "merchants will figure it out" or "this is industry standard" or "everyone does it this way" — that is the trigger to run this skill in full. Industry standard on Shopify is what generates 12 BBB complaints a day. Do not aim for industry standard.
---
## 2. The 7 cardinal merchant pains (ranked by anger)
Ranked by how fast they get you a public 1-star review, a chargeback, a Reddit post, and an unrecoverable partner-reputation hit. Each is documented with verbatim merchant quotes.
### Pain 1 — Charged after uninstall
The single most-litigated complaint on the App Store. At least eight active Shopify Community threads use this exact phrase as the title. The complaint compounds because Shopify Support since May 3, 2023 no longer mediates refund requests on behalf of merchants — they tell the merchant to email the developer, who often ghosts.
> "Why am I still billed for an uninstalled app on Shopify?" — verbatim title repeated across at least five threads. [community.shopify.com/t/why-am-i-still-billed-for-an-uninstalled-app-on-shopify/191216](https://community.shopify.com/t/why-am-i-still-billed-for-an-uninstalled-app-on-shopify/191216)
> "Why am I still being charged for POS after uninstalling? I don't use it and uninstalled the app months ago." — the Shopify-owned POS app does it too. [community.shopify.com/t/why-am-i-still-being-charged-for-pos-after-uninstalling/286967](https://community.shopify.com/t/why-am-i-still-being-charged-for-pos-after-uninstalling/286967)
> "Why is a merchant that uninstalled our app seeing charges two months later?" — developer-side version of the same complaint. [community.shopify.dev/t/why-is-a-merchant-that-uninstalled-our-app-seeing-charges-two-months-later/23625](https://community.shopify.dev/t/why-is-a-merchant-that-uninstalled-our-app-seeing-charges-two-months-later/23625)
**Root cause:** recurring app charges are generated on the **first day of the app's billing cycle**, not Shopify's. Uninstalling stops future cycles but does NOT cancel an already-generated invoice. If the merchant uninstalls one day after install, an invoice is still pending.
### Pain 2 — Trial → silent auto-charge
> "I canceled my free trial in time, and still got charged." [community.shopify.com/c/shopify-discussions/i-canceled-my-free-trial-in-time-and-still-got-charged/td-p/2282529](https://community.shopify.com/c/shopify-discussions/i-canceled-my-free-trial-in-time-and-still-got-charged/td-p/2282529)
> "Tried to cancel subscription after free trial was charged." [community.shopify.com/c/shopify-discussions/tried-to-cancel-subscription-after-free-trial-was-charged/td-p/851254](https://community.shopify.com/c/shopify-discussions/tried-to-cancel-subscription-after-free-trial-was-charged/td-p/851254)
> "Privy continued charging for nearly six years on an inaccessible second account, with the user only discovering this in 2025. After providing full documentation, Privy refunded only six months of charges out of 70+ months and refused to refund the remainder." [apps.shopify.com/privy/reviews](https://apps.shopify.com/privy/reviews)
### Pain 3 — Leftover code in theme after uninstall
> "Left over code from app." — verbatim title; the Site Speed sub-forum is full of these. [community.shopify.com/c/site-speed/left-over-code-from-app/td-p/1581837](https://community.shopify.com/c/site-speed/left-over-code-from-app/td-p/1581837)
> "Leftover code from uninstalled app — how do I remove it from my website?" [community.shopify.com/t/leftover-code-from-uninstalled-app-how-do-i-remove-it-from-my-website/294871](https://community.shopify.com/t/leftover-code-from-uninstalled-app-how-do-i-remove-it-from-my-website/294871)
> "After uninstalling the Translate & Adapt app, a language selector dropdown remained embedded in the storefront — with no notification that theme elements persist after uninstall." — even Shopify's own app does this.
### Pain 4 — Slowing the store / killing Core Web Vitals
The #1 functional complaint that maps to lost revenue.
> "This app loads incredibly slow — takes about 30–40 sec. after clicking on items." — PageFly 1-star, cited in Reddit "lightweight builder" recommendations.
> "Why is my store speed slowing down and how can I improve it?" — the answer 9 times out of 10 is "look at your apps." [community.shopify.com/c/shopify-discussions/why-is-my-store-speed-slowing-down-and-how-can-i-improve-it/m-p/2221261](https://community.shopify.com/c/shopify-discussions/why-is-my-store-speed-slowing-down-and-how-can-i-improve-it/m-p/2221261)
The math reviewers quote: every 1-second delay → 7% conversion drop. 53% of mobile visitors leave if a page does not load in 3 seconds. Average Shopify store: 6–12 apps installed, adding 2–5 seconds to page load.
### Pain 5 — Cancel friction / can't cancel inside the app
> "Cannot cancel inside the app. Have to email support, then ask again the next month when I'm charged anyway." — Justuno reviewer pattern.
> "Asked to cancel three times. Each time they confirm cancellation and the next month I'm charged again." — Spocket / general subscription cancellation.
The FTC's Click-to-Cancel rule (in force since 2025) requires cancellation to be at least as easy as signup. Apps that violate it expose Shopify AND the merchant to legal risk.
### Pain 6 — Support is a bot loop with no human exit
> "Each question took 6 hours+ to get a response on their own chat function." — Gorgias review widely cited by Reddit merchants comparing helpdesks. [apps.shopify.com/helpdesk/reviews](https://apps.shopify.com/helpdesk/reviews)
> "How can I reach support when the AI bot keeps timing out?" [community.shopify.com/t/how-can-i-reach-support-when-the-ai-bot-keeps-timing-out/413511](https://community.shopify.com/t/how-can-i-reach-support-when-the-ai-bot-keeps-timing-out/413511)
> "Bug reports submitted a month ago. No fix. They told me publicly on the App Store that I haven't been ignored. I have." — Willdesk reviewer.
> "Their support replied with a script three times. Same words. Different agent name. The bug is still there." — Bold Subscriptions reviewer.
### Pain 7 — Breaks on the next Shopify platform update
> "The app has taken money from our account without authorization, approximately $1600 in unauthorized charges. The app became useless after Shopify's November checkout app update." — ReConvert 1-star. [apps.shopify.com/reconvert-upsell-cross-sell/reviews](https://apps.shopify.com/reconvert-upsell-cross-sell/reviews)
> "Some merchants unable to access and apply Checkout Blocks App functions — checkout blocks has been broken for about 12 hours, with all functions on stores being wiped and the blocks while still in the app being removed from the checkout." [community.shopify.dev/t/some-merchants-unable-to-access-and-apply-checkout-blocks-app-functions/27602](https://community.shopify.dev/t/some-merchants-unable-to-access-and-apply-checkout-blocks-app-functions/27602)
> "Sync stopped working when Shopify moved off REST. App kept saying everything was synced. We oversold for two weeks." — Inventory Planner reviewer.
---
## 3. Theme injection hygiene
### The rule
**App Embeds and App Blocks only. Never ScriptTag. Never write to `theme.liquid`.**
ScriptTag-injected scripts:
- Cannot be disabled by the merchant without contacting you.
- Persist (sometimes) after uninstall.
- Load on every page including `/policies/*`, `/account/*`, cart, checkout — pages your widget doesn't need.
- Count against the merchant's Lighthouse score forever.
App Embeds:
- Default OFF — the merchant must enable in Theme Editor → App embeds.
- One-click toggle off without touching code.
- Loaded from Shopify's CDN with performance budgets.
- **Disappear automatically when the app is uninstalled.**
Shopify removed `theme.liquid` write scope access from third-party apps in April 2023 specifically because of abuse. If your onboarding still says "paste this snippet into your theme code," you are building for a platform that no longer exists.
### Concrete prohibitions
- Do NOT use `ScriptTag` API for new apps. Period.
- Do NOT instruct merchants to paste code into `theme.liquid` manually.
- Do NOT use ScriptTag as a "fallback" when the merchant disables your App Embed.
- Do NOT auto-enable App Embeds via any API trick. They must be off until the merchant toggles them on.
- Do NOT inject anything into checkout via legacy methods. Use Checkout UI Extensions.
### App Embed scaffold (Theme App Extension)
```toml
# extensions/storefront-widget/shopify.extension.toml
api_version = "2026-01"
name = "Your Widget"
type = "theme"
[[extensions.targeting]]
target = "section"
```
```liquid
{# extensions/storefront-widget/blocks/widget.liquid #}
{% schema %}
{
"name": "Your Widget",
"target": "section",
"settings": [
{ "type": "checkbox", "id": "enabled", "label": "Enable widget", "default": false }
]
}
{% endschema %}
{% if block.settings.enabled %}
<div id="your-widget" data-shop="{{ shop.permanent_domain }}"></div>
<script src="{{ 'widget.js' | asset_url }}" defer></script>
{% endif %}
```
### Webhook handler — uninstall cleanup
Even though App Embed extension assets disappear automatically, you still own all the other side effects: ScriptTag rows (if a legacy version of your app created any), metafield definitions, metaobjects, billing records, cached customer data. The uninstall webhook is non-optional.
```typescript
// app/routes/webhooks.app.uninstalled.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import { db } from "../db.server";
export async function action({ request }: ActionFunctionArgs) {
const { shop, session, topic } = await authenticate.webhook(request);
console.log(`[webhook] ${topic} for ${shop}`);
if (!session) {
// Already uninstalled or token revoked — proceed with cleanup using stored data only
}
await Promise.all([
// 1. Remove any legacy ScriptTags (only if you ever shipped a version with them)
removeLegacyScriptTags(shop, session?.accessToken).catch(logSafe),
// 2. Delete metafield definitions you created under your reserved namespace
deleteAppMetafieldDefinitions(shop, session?.accessToken).catch(logSafe),
// 3. Delete metaobjects you created
deleteAppMetaobjects(shop, session?.accessToken).catch(logSafe),
// 4. Cancel any external billing (you shouldn't have any — see Section 4)
cancelExternalBilling(shop).catch(logSafe),
// 5. Mark the shop record for data deletion in 48h grace window
db.shop.update({ where: { shop }, data: { uninstalledAt: new Date(), pendingDeletionAt: new Date(Date.now() + 48 * 60 * 60 * 1000) } }),
// 6. Send the goodbye email with one-click data-export link
sendGoodbyeEmailWithExport(shop),
]);
return new Response();
}
function logSafe(err: unknown) {
console.error("[uninstall cleanup partial failure]", err);
}
```
### Provide a "clean up theme code" button
For apps that have ever shipped a ScriptTag version, expose a dashboard button: *"Scan and remove app code from theme."* Detect any tag you've ever added (track by `src` substring matching your CDN), offer one click to remove, with a diff preview. This single feature converts angry uninstallers into neutral ones.
---
## 4. Billing trust
### The structural Shopify "developer-discretion refund" trap
Read this twice. It is the most-misunderstood part of the Shopify billing model and it is responsible for hundreds of 1-star reviews.
1. Shopify generates recurring app charges on the **first day of the app's billing cycle for that shop**, not on Shopify's monthly billing day.
2. Uninstalling the app **stops future billing cycles but does NOT cancel an already-generated invoice for the current cycle.**
3. Shopify Support **since May 3, 2023** no longer mediates refund requests on behalf of merchants for third-party app charges. The merchant must email the developer.
4. The developer can issue a credit via the Billing API — but it is at developer discretion.
**Result:** a merchant who installs on day 1, uninstalls on day 3, will receive an invoice for the full month. When they contact Shopify, Shopify says "contact the developer." When they contact the developer, the developer can choose to refund or not. If the developer ignores them, the merchant has no platform-level recourse. They write a 1-star review and post on Reddit.
### How to never trip the trap
1. **All billing through Shopify Billing API.** No external Stripe. No PayPal. No Paddle. No exceptions. External billing means uninstall does not automatically cancel — and you become responsible for the months-later-charge story.
2. **Pro-rate / credit partial billing cycles on uninstall.** Use the `appSubscriptionCancel` mutation with `prorate: true`. Yes, you eat some margin. You also stop the chargeback wave and the public 1-stars. The math always works out positive.
3. **Send a billing-cycle reminder email 3 days before the next charge.** And another the day of. Include direct uninstall link with a one-click confirmation.
4. **No trial-to-paid surprise.** Email at install + trial-end-minus-3 + trial-end day. Require an active confirmation to convert, or charge $0 until first measurable usage event.
5. **One-click cancel inside the app.** Not via support email. Not behind 6 confirmation modals. Single button → confirm → done. Optional exit survey appears AFTER the cancel processes, fully skippable.
6. **Subscribe `app/uninstalled` webhook with retry logic.** A silent webhook failure is the root cause of the "charged two months later" complaint pattern. Set up a dead-letter queue.
7. **Auto-pause on usage cap breach. Never auto-upgrade.** A merchant who exceeds 1000 events on a 1000-event plan should be paused with a prompt to upgrade — NOT silently upgraded to the $99 tier.
### Sample appSubscriptionCancel with proration
```graphql
mutation CancelSubscriptionWithRefund($id: ID!) {
appSubscriptionCancel(id: $id, prorate: true) {
appSubscription {
id
status
currentPeriodEnd
}
userErrors {
field
message
}
}
}
```
### Pre-charge warning email template
```
Subject: Heads up — your [App Name] trial ends Friday
Hi [merchant first name],
Your 14-day trial of [App Name] ends in 3 days, on [date].
On [date], your card will be charged $X for the [plan name] plan.
If [App Name] isn't working for you, you can cancel in one click:
https://[app-admin-url]/billing/cancel
You won't be charged anything if you cancel before [date].
Questions? Reply to this email and a real human will respond in under 24h.
[Founder name]
[App Name]
```
### Billing checklist (must all be true before ship)
- [ ] All billing routed through `appSubscriptionCreate` / `appUsageRecordCreate`. Zero external billing.
- [ ] `appSubscriptionCancel` is called with `prorate: true` on merchant-initiated cancel.
- [ ] `app/uninstalled` webhook is subscribed AND has a dead-letter queue.
- [ ] Three-email trial-ending sequence is automated (install + T-3 + T-0).
- [ ] Pricing page on the App Store listing matches the in-app billing screen exactly (same tier names, same prices, same cycle).
- [ ] One-click cancel inside the app, no support email required.
- [ ] Annual plans (if offered) display a clickable "non-refundable" disclosure at checkout.
- [ ] Usage caps show a real-time meter + warning at 80% / 90% / 100%.
---
## 5. Performance discipline
### Hard targets
These are the Built for Shopify 2026 thresholds. Miss any of them and you lose BFS status and slide down the App Store ranking algorithm.
- **LCP impact ≤ 200ms**
- **CLS impact ≤ 0.02**
- **INP impact ≤ 50ms**
- **Lighthouse score reduction ≤ 10 points (target ≤ 5)**
- **API p95 < 500ms**
- **API failure rate < 0.1%**
- **Storefront JS bundle ≤ 50KB gzipped per surface**
### What "third-party app scripts" look like in aggregate
The average Shopify store has 15–20 apps with 5–10 injecting frontend scripts. Cumulatively, third-party app scripts can add **1–3MB of JavaScript** — 3 to 10 times the size of the theme itself. Chat widgets are the worst single offenders:
- Tidio: ~350KB on every page
- Zendesk Chat: ~300KB
- Intercom: ~400KB
- Drift: ~380KB
- Hotjar: ~120KB + persistent DOM tracking
- Lucky Orange: ~180KB
- FullStory: ~200KB+
Loaded on every page whether anyone opens the widget or starts a session. Page builders are next — PageFly adds 300–600ms to load time and drops mobile Lighthouse ~35 points vs native Shopify 2.0 sections.
### What never to include
- **No bundled jQuery, React, Vue, or Lodash** in your storefront script. Use platform-native primitives.
- **No render-blocking scripts in `<head>`.** Every script tag gets `defer` or `async`.
- **No always-on widgets.** Chat bubbles, popups, "social proof" toasts — all default OFF. Frequency cap once per visitor per 7 days minimum.
- **No analytics SDK loaded on every page.** Use Web Pixels API — it is sandboxed and off the main thread.
- **No synchronous server calls during checkout.** Use Checkout UI Extensions only.
### Lazy-load patterns
```html
<!-- Right -->
<script src="/widget.js" defer></script>
<!-- Better — load only when the user is likely to interact -->
<script>
if ('requestIdleCallback' in window) {
requestIdleCallback(() => loadWidget(), { timeout: 3000 });
} else {
window.addEventListener('load', () => setTimeout(loadWidget, 2000));
}
function loadWidget() {
const s = document.createElement('script');
s.src = '/widget.js';
s.async = true;
document.head.appendChild(s);
}
</script>
<!-- Best for chat-like widgets — load nothing until the user clicks the launcher -->
<button id="chat-launcher" aria-label="Open chat">Chat</button>
<script>
document.getElementById('chat-launcher').addEventListener('click', () => {
if (window.widgetLoaded) return openWidget();
const s = document.createElement('script');
s.src = '/widget-full.js';
s.onload = () => { window.widgetLoaded = true; openWidget(); };
document.body.appendChild(s);
}, { once: false });
</script>
```
### Performance budget in CI
Enforce it. If your PR drops Lighthouse 5 points, the PR is rejected. Use `@shopify/web-pixels-extension` lint, Lighthouse CI on a canonical test theme, and reject builds where the gzipped storefront bundle exceeds 50KB.
> "Made the entire store much slower than it should be." — Searchanise reviewer.
> "Their app loads incredibly slow — takes about 30–40 sec. after clicking on items." — PageFly 1-star.
---
## 6. Support promise
### The merchant trust math
A broken feature is forgivable. A broken feature plus no reply is a 1-star review and a public Reddit post. Support is not a cost center; it is the single most leveraged trust signal you have.
### Hard commitments
- **First-touch reply from a human within 24 hours** on every paid plan. Tag bots as bots. No exceptions for weekends.
- **A named contact** — a real person's name, not "the team." Reviewers consistently call out "the team will email you" as a stalling pattern.
- **In-app contact button** on every primary screen. Not a help-center maze. One click, opens a ticket, captures the shop URL and current screen automatically.
- **Public status page** at a fixed URL, linked from the app dashboard. Use a real status page (Statuspage, Atlassian, Better Stack) — not a blog post.
- **Public changelog** at a fixed URL, also linked from the app dashboard. Every breaking change announced 30 days ahead with a migration path.
- **Tier-1 support agents have refund authority** for amounts under $50. A $9 dispute should not require three handoffs.
- **No "we've escalated to the team" without a name and an ETA.** Merchants screen-shot this phrase and post it as evidence of stalling.
- **Public reply to every 1-star review within 48 hours.** Acknowledge, apologize if appropriate, offer offline contact. Never reply with "you have not been ignored" when the merchant said they were ignored.
> "Bug reports submitted a month ago. No fix. They told me publicly on the App Store that I haven't been ignored. I have." — Willdesk reviewer. The gaslighting reply drove the rating further down.
> "Asked for a refund. They ghosted me. The ticket was deleted from the system." — Jotform AI Chatbot reviewer.
### Negative-review reply template
```
Hi [merchant first name],
Thanks for taking the time to write this — and I'm sorry [App Name] hasn't worked the way you expected.
You're right that [specific thing they said]. We [shipped the fix / are shipping the fix on X date / are investigating now]. I've sent you an email at [email] with the current state — happy to get on a call too.
For anyone reading: if you've hit the same issue, email [founder@app.com] directly and I'll personally make it right.
[Founder name]
```
---
## 7. Scope minimization
### The principle
Request the **least privilege required**. For every scope in `shopify.app.toml`, you must be able to point at a specific line of code that requires it. If you can't, drop it.
### What "over-scoping" costs you
1. Merchants get scared at install. A "simple banner app" asking for `write_customers` is a red flag visible to anyone who reads the install prompt. Conversion drops.
2. Built for Shopify reviewers explicitly flag over-scoping. You lose BFS status.
3. If your app is breached, the blast radius is everything you scoped. The 2020 Shopify HackerOne privilege-escalation report ($50K+ bounty) traced back to overly broad token scopes — endpoint-specific scoping would have prevented unauthorized admin account creation.
4. Shopify Trust & Safety scrutinizes scope justification during app review. Slow reviews mean slow time-to-market.
5. The Consentik plugin breach in 2025 exposed **Shopify Personal Access Tokens that could give attackers full administrative control over a store**, Meta Ads tokens, and live analytics through an unsecured Kafka server for 100+ days. The app had a Built-for-Shopify badge, 4.9 stars, and 4,180 stores at the time of the breach. ([cybernews.com/security/shopify-plugin-consentik-data-leak](https://cybernews.com/security/shopify-plugin-consentik-data-leak/))
### Scope justification doc
Maintain `docs/scopes.md` in your repo. Update it on every release.
```markdown
# Scope justification
## read_products
- Used in: `app/services/product-fetch.ts:42` — displays product titles in the widget config screen.
- Could be reduced to: optional scope, requested only when the merchant enables the widget.
## read_customers
- Used in: `app/services/customer-segment.ts:88` — required for segment-based widget display rules.
- Could be reduced to: optional scope. Currently always-on; should move to optional in v2.3.
## write_products
- Not currently used. Removed in v2.1.
```
### Optional scopes pattern
Use **optional scopes** for features only some merchants need. Request at the moment they enable the feature, not at install.
```graphql
mutation RequestScopes {
appRequestAccessScopes(scopes: ["read_orders", "read_customers"]) {
grantedAccessScopes {
handle
}
userErrors {
field
message
}
}
}
```
### Hard rules
- **No `write_*` scope where `read_*` will work.**
- **No `read_all_orders` unless absolutely required** — for most apps `read_orders` is sufficient (returns orders from the last 60 days).
- **Never store the Shopify access token anywhere reachable from the public internet.** No public Kafka servers (Consentik). No keys in client bundles. Run SAST quarterly.
- **Document scope rationale in your app listing.** "We request `read_customers` because [specific feature]." This builds install-time trust.
- **Re-audit on every major release.** If a scope hasn't been called by any code path in 90 days, drop it.
---
## 8. Locale safety
Translation and locale-handling apps generate a special category of pain because they touch everything: URL structure, hreflang tags, theme markup, customer language preferences, checkout localization. The damage radius is huge and the cleanup is hard.
### Documented locale carnage
- **Langify's "switch back to original language" bug** — customers see the storefront flip back mid-session.
- **Transcy** generates 743 hreflang conflicts on a single store, leaves "markup junk" after deactivation.
- **Translate & Adapt** (Shopify's own app) leaves a language dropdown embedded in the theme after uninstall, with no notification.
- **T Lab** swaps translated product URL handles between products, sending shoppers to wrong product pages.
- **Locales.ai** produces half-translated pages where some sections render in the source language and others in the target.
### Rules
1. **Never write hreflang tags directly into theme files.** Use Shopify's `linkedDomains` and the Markets API.
2. **Never override the merchant's primary locale silently.** If you detect a mismatch (merchant default is `en`, customer browser is `de`), surface a banner in the storefront and let the customer choose — don't force-redirect.
3. **Test the full uninstall path on a multi-locale store.** Watch for: orphaned dropdown widgets, broken hreflang tags, swapped URL handles, stale `Accept-Language` cookies, persistent translation metafields.
4. **Never block the merchant from editing their own translations.** If your AI translates a product title, the merchant must be able to overwrite it. Reviews consistently call this out as the breaking point.
5. **Don't proxy customer requests through your translation server.** Latency adds up; your CDN is slower than Shopify's; merchants notice.
6. **Document the conflict matrix** — which themes you've tested, which other locale apps you coexist with, which checkout configurations break.
### Half-translated page check
Before shipping a translation update, run the storefront through:
- A native-speaker spot check on 5 random pages.
- Hreflang validator (e.g., `hreflang.org`).
- A diff against the source locale: any `<h1>`, `<title>`, `<meta name="description">` in the source language is a P0 bug.
---
## 9. Pre-ship merchant-pain self-audit (40 items)
Run this checklist 48 hours before submission to the App Store or any major release. Every "no" is a potential rejection or post-launch fire.
### Theme + storefront (8)
- [ ] No code writes to `theme.liquid` or other theme files.
- [ ] All storefront UI uses Theme App Extensions (App Blocks or App Embeds).
- [ ] App Embeds are deactivated by default.
- [ ] Storefront JS bundle is < 50KB gzipped per surface.
- [ ] No synchronous scripts in storefront `<head>`.
- [ ] Storefront Lighthouse score impact ≤ 5 points (target ≤ 2).
- [ ] LCP impact ≤ 200ms, CLS impact ≤ 0.02, INP impact ≤ 50ms.
- [ ] Checkout integration uses Checkout UI Extensions — no DOM injection.
### Permissions + security (6)
- [ ] Each requested scope maps to a specific line of code (documented in `docs/scopes.md`).
- [ ] No `write_*` scopes where `read_*` would suffice.
- [ ] No `read_all_orders` unless absolutely required.
- [ ] Optional scopes requested at moment-of-use, not install.
- [ ] OAuth tokens stored encrypted at rest. HMAC verification on all webhooks.
- [ ] No customer PII sent to third parties without DPA + disclosure.
### Billing + trials (8)
- [ ] All billing through Shopify Billing API — no external Stripe / PayPal.
- [ ] Pricing page in listing matches in-app billing screen exactly.
- [ ] Free trial behavior clearly disclosed before card request (if any).
- [ ] Email sent 3 days before trial converts to paid.
- [ ] Usage meter visible in admin with real-time updates + warnings at 80/90/100%.
- [ ] No auto-upgrades to higher plans — auto-pause instead.
- [ ] One-click cancel inside Shopify admin. No "email support" requirement.
- [ ] Cancellation triggers immediate billing stop with proration.
### Uninstall hygiene (5)
- [ ] `app/uninstalled` webhook handler implemented, tested, dead-letter-queue protected.
- [ ] All app-reserved metafields deleted on uninstall.
- [ ] All app metaobjects deleted on uninstall.
- [ ] All storefront code removed on uninstall (extensions auto-clean; ScriptTag cleanup runs if you ever shipped a legacy version).
- [ ] Merchant data exportable before uninstall (GDPR Article 20).
### Reviews + marketing (5)
- [ ] Review prompt fires at most once, after 30 days + active use.
- [ ] Review prompt is dismissible permanently — no nagging.
- [ ] No incentives tied to reviews (zero. none.).
- [ ] No fake reviews purchased, ever.
- [ ] No threats or doxing in responses to negative reviews.
### Support (4)
- [ ] In-app contact button on every primary screen.
- [ ] Support SLA published on the install page and met.
- [ ] Public status page and changelog at fixed URLs, linked from app dashboard.
- [ ] Tier-1 agents have refund authority under $50.
### Storefront UI honesty (4)
- [ ] No fake countdown timers (server-validated end times only).
- [ ] No fake stock numbers ("Only X left" reads real inventory).
- [ ] No fake "X people viewing" — real session data or remove the widget.
- [ ] No deceptive checkbox defaults (e.g., pre-checked subscriptions).
---
## 10. Decision tree — "merchant says X" → "diagnose Y" → "fix Z"
### "I uninstalled your app and was still charged this month."
**Diagnose:** Recurring app charge generated on day 1 of the app's billing cycle. Uninstall stops future cycles but the current invoice already exists.
**Fix:** Issue a credit via `appSubscriptionCancel` with `prorate: true` within 24 hours. Reply publicly to any review thanking them and confirming the credit. Audit your `app/uninstalled` webhook — if it failed, that's why the timing felt arbitrary.
### "Your app slowed down my store."
**Diagnose:** Probably one of: ScriptTag on every page, bundled jQuery/React, render-blocking script in `<head>`, no `defer`/`async`, widget loading above the fold.
**Fix:** Migrate to Theme App Extensions. Lazy-load with `requestIdleCallback`. Run Lighthouse against the merchant's store and post the before/after to them. Promise a performance budget in your next release.
### "There's leftover code from your app in my theme."
**Diagnose:** Either you used ScriptTag historically, or you're using ScriptTag now. App Embed assets disappear on uninstall; ScriptTag entries do not always.
**Fix:** Migrate to App Embeds in your next release. Ship a "clean up theme code" button immediately in the dashboard that scans for and removes any tag with your CDN URL. For the merchant complaining, send a custom Liquid removal script with their shop URL pre-filled.
### "I canceled my trial and still got charged."
**Diagnose:** Either (a) the trial-end email never fired, (b) the cancel button is buried, or (c) the cancel was processed but didn't propagate to the Billing API.
**Fix:** Refund first. Then audit: trial-ending email job, in-app cancel button placement (must be on the main settings screen), Billing API webhook subscription.
### "Your AI bot keeps timing out / I can't reach a human."
**Diagnose:** Support chat without a human-exit path.
**Fix:** Add "Talk to a human" as the always-visible top option in the chat. Route directly to a real agent. Publish your SLA.
### "Your app broke after the Shopify checkout update."
**Diagnose:** You're using a deprecated checkout API or hard-coded REST.
**Fix:** Migrate to Checkout UI Extensions (for checkout) or GraphQL Admin 2026-01 (for everything else). Subscribe to Shopify Dev Changelog. Test in CI against the latest checkout extensibility release.
### "Your app charges fees the merchant didn't expect."
**Diagnose:** Usage-based charges without a real-time meter, or transaction fees buried in fine print.
**Fix:** Surface usage in the admin home screen with a clear meter. Warning emails at 80/90/100%. Disclose transaction fees on the pricing page in the same font size as the base price.
### "My reviews disappeared after I paused the app."
**Diagnose:** Data hostage pattern — pausing the app hides UGC content even though the merchant still owns it.
**Fix:** Pause should never delete or hide data. Provide CSV / JSON export at all times. Show review counts even on free / paused plans.
### "Your popup appears on every page even with frequency caps."
**Diagnose:** Two injection mechanisms (App Embed + ScriptTag fallback), or session storage is not being respected, or the cap is being reset on navigation.
**Fix:** Single injection path. Cap stored in `localStorage` keyed by shop + visitor ID. Respect `prefers-reduced-motion`. X-button hit area minimum 44×44px.
### "Your translation app left a language dropdown in my theme after I uninstalled."
**Diagnose:** Translation app wrote a custom snippet into the theme that the uninstall hook didn't clean.
**Fix:** Migrate to App Embeds (which clean automatically). For affected merchants, ship a one-click theme cleanup. Add a pre-uninstall warning surfacing what code will be removed.
### "Your app shows 100% optimized but nothing changed in my HTML."
**Diagnose:** Booster SEO pattern — writing values to your own database but never pushing them to the front end via metafield / theme.
**Fix:** Stop reporting success that you cannot verify against the storefront HTML. Add a "verify in storefront" check that fetches the live page and asserts the change is present.
---
## 11. 30 verbatim merchant quotes — preserved with URL citations
These are the words real merchants used. Reading them is the cheapest pain-prevention exercise you will ever do.
1. **"Why am I being overcharged on Shopify for subscriptions?"** — [community.shopify.com/t/why-am-i-being-overcharged-on-shopify-for-subscriptions/314115](https://community.shopify.com/t/why-am-i-being-overcharged-on-shopify-for-subscriptions/314115)
2. **"Apps on Shopify used to be reasonable. You could justify $5 to $10 a month for features. If you do even a little business you are going to be at the top tier of an app."** — [community.shopify.com/t/the-price-of-apps-is-completely-out-of-control/419098](https://community.shopify.com/t/the-price-of-apps-is-completely-out-of-control/419098)
3. **"They held my store and subscriptions hostage for weeks before I was able to migrate away from them, uninstall the app, report them to Shopify, and file chargebacks."** — Bold Subscriptions 1-star. [apps.shopify.com/bold-subscriptions/reviews](https://apps.shopify.com/bold-subscriptions/reviews)
4. **"Why am I still billed for an uninstalled app on Shopify?"** — [community.shopify.com/t/why-am-i-still-billed-for-an-uninstalled-app-on-shopify/191216](https://community.shopify.com/t/why-am-i-still-billed-for-an-uninstalled-app-on-shopify/191216)
5. **"Why is a merchant that uninstalled our app seeing charges two months later?"** — developer-side. [community.shopify.dev/t/why-is-a-merchant-that-uninstalled-our-app-seeing-charges-two-months-later/23625](https://community.shopify.dev/t/why-is-a-merchant-that-uninstalled-our-app-seeing-charges-two-months-later/23625)
6. **"I canceled my free trial in time, and still got charged."** — [community.shopify.com/c/shopify-discussions/i-canceled-my-free-trial-in-time-and-still-got-charged/td-p/2282529](https://community.shopify.com/c/shopify-discussions/i-canceled-my-free-trial-in-time-and-still-got-charged/td-p/2282529)
7. **"Tried to cancel subscription after free trial was charged."** — [community.shopify.com/c/shopify-discussions/tried-to-cancel-subscription-after-free-trial-was-charged/td-p/851254](https://community.shopify.com/c/shopify-discussions/tried-to-cancel-subscription-after-free-trial-was-charged/td-p/851254)
8. **"Why am I still being charged for POS after uninstalling? I don't use it and uninstalled the app months ago."** — [community.shopify.com/t/why-am-i-still-being-charged-for-pos-after-uninstalling/286967](https://community.shopify.com/t/why-am-i-still-being-charged-for-pos-after-uninstalling/286967)
9. **"Anyone else getting screwed by Klaviyo's pricing?"** — [community.shopify.com/t/anyone-else-getting-screwed-by-klaviyos-pricing/561275](https://community.shopify.com/t/anyone-else-getting-screwed-by-klaviyos-pricing/561275)
10. **"Left over code from app."** — [community.shopify.com/c/site-speed/left-over-code-from-app/td-p/1581837](https://community.shopify.com/c/site-speed/left-over-code-from-app/td-p/1581837)
11. **"Leftover code from uninstalled app — how do I remove it from my website?"** — [community.shopify.com/t/leftover-code-from-uninstalled-app-how-do-i-remove-it-from-my-website/294871](https://community.shopify.com/t/leftover-code-from-uninstalled-app-how-do-i-remove-it-from-my-website/294871)
12. **"I paused the Loox Application and all my Reviews are gone."** — [community.shopify.com/c/shopify-apps/i-paused-the-loox-application-and-all-my-reviews-are-gone/m-p/2029071](https://community.shopify.com/c/shopify-apps/i-paused-the-loox-application-and-all-my-reviews-are-gone/m-p/2029071)
13. **"Despite settings to display the popup only once per session and to stop showing it once the popup window closed or email address submitted, the popup would still appear after every newly loaded page which is very annoying for customers."** — [community.shopify.com/t/recurring-discount-popup-by-popup-smart-app/412559](https://community.shopify.com/t/recurring-discount-popup-by-popup-smart-app/412559)
14. **"Is Shopify ever going to take fake app store reviews seriously?"** — [community.shopify.com/t/is-shopify-ever-going-to-take-fake-app-store-reviews-seriously/569409](https://community.shopify.com/t/is-shopify-ever-going-to-take-fake-app-store-reviews-seriously/569409)
15. **"Common issues seem to repeat across apps, but they're buried under noise and generic feedback."** — [news.ycombinator.com/item?id=46897012](https://news.ycombinator.com/item?id=46897012)
16. **"How can I reach support when the AI bot keeps timing out?"** — [community.shopify.com/t/how-can-i-reach-support-when-the-ai-bot-keeps-timing-out/413511](https://community.shopify.com/t/how-can-i-reach-support-when-the-ai-bot-keeps-timing-out/413511)
17. **"Each question took 6 hours+ to get a response on their own chat function."** — Gorgias. [apps.shopify.com/helpdesk/reviews](https://apps.shopify.com/helpdesk/reviews)
18. **"After a recent app update requiring reconfiguration, support was unresponsive, the team was pleasant but unwilling to take responsibility, and the user was given confusing instructions while being stuck in a continuous support loop."** — ReConvert 1-star. [apps.shopify.com/reconvert-upsell-cross-sell/reviews?page=2&ratings%5B%5D=1](https://apps.shopify.com/reconvert-upsell-cross-sell/reviews?page=2&ratings%5B%5D=1)
19. **"The app has taken money from our account without authorization, approximately $1600 in unauthorized charges. The app became useless after Shopify's November checkout app update."** — ReConvert 1-star. [apps.shopify.com/reconvert-upsell-cross-sell/reviews](https://apps.shopify.com/reconvert-upsell-cross-sell/reviews)
20. **"Some merchants unable to access and apply Checkout Blocks App functions — checkout blocks has been broken for about 12 hours, with all functions on stores being wiped and the blocks while still in the app being removed from the checkout."** — [community.shopify.dev/t/some-merchants-unable-to-access-and-apply-checkout-blocks-app-functions/27602](https://community.shopify.dev/t/some-merchants-unable-to-access-and-apply-checkout-blocks-app-functions/27602)
21. **"Custom Checkout Apps Not Working with Shop Pay — some custom checkout apps that allow customers to enter VAT numbers and add product cross-sells are no longer available when a customer with a Shop Pay account enters the checkout."** — [community.shopify.dev/t/custom-checkout-apps-not-working-with-shop-pay/11181](https://community.shopify.dev/t/custom-checkout-apps-not-working-with-shop-pay/11181)
22. **"This app isn't compatible with your store."** — [community.shopify.com/t/this-app-isnt-compatible-with-your-store/298151](https://community.shopify.com/t/this-app-isnt-compatible-with-your-store/298151)
23. **"Why is my store speed slowing down and how can I improve it?"** — [community.shopify.com/c/shopify-discussions/why-is-my-store-speed-slowing-down-and-how-can-i-improve-it/m-p/2221261](https://community.shopify.com/c/shopify-discussions/why-is-my-store-speed-slowing-down-and-how-can-i-improve-it/m-p/2221261)
24. **"Too Many Shortcomings Requiring Too Many Apps."** — [community.shopify.com/t/too-many-shortcomings-requiring-too-many-apps/181229/6](https://community.shopify.com/t/too-many-shortcomings-requiring-too-many-apps/181229/6)
25. **"Shopify Personal Access Tokens that could give attackers full administrative control over a store, Facebook/Meta Ads tokens that allowed fraudulent ad campaigns, and real-time site analytics events offering reconnaissance data for targeted attacks."** — Consentik plugin breach (Built-for-Shopify badged, 4.9 stars, ~4,180 stores). [cybernews.com/security/shopify-plugin-consentik-data-leak](https://cybernews.com/security/shopify-plugin-consentik-data-leak/)
26. **"Booster SEO showed everything as 100% optimized when in reality none of the changes were actually being applied to their store — product images had no alt text and meta tags were never pushed to the frontend."** — [apps.shopify.com/booster-apps-seo-optimizer/reviews](https://apps.shopify.com/booster-apps-seo-optimizer/reviews)
27. **"Privy continued charging for nearly six years on an inaccessible second account, with the user only discovering this in 2025. After providing full documentation, Privy refunded only six months of charges out of 70+ months and refused to refund the remainder."** — Privy 1-star. [apps.shopify.com/privy/reviews](https://apps.shopify.com/privy/reviews)
28. **"After uninstalling the Translate & Adapt app, a language selector dropdown remained embedded in the storefront — with no notification that theme elements persist after uninstall."** — Translate & Adapt (Shopify-built).
29. **"You install an app to solve one problem, then another for a different feature, and before you know it, you're paying $200+ per month in app subscriptions, your site loads slowly, and some apps don't work well together."** — [painonsocial.com/blog/shopify-problems-reddit](https://painonsocial.com/blog/shopify-problems-reddit)
30. **"Bug reports submitted a month ago. No fix. They told me publicly on the App Store that I haven't been ignored. I have."** — Willdesk reviewer. The gaslighting reply made the rating worse.
---
## Closing principle
Every anti-pattern in this skill exists because someone, at some point, optimized for a short-term metric (installs, conversion-to-paid, revenue per install, review count) at the cost of merchant trust. The Shopify ecosystem is small enough that reputation compounds — both ways.
Merchants who hate your app will tell other merchants on Reddit, in Slack groups, in agency Discords, on Trustpilot, on the BBB, in App Store reviews that outrank your listing on Google. Merchants who love your app will tell other merchants in exactly the same channels.
You do not get to opt out of the feedback loop. You only get to choose which loop is feeding.
Build the app a thoughtful merchant would forgive when something breaks. That's the bar.
metafields-metaobjects17.6 KB
---
name: metafields-metaobjects
description: "Custom data fields and objects specification, namespace/key management, definition creation, querying, and Liquid theme access. Triggers include: 'metafield', 'metaobject', 'custom field', 'custom data', 'namespace key', 'metafield definition', 'metaobject type', 'product metafield', 'variant metafield', 'customer custom field', 'order metafield'."
---
# Shopify Metafields and Metaobjects Guide
## Metafields vs Metaobjects: When to Use Each
### Metafields
**Purpose:** Store custom key-value data on existing resources (products, orders, customers, etc.)
**Use Metafields When:**
- Adding custom attributes to existing resources
- Simple key-value relationships
- Data is tightly coupled to the resource
- Needed in themes (Liquid access)
- Examples: size chart URL, custom color, warranty period, gift message
**Characteristics:**
- Attached to existing resource types
- Simple string or typed values
- Queryable via GraphQL
- Accessible in Liquid templates
- Max 2,500 metafields per resource
- Namespace + key = unique identifier
- Supports display in admin UI via definitions
### Metaobjects
**Purpose:** Define custom data types/records independent of resources
**Use Metaobjects When:**
- Creating independent data structures
- Multi-field records needed
- Reusable data types across shop
- Complex relationships
- Data referenced by multiple resources
- Examples: FAQs, reviews, testimonials, size guides, lookbooks, staff profiles
**Characteristics:**
- Independent data type (like custom table)
- Multiple fields with defined types
- Queryable via GraphQL
- Can be referenced by products/collections via metafield
- Organized in admin UI
- Supports indexing and search
- Better for data that stands alone
**Comparison Table:**
| Feature | Metafield | Metaobject |
|---------|-----------|-----------|
| Attached to resources | Yes | No (standalone) |
| Multi-field support | No (single value) | Yes |
| Admin UI form | Via definition | Native editor |
| Liquid access | Direct | Via reference metafield |
| Relationship support | One-way (to resource) | One-way (reference metafield) |
| Reusability | Per resource type | Across shop |
| Use case | Quick attributes | Structured data |
## Metafield Resource Types
The following resources support metafields:
1. **Product** - 2,500 max metafields per product
2. **Product Variant** - 2,500 max metafields
3. **Order** - Custom order data
4. **Customer** - Customer profiles
5. **Collection** - Collection attributes
6. **Draft Order** - Pre-order metadata
7. **Shop** - Store-wide settings
8. **Location** - Warehouse/store details
9. **Company** - B2B company info
10. **Company Location** - B2B location data
11. **Market** - Market-specific metadata
12. **File** - Asset metadata
## Metafield Type System
### Supported Types
**Text Types:**
- `single_line_text_field` - Max 255 chars (search enabled, sortable)
- `multi_line_text_field` - Max 5,000 chars (search enabled)
- `rich_text_field` - HTML + Markdown (max 100,000 chars)
**Numeric Types:**
- `number_integer` - 64-bit signed integer
- `number_decimal` - Decimal number (up to 8 decimal places)
**Date/Time Types:**
- `date_field` - ISO 8601 date (YYYY-MM-DD)
- `date_time_field` - ISO 8601 datetime (2026-05-04T14:30:00Z)
**Boolean:**
- `boolean` - true/false
**Reference Types:**
- `product_reference` - Reference to Shopify product
- `collection_reference` - Reference to collection
- `file_reference` - Reference to file in Files API
- `metaobject_reference` - Reference to metaobject
**Display Types:**
- `color` - Color value (hex #RRGGBB)
- `weight` - Weight with unit (grams, kilograms, pounds, ounces)
- `volume` - Volume with unit
- `dimension` - Dimension with unit
- `url` - Full URL (max 2,048 chars)
**JSON Type:**
- `json` - Valid JSON object/array (max 100,000 chars, searchable)
**List Type:**
- `list.*` - Array of type (e.g., `list.metaobject_reference`)
### Type Examples
**Single Line Text:**
```graphql
{
namespace: "app",
key: "manufacturer_id",
type: "single_line_text_field",
value: "MFG-12345"
}
```
**JSON for Complex Structure:**
```graphql
{
namespace: "app",
key: "compatibility_matrix",
type: "json",
value: "{\"platforms\": [\"iOS\", \"Android\"], \"min_version\": \"12.0\"}"
}
```
**Weight:**
```graphql
{
namespace: "app",
key: "shipping_weight",
type: "weight",
value: "{\"value\": 2.5, \"unit\": \"kg\"}"
}
```
**Product Reference:**
```graphql
{
namespace: "app",
key: "replacement_product",
type: "product_reference",
value: "gid://shopify/Product/123456789"
}
```
**List of Metaobject References:**
```graphql
{
namespace: "app",
key: "related_guides",
type: "list.metaobject_reference",
value: "[\"gid://shopify/Metaobject/12345\", \"gid://shopify/Metaobject/67890\"]"
}
```
## Namespace and Key Conventions
### Namespace Rules
- Unique to your app/vendor
- Used to organize related metafields
- Immutable once set
- Best practice: use `app` or your app handle
- Example namespaces: `app`, `seo`, `loyalty`, `inventory_tracking`
### Key Rules
- Lowercase alphanumeric + underscores
- Max 64 characters
- Immutable once set
- Should be descriptive
- Examples: `custom_size`, `warranty_months`, `supplier_sku`
### Naming Convention Table
| Use Case | Namespace | Key | Full Name |
|----------|-----------|-----|-----------|
| App custom fields | `app` | `custom_color` | app.custom_color |
| SEO metadata | `seo` | `meta_description` | seo.meta_description |
| Loyalty program | `loyalty` | `points_balance` | loyalty.points_balance |
| B2B company | `b2b` | `account_manager` | b2b.account_manager |
| Inventory tracking | `inventory` | `reorder_point` | inventory.reorder_point |
| Theme customization | `theme` | `custom_layout` | theme.custom_layout |
### Best Practices
1. Group related metafields under same namespace
2. Use descriptive, business-friendly key names
3. Avoid generic names (`data`, `meta`, `custom`)
4. Document namespace/key mapping in app
5. Consider storefront visibility needs (use `visible_to_storefront` flag)
## Metafield Definition Creation
Definitions enable admin UI forms and validation.
### Creating Definition via GraphQL
```graphql
mutation CreateMetafieldDefinition($definition: MetafieldDefinitionInput!) {
metafieldDefinitionCreate(definition: $definition) {
metafieldDefinition {
id
name
namespace
key
type
validations {
name
value
}
}
userErrors {
field
message
}
}
}
```
**Input Variables:**
```json
{
"definition": {
"name": "Product Color",
"namespace": "app",
"key": "product_color",
"description": "Custom color classification for storefront filters",
"type": "single_line_text_field",
"ownerType": "PRODUCT",
"visibleToStorefront": true,
"validations": [
{
"name": "max_length",
"value": "50"
}
]
}
}
```
### Definition with Rich Validation
```graphql
mutation {
metafieldDefinitionCreate(definition: {
name: "Reorder Point"
namespace: "inventory"
key: "reorder_point"
type: "number_integer"
ownerType: "PRODUCT_VARIANT"
description: "Minimum inventory level before reorder needed"
validations: [
{ name: "min", value: "1" }
{ name: "max", value: "10000" }
]
}) {
metafieldDefinition { id }
userErrors { field message }
}
}
```
### Definition for Metaobject Reference
```graphql
mutation {
metafieldDefinitionCreate(definition: {
name: "Related Reviews"
namespace: "app"
key: "related_reviews"
type: "list.metaobject_reference"
ownerType: "PRODUCT"
description: "Customer reviews for this product"
validations: [
{
name: "metaobject_definition_id"
value: "gid://shopify/MetaobjectDefinition/1234567"
}
]
}) {
metafieldDefinition { id }
userErrors { field message }
}
}
```
## Metaobject Type Definition
Create custom data structures for shop.
### Creating Metaobject Definition
```graphql
mutation CreateMetaobjectDefinition($definition: MetaobjectDefinitionInput!) {
metaobjectDefinitionCreate(definition: $definition) {
metaobjectDefinition {
id
type
displayNameKey
fields {
key
type
required
}
}
userErrors {
field
message
}
}
}
```
**Input Variables:**
```json
{
"definition": {
"type": "faq_item",
"displayNameKey": "question",
"description": "Frequently asked questions",
"fields": [
{
"key": "question",
"type": "single_line_text_field",
"required": true,
"description": "FAQ question text"
},
{
"key": "answer",
"type": "rich_text_field",
"required": true,
"description": "FAQ answer (HTML + Markdown)"
},
{
"key": "category",
"type": "single_line_text_field",
"required": false,
"description": "Topic category"
},
{
"key": "display_order",
"type": "number_integer",
"required": false,
"description": "Sort order in list"
}
]
}
}
```
### Creating Metaobject Instances
Once definition created, create records:
```graphql
mutation CreateMetaobject($input: MetaobjectCreateInput!) {
metaobjectCreate(input: $input) {
metaobject {
id
type
fields {
key
value
}
}
userErrors {
field
message
}
}
}
```
**Variables:**
```json
{
"input": {
"type": "faq_item",
"fields": [
{
"key": "question",
"value": "How do I return an item?"
},
{
"key": "answer",
"value": "<p>Returns accepted within 30 days. Visit our <a href=\"/policies/returns\">returns page</a>.</p>"
},
{
"key": "category",
"value": "Returns & Exchanges"
},
{
"key": "display_order",
"value": "1"
}
]
}
}
```
## Querying Metafields
### Product Metafields
```graphql
query GetProductWithMetafields($id: ID!) {
product(id: $id) {
id
title
metafields(first: 10) {
edges {
node {
id
namespace
key
type
value
}
}
}
}
}
```
**Variables:**
```json
{
"id": "gid://shopify/Product/123456789"
}
```
### Filtered Metafields
```graphql
query GetSpecificMetafield($id: ID!) {
product(id: $id) {
id
customColor: metafield(namespace: "app", key: "custom_color") {
value
type
}
sizeChart: metafield(namespace: "app", key: "size_chart_url") {
value
}
}
}
```
### Metaobject Query
```graphql
query GetMetaobject($id: ID!) {
metaobject(id: $id) {
id
type
displayName
fields {
key
value
type
}
}
}
```
### List Metaobjects
```graphql
query ListFAQs {
metaobjects(type: "faq_item", first: 10) {
edges {
node {
id
displayName
question: field(key: "question") { value }
answer: field(key: "answer") { value }
category: field(key: "category") { value }
}
}
}
}
```
### Metaobject References in Products
```graphql
query GetProductWithReferences($id: ID!) {
product(id: $id) {
id
title
relatedGuides: metafield(
namespace: "app",
key: "related_guides"
) {
value
type
reference {
... on Metaobject {
id
displayName
type
}
}
}
}
}
```
## Storefront API Access
### Public Metafield Access
Metafields with `visible_to_storefront: true` accessible via Storefront API:
```graphql
query GetProductStorefront($handle: String!) {
productByHandle(handle: $handle) {
id
title
metafields(first: 10) {
edges {
node {
namespace
key
value
type
}
}
}
}
}
```
### Storefront Query Example
```typescript
// Client-side storefront API query
const query = `
query GetProductMetafields($handle: String!) {
productByHandle(handle: $handle) {
id
title
productColor: metafield(namespace: "app", key: "product_color") {
value
}
manufacturerId: metafield(namespace: "app", key: "manufacturer_id") {
value
}
}
}
`;
const response = await fetch('https://myshop.myshopify.com/api/2026-01/graphql.json', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': PUBLIC_ACCESS_TOKEN
},
body: JSON.stringify({
query,
variables: { handle: 'blue-t-shirt' }
})
});
const { data } = await response.json();
console.log(data.productByHandle.productColor.value);
```
## Liquid Theme Access
### Product Metafield in Liquid
```liquid
{%- assign custom_color = product.metafields.app.custom_color.value -%}
<p>Color: {{ custom_color }}</p>
{%- assign warranty = product.metafields.app.warranty_months.value -%}
<p>Warranty: {{ warranty }} months</p>
{%- assign size_chart = product.metafields.app.size_chart_url.value -%}
<a href="{{ size_chart }}">View Size Chart</a>
```
### Metaobject Reference in Liquid
```liquid
{%- assign faq_list = product.metafields.app.related_faqs.value -%}
<div class="faqs">
{%- for faq in faq_list -%}
<div class="faq-item">
<h3>{{ faq.question.value }}</h3>
<p>{{ faq.answer.value }}</p>
</div>
{%- endfor -%}
</div>
```
### Conditional Display
```liquid
{%- if product.metafields.app.is_discontinued.value -%}
<div class="alert">This product is discontinued</div>
{%- endif -%}
```
### JSON Metafield in Liquid
```liquid
{%- assign compatibility = product.metafields.app.compatibility_matrix.value -%}
<ul>
{%- for platform in compatibility.platforms -%}
<li>{{ platform }}</li>
{%- endfor -%}
</ul>
```
## Visible vs Hidden Metafields
### visible_to_storefront: true
**Accessible:**
- Storefront API (public queries)
- Liquid templates
- Customer-facing applications
- Search engines (SEO indexing)
**Use for:**
- Product colors, sizes
- SEO metadata
- Customer-facing attributes
- Storefront display data
### visible_to_storefront: false
**Accessible:**
- Admin GraphQL API only
- Admin dashboard
- Backend systems
**Use for:**
- Internal tracking IDs
- Supplier information
- Cost/margin data
- Backend workflow flags
## Setting Metafields via GraphQL
### metafieldsSet Mutation
```graphql
mutation SetMetafields($input: MetafieldsSetInput!) {
metafieldsSet(input: $input) {
metafields {
id
namespace
key
value
type
}
userErrors {
field
message
}
}
}
```
**Input Variables:**
```json
{
"input": {
"ownerId": "gid://shopify/Product/123456789",
"metafields": [
{
"namespace": "app",
"key": "custom_color",
"type": "single_line_text_field",
"value": "navy-blue"
},
{
"namespace": "app",
"key": "warranty_months",
"type": "number_integer",
"value": "24"
},
{
"namespace": "seo",
"key": "meta_description",
"type": "single_line_text_field",
"value": "Premium navy blue t-shirt with guaranteed quality"
}
]
}
}
```
### Bulk Setting via GraphQL
```graphql
mutation BulkSetMetafields($input: [MetafieldsSetInput!]!) {
metafieldsSet(input: $input) {
metafields { id }
userErrors { field message }
}
}
```
## Common Gotchas
| Issue | Cause | Solution |
|-------|-------|----------|
| Metafield not visible in Liquid | `visible_to_storefront: false` | Set to true in definition |
| JSON parse error | Invalid JSON string | Validate JSON before setting |
| Reference broken | Referenced object deleted | Add referential integrity checks |
| Type mismatch on update | Changing type after creation | Create new key, deprecate old |
| Admin UI doesn't show | Definition not created | Create definition via mutation |
| Max length exceeded | Value too long | Check validation rules |
| List reference empty | Metaobject definition ID invalid | Verify definition exists |
| Storefront query fails | Missing scopes | Ensure read_products scope |
| Slow metafield queries | Querying too many fields | Limit first parameter, use pagination |
| Cost explosion | Querying all metafields | Limit first parameter, use pagination |
## Performance Considerations
### Querying Efficiently
**Bad - Expensive Cost:**
```graphql
query {
products(first: 100) {
edges {
node {
metafields(first: 250) { # 250 metafields per product!
edges { node { value } }
}
}
}
}
}
```
**Good - Optimized:**
```graphql
query {
products(first: 100) {
edges {
node {
id
customColor: metafield(namespace: "app", key: "custom_color") {
value
}
warranty: metafield(namespace: "app", key: "warranty_months") {
value
}
}
}
}
}
```
### Rate Limit Impact
Metafield operations incur API cost:
- Querying metafield: 1 point
- Setting metafield: 10 points per metafield
- Bulk operations: batch multiple sets
## Best Practices
1. **Define metafields upfront** - Creates admin UI automatically
2. **Use consistent namespacing** - Group related fields logically
3. **Plan for storefront visibility** - Design with customer access in mind
4. **Validate data types** - Use appropriate types to prevent errors
5. **Document your metafields** - Maintain namespace/key reference
6. **Consider performance** - Query only needed metafields
7. **Use metaobjects for reusable data** - More organized than scattered metafields
8. **Prefer references over IDs** - Use product_reference type, not storing IDs
9. **Implement fallbacks** - Handle missing metafields in themes
10. **Version your definitions** - Plan for future schema changes
polaris-ui22.5 KB
---
name: polaris-ui
description: "Use this skill for Polaris 12.x UI Components. Triggers include: 'polaris components', 'react ui shopify', 'approvider initialization', 'polaris form', 'polaris button', 'polaris card', 'polaris table indexTable', 'polaris modal dialog', 'polaris select dropdown', 'polaris textfield input', 'polaris checkbox radio', 'polaris navigation', 'polaris layout blocklist inlinestack', 'polaris design tokens', 'polaris icons', 'polaris stack grid', 'polaris page frame resource list', 'polaris loading spinner', 'polaris toast notification', 'polaris banner alert', 'shopify ui component library', 'polaris 12', 'polaris css import', 'polaris styled components', 'polaris form validation', 'polaris accessibility a11y', 'polaris icon reference', 'polaris theme provider', 'polaris page actions buttons'."
---
# Polaris 12.x UI Components
Polaris 12.x is Shopify's official React component library for building consistent, accessible admin and embedded app UIs. This skill covers Polaris component patterns, AppProvider initialization, 25+ commonly-used components, design tokens, form validation, and real-world layout patterns for Shopify apps.
## When Asked
**When asked to build a Shopify app UI with forms and inputs:**
Provide a complete React component example using AppProvider, TextField, Select, Checkbox, and Button components with proper state management and form submission handlers.
**When asked about Polaris layout and spacing:**
Explain BlockStack, InlineStack, and InlineGrid with code examples showing proper spacing, padding, and responsive behavior using gap and padding props.
**When asked to create data tables or resource lists:**
Use IndexTable for tabular data with sorting, selection, and bulk actions; ResourceList for browseable item collections with filters and search.
**When asked about Polaris design tokens or theming:**
Provide color, typography, and spacing token values; explain how to access tokens via CSS variables and customize with custom properties.
**When asked to implement forms with validation:**
Build controlled forms with TextField, Select, Checkbox components; add error states, help text, and form submission with validation logic.
**When asked about Polaris icons and iconography:**
Reference available icons (Icon component, 150+ icons from @shopify/polaris-icons), show Icon usage with color and size props.
**When asked to create modals, dialogs, or overlays:**
Use Modal component for full-screen overlays, Popover for inline popups, and ContextualSaveBar for unsaved changes UI patterns.
---
## Polaris 12.x Architecture Overview
Polaris provides:
- **AppProvider**: Required root component that initializes Polaris context (i18n, theme, CSRF token)
- **25+ UI Components**: Button, Card, Layout (BlockStack, InlineStack, InlineGrid), TextField, Select, Modal, IndexTable, ResourceList, etc.
- **Design Tokens**: Color (primary, success, warning, critical), typography (display, heading, body), spacing (0.5rem, 1rem, 1.5rem, 2rem), shadow, border-radius
- **Polaris Icons**: 150+ SVG icons via @shopify/polaris-icons; Icon component wrapper
- **Accessibility**: WCAG 2.1 AA compliant, semantic HTML, ARIA labels, keyboard navigation
- **CSS Imports**: Import from @shopify/polaris (includes Tailwind-like utility classes; use CSS modules or styled-components for scoping)
### Installation & Setup
```bash
npm install @shopify/polaris @shopify/polaris-icons
# or yarn / pnpm
```
### AppProvider Initialization (Required)
```tsx
import React from 'react';
import { AppProvider } from '@shopify/polaris';
import '@shopify/polaris/build/esm/styles.css';
function App() {
return (
<AppProvider i18n={{}}>
<YourComponent />
</AppProvider>
);
}
export default App;
```
**Props:**
- `i18n`: Object with translation strings (optional; defaults to English)
- `features`: Object to enable experimental features
- `colorScheme`: Light (default) or dark mode
- `link`: Function for custom link navigation
---
## Core Layout Components
### BlockStack (Vertical Stack)
```tsx
import { BlockStack, Text } from '@shopify/polaris';
export function VerticalLayout() {
return (
<BlockStack gap="400">
<Text as="h1">Title</Text>
<Text as="p">Content item 1</Text>
<Text as="p">Content item 2</Text>
</BlockStack>
);
}
```
**Props:**
- `gap`: "100" | "200" | "300" | "400" | "500" (controls vertical spacing; default "200")
- `align`: "start" | "center" | "end"
- `inlineAlign`: "start" | "center" | "end"
- `children`: React elements
### InlineStack (Horizontal Stack)
```tsx
import { InlineStack, Button } from '@shopify/polaris';
export function HorizontalLayout() {
return (
<InlineStack gap="400" wrap={false}>
<Button>Save</Button>
<Button>Cancel</Button>
</InlineStack>
);
}
```
**Props:**
- `gap`: "100" | "200" | "300" | "400" | "500"
- `align`: "start" | "center" | "end" | "space-between" | "space-around"
- `wrap`: boolean (default true)
- `blockAlign`: "start" | "center" | "end" | "baseline"
### InlineGrid (Responsive Grid)
```tsx
import { InlineGrid, Card } from '@shopify/polaris';
export function ResponsiveGrid() {
return (
<InlineGrid columns={['oneThird', 'twoThirds']} gap="400">
<Card title="Sidebar">Filters</Card>
<Card title="Main">Content</Card>
</InlineGrid>
);
}
```
**Props:**
- `columns`: string[] (e.g., ["oneHalf", "oneHalf"], ["oneThird", "twoThirds"], ["full"]; responsive)
- `gap`: "100" | "200" | "300" | "400" | "500"
### Box (Generic Container)
```tsx
import { Box } from '@shopify/polaris';
export function PaddedBox() {
return (
<Box padding="400" background="bg-surface-secondary">
Content with padding
</Box>
);
}
```
**Props:**
- `padding`: "0" | "100" | "200" | "300" | "400" | "500"
- `paddingBlock`: vertical padding
- `paddingInline`: horizontal padding
- `background`: "bg-surface" | "bg-surface-secondary" | "bg-fill" | "bg-fill-selected"
- `border`: "divider"
- `borderRadius`: "base" | "large"
---
## Form Components
### TextField (Text Input)
```tsx
import { TextField } from '@shopify/polaris';
import { useState } from 'react';
export function FormInput() {
const [value, setValue] = useState('');
const [error, setError] = useState('');
const handleChange = (value) => {
setValue(value);
if (value.length < 3) {
setError('Minimum 3 characters');
} else {
setError('');
}
};
return (
<TextField
label="Product Name"
value={value}
onChange={handleChange}
error={error}
helpText="Enter the product name (3+ chars)"
placeholder="e.g., Awesome Widget"
requiredIndicator
/>
);
}
```
**Props:**
- `label`: string
- `value`: string (controlled component)
- `onChange`: (value: string) => void
- `error`: string | true (displays error message or red border)
- `helpText`: string (gray text below input)
- `placeholder`: string
- `type`: "text" | "email" | "password" | "tel" | "url" | "number" (default "text")
- `disabled`: boolean
- `readOnly`: boolean
- `requiredIndicator`: boolean
- `maxLength`: number
- `prefix`: ReactNode (text/icon before input)
- `suffix`: ReactNode (text/icon after input)
### Select (Dropdown)
```tsx
import { Select } from '@shopify/polaris';
import { useState } from 'react';
export function DropdownSelect() {
const [selected, setSelected] = useState('option1');
return (
<Select
label="Status"
options={[
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
{ label: 'Archived', value: 'archived' },
]}
value={selected}
onChange={setSelected}
/>
);
}
```
**Props:**
- `label`: string
- `options`: { label: string; value: string }[]
- `value`: string (controlled)
- `onChange`: (value: string) => void
- `disabled`: boolean
- `placeholder`: string
### Checkbox & RadioButton
```tsx
import { Checkbox, RadioButton, BlockStack } from '@shopify/polaris';
import { useState } from 'react';
export function CheckboxExample() {
const [checked, setChecked] = useState(false);
return (
<BlockStack gap="200">
<Checkbox
label="I agree to terms"
checked={checked}
onChange={() => setChecked(!checked)}
helpText="Read our terms before enabling"
/>
</BlockStack>
);
}
```
**Props:**
- `label`: string
- `checked`: boolean
- `onChange`: (checked: boolean) => void
- `disabled`: boolean
- `error`: boolean | string
### Form Component (Wrapper)
```tsx
import { Form, FormLayout, TextField, Select, Button } from '@shopify/polaris';
import { useState } from 'react';
export function AppForm() {
const [formData, setFormData] = useState({ name: '', category: '' });
const [errors, setErrors] = useState({});
const handleSubmit = (e) => {
e.preventDefault();
if (!formData.name) {
setErrors({ name: 'Required' });
return;
}
console.log('Submit:', formData);
};
return (
<Form onSubmit={handleSubmit}>
<FormLayout>
<TextField
label="Product Name"
value={formData.name}
onChange={(value) => setFormData({ ...formData, name: value })}
error={errors.name}
requiredIndicator
/>
<Select
label="Category"
options={[
{ label: 'Electronics', value: 'electronics' },
{ label: 'Clothing', value: 'clothing' },
]}
value={formData.category}
onChange={(value) => setFormData({ ...formData, category: value })}
/>
<Button submit>Save Product</Button>
</FormLayout>
</Form>
);
}
```
---
## Data Display Components
### Card
```tsx
import { Card, Text, BlockStack } from '@shopify/polaris';
export function CardExample() {
return (
<Card title="Sales This Quarter" sectioned>
<BlockStack gap="200">
<Text as="p">$10,250 (↑ 12% from last quarter)</Text>
</BlockStack>
</Card>
);
}
```
**Props:**
- `title`: string | ReactNode
- `subtitle`: string
- `sectioned`: boolean (adds padding)
- `padding`: "0" | "200" | "400"
- `background`: "bg-surface" | "bg-fill"
- `children`: ReactNode
### IndexTable (Data Table)
```tsx
import {
IndexTable,
useIndexResourceState,
Button,
Text,
} from '@shopify/polaris';
import { useState } from 'react';
export function DataTable() {
const products = [
{ id: '1', name: 'Widget A', price: '$29.99', status: 'Active' },
{ id: '2', name: 'Widget B', price: '$39.99', status: 'Draft' },
];
const { selectedResources, allResourcesSelected, handleSelectionChange } =
useIndexResourceState(products);
return (
<IndexTable
resourceName={{ singular: 'product', plural: 'products' }}
itemCount={products.length}
selectedItemsCount={
allResourcesSelected ? 'All' : selectedResources.length
}
onSelectionChange={handleSelectionChange}
headings={[
{ title: 'Name' },
{ title: 'Price' },
{ title: 'Status' },
]}
>
{products.map((product) => (
<IndexTable.Row
key={product.id}
id={product.id}
selected={selectedResources.includes(product.id)}
>
<IndexTable.Cell>{product.name}</IndexTable.Cell>
<IndexTable.Cell>{product.price}</IndexTable.Cell>
<IndexTable.Cell>{product.status}</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
);
}
```
**Key Props:**
- `resourceName`: { singular: string; plural: string }
- `itemCount`: number (total items)
- `selectedItemsCount`: number | "All"
- `onSelectionChange`: (selected) => void
- `headings`: { title: string }[]
### ResourceList
```tsx
import { ResourceList, ResourceItem, Text, Button } from '@shopify/polaris';
export function ListExample() {
const items = [
{ id: '1', name: 'Product A', status: 'Active' },
{ id: '2', name: 'Product B', status: 'Draft' },
];
return (
<ResourceList
resourceName={{ singular: 'product', plural: 'products' }}
items={items}
renderItem={(item) => (
<ResourceItem
id={item.id}
accessibilityLabel={`View product ${item.name}`}
>
<Text variant="bodyMd" as="span">
{item.name}
</Text>
<Text variant="bodySm" as="span" tone="subdued">
{item.status}
</Text>
</ResourceItem>
)}
/>
);
}
```
---
## Modal & Overlay Components
### Modal
```tsx
import { Modal, Button, TextField, BlockStack } from '@shopify/polaris';
import { useState } from 'react';
export function ModalExample() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button onClick={() => setIsOpen(true)}>Open Modal</Button>
<Modal
open={isOpen}
onClose={() => setIsOpen(false)}
title="Create Product"
primaryAction={{
content: 'Save',
onAction: () => {
console.log('Save clicked');
setIsOpen(false);
},
}}
secondaryActions={[
{
content: 'Cancel',
onAction: () => setIsOpen(false),
},
]}
>
<Modal.Section>
<BlockStack gap="400">
<TextField label="Product Name" />
</BlockStack>
</Modal.Section>
</Modal>
</>
);
}
```
**Props:**
- `open`: boolean
- `onClose`: () => void
- `title`: string | ReactNode
- `primaryAction`: { content: string; onAction: () => void; loading?: boolean; disabled?: boolean }
- `secondaryActions`: same shape as primaryAction (array)
- `children`: ReactNode (Modal.Section wrapper recommended)
### ContextualSaveBar (Sticky Footer)
```tsx
import { ContextualSaveBar, Button } from '@shopify/polaris';
import { useState } from 'react';
export function UnsavedChangesBar() {
const [isDirty, setIsDirty] = useState(false);
return (
<>
{isDirty && (
<ContextualSaveBar
message="Unsaved changes"
saveAction={{
onAction: () => {
console.log('Save');
setIsDirty(false);
},
loading: false,
disabled: false,
}}
discardAction={{
onAction: () => {
console.log('Discard');
setIsDirty(false);
},
}}
/>
)}
<Button onClick={() => setIsDirty(true)}>Make Change</Button>
</>
);
}
```
---
## Navigation & Routing
### Navigation Component
```tsx
import { Navigation } from '@shopify/polaris';
import { HomeIcon, ProductIcon, OrdersIcon } from '@shopify/polaris-icons';
export function AppNavigation() {
return (
<Navigation location="/">
<Navigation.Section
items={[
{
url: '/',
label: 'Dashboard',
icon: HomeIcon,
},
{
url: '/products',
label: 'Products',
icon: ProductIcon,
},
{
url: '/orders',
label: 'Orders',
icon: OrdersIcon,
},
]}
/>
</Navigation>
);
}
```
**Props:**
- `location`: string (current URL for active indicator)
- `children`: Navigation.Section (groups of items)
- Navigation.Section.items[]: { url: string; label: string; icon?: React.ComponentType; badge?: string }
---
## Icons & Iconography
### Using Polaris Icons
```tsx
import { Icon, Button, InlineStack } from '@shopify/polaris';
import {
SaveIcon,
DeleteIcon,
SearchIcon,
PlusIcon,
} from '@shopify/polaris-icons';
export function IconExamples() {
return (
<InlineStack gap="400">
<Button icon={SaveIcon}>Save</Button>
<Button icon={DeleteIcon} variant="primary">
Delete
</Button>
<Icon source={SearchIcon} tone="base" />
</InlineStack>
);
}
```
**Common Icons:**
- SaveIcon, CancelIcon, DeleteIcon, EditIcon
- SearchIcon, FilterIcon, SortIcon
- PlusIcon, MinusIcon, ChevronDownIcon, ChevronRightIcon
- HomeIcon, ProductIcon, OrdersIcon, CustomerIcon
- CheckIcon, AlertIcon, InfoIcon, WarningIcon
**Icon Component Props:**
- `source`: React.ComponentType (the icon SVG)
- `tone`: "base" | "subdued" | "positive" | "warning" | "critical"
- `accessibilityLabel`: string
---
## Design Tokens & Theming
### Color Tokens
```
Primary: var(--p-color-interactive)
Success: var(--p-color-text-success)
Warning: var(--p-color-text-warning)
Critical: var(--p-color-text-critical)
Text: var(--p-color-text), var(--p-color-text-subdued)
Surface: var(--p-color-bg-surface), var(--p-color-bg-surface-secondary)
```
### Spacing Values
```
100: 0.25rem (4px)
200: 0.5rem (8px)
300: 1rem (16px)
400: 1.5rem (24px)
500: 2rem (32px)
600: 2.5rem (40px)
700: 3rem (48px)
```
### Typography
```
Display: font-size 2rem, font-weight 600 (headings)
Heading: font-size 1.5rem, font-weight 600 (titles)
BodyLg: font-size 1rem, font-weight 400 (body text)
BodyMd: font-size 0.875rem, font-weight 400 (default body)
BodySm: font-size 0.8125rem, font-weight 400 (secondary text)
Code: monospace, 0.875rem
```
### Using in Custom CSS
```css
.custom-box {
background: var(--p-color-bg-surface);
color: var(--p-color-text);
padding: var(--p-space-300);
border-radius: var(--p-border-radius-base);
}
```
---
## Worked Example: Product Management App
```tsx
import React, { useState } from 'react';
import {
AppProvider,
Page,
Layout,
Card,
IndexTable,
Button,
Modal,
TextField,
FormLayout,
InlineStack,
useIndexResourceState,
BlockStack,
Text,
} from '@shopify/polaris';
import { PlusIcon } from '@shopify/polaris-icons';
import '@shopify/polaris/build/esm/styles.css';
export function ProductApp() {
const [products, setProducts] = useState([
{ id: '1', name: 'Widget A', price: '$29.99', status: 'Active' },
{ id: '2', name: 'Widget B', price: '$39.99', status: 'Draft' },
]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [newProduct, setNewProduct] = useState({ name: '', price: '' });
const [errors, setErrors] = useState({});
const { selectedResources, handleSelectionChange } =
useIndexResourceState(products);
const handleAddProduct = () => {
setErrors({});
if (!newProduct.name) {
setErrors({ name: 'Product name is required' });
return;
}
setProducts([
...products,
{
id: String(Date.now()),
name: newProduct.name,
price: newProduct.price || '$0.00',
status: 'Draft',
},
]);
setNewProduct({ name: '', price: '' });
setIsModalOpen(false);
};
const handleDelete = () => {
setProducts(
products.filter((p) => !selectedResources.includes(p.id))
);
};
return (
<AppProvider i18n={{}}>
<Page
title="Products"
subtitle="Manage your product catalog"
primaryAction={{
content: 'Add Product',
icon: PlusIcon,
onAction: () => setIsModalOpen(true),
}}
>
<Layout>
<Layout.Section>
<Card>
{selectedResources.length > 0 && (
<Card.Section>
<InlineStack gap="400">
<Text as="p" tone="subdued">
{selectedResources.length} selected
</Text>
<Button
variant="primary"
tone="critical"
onClick={handleDelete}
>
Delete
</Button>
</InlineStack>
</Card.Section>
)}
<IndexTable
resourceName={{ singular: 'product', plural: 'products' }}
itemCount={products.length}
selectedItemsCount={selectedResources.length}
onSelectionChange={handleSelectionChange}
headings={[
{ title: 'Name' },
{ title: 'Price' },
{ title: 'Status' },
]}
>
{products.map((product) => (
<IndexTable.Row
key={product.id}
id={product.id}
selected={selectedResources.includes(product.id)}
>
<IndexTable.Cell>{product.name}</IndexTable.Cell>
<IndexTable.Cell>{product.price}</IndexTable.Cell>
<IndexTable.Cell>{product.status}</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
</Card>
</Layout.Section>
</Layout>
<Modal
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Add Product"
primaryAction={{
content: 'Save',
onAction: handleAddProduct,
}}
secondaryActions={[
{
content: 'Cancel',
onAction: () => setIsModalOpen(false),
},
]}
>
<Modal.Section>
<FormLayout>
<TextField
label="Product Name"
value={newProduct.name}
onChange={(value) =>
setNewProduct({ ...newProduct, name: value })
}
error={errors.name}
requiredIndicator
/>
<TextField
label="Price"
value={newProduct.price}
onChange={(value) =>
setNewProduct({ ...newProduct, price: value })
}
prefix="$"
/>
</FormLayout>
</Modal.Section>
</Modal>
</Page>
</AppProvider>
);
}
```
---
## Accessibility Best Practices
- Always provide semantic `label` props to form inputs
- Use `requiredIndicator` for required fields
- Provide `helpText` to clarify input expectations
- Use descriptive button labels; avoid "Click here"
- Set `accessibilityLabel` on icon-only buttons
- Use `ResourceItem.accessibilityLabel` for screen readers
- Use `tone` prop on Text and Icon for semantic color meaning, not just visual
---
## Common Patterns Checklist
- [ ] Wrap app in `<AppProvider>` with CSS import
- [ ] Use `BlockStack` for vertical layouts, `InlineStack` for horizontal
- [ ] Use `Card` to group related content
- [ ] Use `IndexTable` for tabular data, `ResourceList` for browseable items
- [ ] Use `Modal` for overlays; `ContextualSaveBar` for unsaved changes
- [ ] Import icons from `@shopify/polaris-icons`
- [ ] Validate form inputs and show `error` prop on invalid fields
- [ ] Use `formLayout` inside `<Form>` for proper spacing
- [ ] Use design token CSS variables for colors and spacing
- [ ] Test keyboard navigation (Tab, Enter, Escape) in all interactive components
shopify-app-store-ads9.52 KB
--- name: shopify-app-store-ads description: "Research, plan, launch, and optimize Shopify App Store ads for a Shopify app. Use when asked to advertise an app in the Shopify App Store, create an App Store Ads campaign, choose keywords or bids, estimate paid acquisition economics, diagnose ad performance, or turn Reddit/community research into a testable campaign. Require explicit approval of the daily and total spend before creating or enabling any paid campaign." --- # Shopify App Store Ads Use App Store ads to test qualified merchant acquisition, not to buy superficial installs. Treat the Shopify Partner Dashboard and current Shopify documentation as the authority for eligibility, available placements, bid behavior, billing, and reporting. ## Ground Rules 1. Verify that the app is publicly published and eligible before proposing launch. An active development version is not evidence of a public App Store listing or advertising eligibility. 2. Research live platform rules before acting. Start with Shopify's [App Store ads documentation](https://help.shopify.com/en/partners/marketing-and-promotions/app-store-ads), then verify the visible Partner Dashboard controls for the specific app. 3. Use community sources (Reddit, reviews, founder posts) to form keyword, pain-point, and creative hypotheses. Label them as anecdotal; do not present them as Shopify policy or benchmark data. 4. Never create, enable, or materially raise a paid campaign without the user's explicit confirmation of the currency, daily cap, total test cap, and stop date. Do not infer approval from a request to "run ads." 5. Never call a campaign live from an API response, draft state, or local configuration alone. Confirm its visible dashboard status, targeting, budget, billing readiness, and landing/listing preview. 6. Do not promise customers or return on ad spend before enough cohort time has passed. Distinguish clicks, installs, trials, activated merchants, and paid customers. ## Readiness Gate Collect these facts before recommending spend: - Public App Store listing URL, app status, category, supported geographies, and current price/trial terms. - Listing conversion evidence: title, tagline, screenshots, review count/rating, pricing clarity, and a merchant-facing promise that matches the target query. - A working install-to-value path: first-run activation event, billing/trial event, and paid-conversion event. Name the exact analytics source and event names; do not invent them. - Unit economics: monthly plan(s), gross margin, expected paid retention, acceptable payback window, and the maximum customer-acquisition cost (CAC). - Partner account access, ad billing readiness, and the person authorized to approve spend. If a required item is unknown, make it a launch blocker or use the smallest reasonable research-only next step. Do not compensate for weak listing conversion or broken onboarding with a larger bid. ## Research and Campaign Brief Build a brief that separates evidence from hypotheses. | Field | Record | | --- | --- | | Merchant segment | Store size, vertical, market, current workflow, and urgent job-to-be-done | | Search intent | The problem phrase merchants use, expected outcome, and disqualifying intent | | Evidence | Shopify listing/report data, review themes, and dated community observations with links | | Offer match | Listing headline, screenshots, trial/pricing, and activation path that answer that intent | | Economics | Target CAC, payback assumption, daily cap, total cap, and stop rule | | Measurement | Shopify report fields plus product events for install, activation, trial, and paid conversion | ### Keyword Research Start from the merchant's problem and workflow, not the app's internal feature names. Group terms into: - **High-intent problem terms:** merchants actively seeking a solution, for example `bundle app` or `back in stock alerts`. - **Workflow and integration terms:** `preorder manager`, `subscription migration`, or `klaviyo reviews` when the product actually supports the workflow. - **Competitor/alternative terms:** use only when the listing truthfully offers a credible alternative and current platform rules allow the approach. - **Exclusions:** irrelevant store types, free-template seekers, jobs/training, or adjacent problems the app cannot solve. Launch with a compact, traceable set. Separate exact/high-intent terms from discovery terms when the dashboard supports it; preserve the search-term evidence that justifies moving a discovery term into an exact group. Add negatives only for proven mismatches, not because a term has not converted immediately. ### Budget and Bid Logic Calculate the guardrail from economics first: ```text max CAC = gross profit expected inside the chosen payback window max CPC = max CAC × expected click-to-paid conversion rate ``` Use conservative initial bids and a fixed learning budget. A suitable first test is often a **proposal** such as a seven-day, search-led test with a small daily cap; it is never a default authorization. Choose the actual cap only after the user confirms the risk they accept. Do not spread a small test across many countries, placements, and keyword themes. Start with the segment where the listing, pricing, and onboarding are strongest. Treat homepage/category placements as separate tests only when the listing can win broad discovery and the budget can support them. ## Approval Checkpoint Before touching a paid control, present a concrete launch card and ask for a direct yes/no approval: ```text App: <public app name and listing URL> Objective: <qualified install / activated trial / paid customer> Targeting: <countries, placement, keyword groups> Budget: <currency><daily cap>/day, maximum <currency><total cap>, through <date> Bid rule: <initial bid or bid ceiling and adjustment rule> Measurement: <Shopify report + product events> Stop rule: <for example: stop at total cap or if activation quality is below threshold> Approve this exact Shopify App Store ads test? ``` If approval changes any material field, restate the final card. Record the user's approval in the task or campaign record before launch. ## Create and Verify the Campaign After approval, use the authenticated Partner Dashboard and complete only the approved configuration. 1. Reconfirm the correct public app and billing account. 2. Configure the approved objective, placement, markets, keywords/negatives, bid, start/end date, and caps. 3. Inspect the preview and confirm that it leads to the intended public listing. 4. Save the campaign and check its dashboard status. Record the campaign name/ID, creation time, final controls, and any review or hold state. 5. Capture the reporting baseline before traffic begins: listing visits, installs, trials, activated accounts, paid accounts, and any known attribution limitations. If the dashboard says pending, paused, in review, rejected, or otherwise not serving, report that state plainly. Do not claim traffic or customers until the relevant report shows it. ## Measure by Funnel and Cohort Report the funnel, denominators, date range, and source for every conclusion: | Metric | Formula | Use | | --- | --- | --- | | CTR | clicks / impressions | Query and listing relevance | | CPC | spend / clicks | Auction cost | | Install rate | installs / clicks | Listing conversion | | Activation rate | activated merchants / installs | Onboarding quality | | Trial rate | trials / installs | Offer and activation fit | | Paid conversion | paid customers / matured install cohort | Monetization fit | | CAC | spend / paid customers | Unit-economics decision | Use Shopify's ad reports as first-party acquisition evidence and product analytics/billing as the source of activation and revenue truth. Reconcile date ranges and attribution windows before combining them. Early free-trial traffic is not a paid-customer result. Review at three points: - **After initial delivery:** confirm serving state, targeting, spend pacing, and obvious search-term mismatch. - **After the fixed learning budget:** compare keyword groups on qualified installs and activation, not CTR alone. - **After the trial/payback window:** decide whether CAC and retained paid conversion support scaling. ## Optimization Decisions - Low CTR: tighten query-to-listing message match or pause irrelevant terms. - Good CTR but low installs: improve the listing's promise, screenshots, proof, or pricing clarity before increasing bids. - Good installs but low activation: fix onboarding and time-to-value; do not label the campaign successful. - Good activation but poor paid conversion: examine plan fit, trial design, support, and merchant segment before scaling. - Strong CAC on a mature cohort: scale one variable at a time—budget, geography, keyword expansion, or placement—and preserve a baseline for comparison. Do not manufacture thresholds or benchmarks. If the user supplies historical baselines, use them; otherwise frame a threshold as a proposed test criterion requiring confirmation. ## Required Handoff Return a concise, auditable result containing: 1. **Status:** research-only, ready for approval, live, pending/rejected, or stopped. 2. **Evidence:** primary Shopify sources and dated community/review hypotheses, clearly separated. 3. **Final campaign card:** targeting, bids, budget, dates, and stop rule. 4. **Verification:** visible dashboard status, campaign identifier, and listing destination. 5. **Funnel report:** source, date range, spend, impressions, clicks, installs, activation, trials, paid customers, and CAC where mature. 6. **Next decision:** keep, pause, fix listing/onboarding, or scale—with the exact evidence supporting it.
Referenced files: 1
shopify-cli23.6 KB
---
name: shopify-cli
description: "Use when scaffolding a new Shopify app, running Shopify CLI commands (shopify app dev/deploy/generate), configuring shopify.app.toml, generating app extensions (admin/checkout/theme/function), debugging tunnels or auth issues, or working with the official Remix/Node/PHP/Ruby app templates. Trigger on 'shopify app', 'shopify cli', 'shopify init', 'shopify dev', 'shopify deploy', 'generate extension', 'shopify.app.toml', 'remix template', 'tunnel', 'ngrok', 'cloudflare tunnel', 'ME APP_URL', 'SHOPIFY_API_KEY', or anything involving the Shopify CLI workflow."
---
# Shopify CLI Skill Reference
## When to Use This Skill
Use this skill for any task involving:
- Creating a new Shopify app with `shopify app init`
- Running development server with `shopify app dev`
- Deploying apps with `shopify app deploy` or `shopify app release`
- Generating extensions (admin actions, checkout UI, theme extensions, functions)
- Configuring `shopify.app.toml` and understanding all available settings
- Working with Remix + React Router v7 app template
- Setting up local tunnels (Cloudflare Tunnel or ngrok)
- Debugging authentication, webhooks, or environment issues
- Managing theme development with `shopify theme` commands
- Generating GraphQL types with `@shopify/api-codegen-preset`
- Understanding app structure, directory layout, and lifecycle
- Error troubleshooting and common failure patterns
## Installation and Setup
### Install Shopify CLI
```bash
npm install -g @shopify/cli@latest
```
**Node.js Requirements:**
- `>=20.19 <22` OR `>=22.12`
- Check version: `node --version`
### Verify Installation
```bash
shopify version
shopify app --help
```
## Command Reference
| Command | Flags | Purpose |
|---------|-------|---------|
| `shopify app init` | `--template remix` | Initialize new Shopify app with Remix template |
| `shopify app dev` | `--reset`, `--build`, `--no-update` | Start local dev server with hot reload |
| `shopify app deploy` | `--force`, `--no-release` | Deploy app to Shopify Partner dashboard |
| `shopify app release` | `--version X.Y.Z`, `--force` | Release deployed version to live |
| `shopify app generate` | `extension`, `webhook` | Generate boilerplate for extensions/webhooks |
| `shopify generate extension` | `--type admin_action`, `--api-version 2026-07` | Create app extension with a currently supported API version |
| `shopify app push` | `--force`, `--no-release` | Push config updates without full deploy |
| `shopify config show` | | Display loaded config from shopify.app.toml |
| `shopify env pull` | | Fetch environment variables from Partner dashboard |
| `shopify app open` | | Open app dashboard in browser |
| `shopify theme dev` | `--store example.myshopify.com` | Start theme development server |
| `shopify theme pull` | `--theme-id 123456789` | Download theme files from store |
| `shopify theme push` | `--force`, `--no-delete` | Upload theme files to store |
| `shopify auth logout` | | Clear stored authentication |
| `shopify auth login` | `--shop example.myshopify.com` | Authenticate with specific store |
## shopify app init Workflow (6-Step Process)
### Step 1: Launch Init Command
```bash
shopify app init --template remix
```
### Step 2: Provide Org and App Name
```
? App name
> my-shopify-app
? Org
> Select from: [list of partner orgs]
```
### Step 3: Select Package Manager
```
? Package manager
> npm / yarn / pnpm
```
### Step 4: Create Local Tunnel
```
? Local tunnel authentication
> Cloudflare / ngrok / skip
```
### Step 5: Install Dependencies
```bash
cd my-shopify-app
npm install
```
### Step 6: Start Dev Server
```bash
npm run dev
```
Output:
```
✓ Tunnel started at https://RANDOMHASH.lhr.life
✓ App URL: https://RANDOMHASH.lhr.life/api/auth
✓ Admin API access scopes configured
✓ Listening on port 3000
```
## Remix Template Deep Dive
### Directory Structure
```
my-shopify-app/
├── shopify.app.toml # App configuration (CRITICAL)
├── remix.config.js # Remix build config
├── package.json # Dependencies
├── .env.example # Environment template
├── prisma/
│ ├── schema.prisma # Database schema
│ └── migrations/ # Database migrations
├── app/
│ ├── shopify.server.ts # Shopify API setup (CRITICAL)
│ ├── db.server.ts # Database connection
│ ├── root.tsx # Root layout
│ ├── routes/
│ │ ├── _index.tsx # Dashboard
│ │ ├── api/
│ │ │ ├── auth/
│ │ │ │ ├── callback.ts # OAuth callback
│ │ │ │ └── login.ts # OAuth initiate
│ │ │ └── webhooks/
│ │ │ └── orders.ts # Webhook handler
│ │ ├── app/
│ │ │ └── dashboard/
│ │ │ └── _index.tsx # App dashboard
│ │ └── admin-actions/
│ │ └── bulk-operation.tsx
│ ├── components/
│ │ └── Navigation.tsx
│ └── styles/
│ └── app.css
└── public/
└── images/
```
### shopify.server.ts (Authentication Setup)
```typescript
import { shopifyApp } from "@shopify/shopify-app-remix/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import { restResources } from "@shopify/shopify-api/rest/admin/2026-07";
import { prisma } from "./db.server";
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY!,
apiSecret: process.env.SHOPIFY_API_SECRET!,
scopes: (process.env.SCOPES || "").split(","),
host: process.env.HOST!,
isEmbeddedApp: false,
sessionStorage: new PrismaSessionStorage(prisma),
distribution: {
saleChannel: "2152896513",
surface: "admin_home_surfaces",
},
restResources,
webhooks: {
APP_INSTALLED: {
deliveryMethod: "Http",
callbackUrl: "/api/webhooks/app-installed",
},
APP_UNINSTALLED: {
deliveryMethod: "Http",
callbackUrl: "/api/webhooks/app-uninstalled",
},
},
});
export default shopify;
```
### db.server.ts (Database Connection)
```typescript
import { PrismaClient } from "@prisma/client";
let prisma: PrismaClient;
declare global {
var __db: PrismaClient | undefined;
}
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
} else {
if (!global.__db) {
global.__db = new PrismaClient();
}
prisma = global.__db;
}
export { prisma };
```
### prisma/schema.prisma (Data Models)
```prisma
datasource db {
provider = "sqlite"
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 Int?
accessToken String
refreshToken String?
user Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Product {
id String @id
shopifyId String @unique
title String
handle String
status String
vendor String?
productType String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Order {
id String @id
shopifyId String @unique
email String
totalPrice String
createdAt DateTime @default(now())
}
```
### Environment Variables (.env)
```env
SHOPIFY_API_KEY=YOUR_API_KEY
SHOPIFY_API_SECRET=YOUR_API_SECRET
SHOPIFY_APP_ID=YOUR_APP_ID
SCOPES=write_products,read_orders,write_inventory
HOST=https://RANDOMHASH.lhr.life
DATABASE_URL=file:./dev.db
NODE_ENV=development
```
### package.json (Key Dependencies)
```json
{
"dependencies": {
"@shopify/shopify-app-remix": "^4.1.0",
"@shopify/polaris": "^12.0.0",
"remix": "^2.0.0",
"react-router": "^7.0.0",
"prisma": "^5.0.0",
"@prisma/client": "^5.0.0"
},
"devDependencies": {
"@shopify/api-codegen-preset": "^1.0.0",
"typescript": "^5.0.0"
},
"scripts": {
"dev": "remix dev --manual",
"build": "remix build",
"start": "remix-serve build",
"type-check": "tsc --noEmit",
"graphql-codegen": "graphql-codegen --config codegen.ts"
}
}
```
## shopify.app.toml Schema Reference
### Complete Example with All Sections
```toml
scopes = "write_products,read_orders,write_inventory,read_fulfillments"
title = "My Shopify App"
description = "App that manages products and inventory"
[build]
automatically_update_urls_on_dev = true
dev_store_url = "dev-store.myshopify.com"
[auth]
redirect_urls = [
"https://example.com/api/auth/callback"
]
[webhooks]
api_version = "2026-07"
[[webhooks.subscriptions]]
topics = ["products/create", "products/update"]
uri = "api/webhooks/products"
filter_query = "status:active"
include_fields = ["id", "title", "handle", "status"]
[[webhooks.subscriptions]]
topics = ["orders/create"]
uri = "api/webhooks/orders"
[pos]
embedded = false
[admin]
embedded = true
[[app_extensions]]
type = "admin_action"
handle = "bulk-edit-products"
label = "Bulk Edit Products"
[[app_extensions]]
type = "checkout_ui"
handle = "post-purchase-upsell"
configuration = "checkout.json"
[[app_extensions]]
type = "theme_app_extension"
handle = "theme-blocks"
[[app_extensions]]
type = "product_discount"
handle = "volume-discount"
[[app_extensions]]
type = "shipping_discount"
handle = "free-shipping"
[settings]
fields = [
{ key = "sync_enabled", type = "boolean", default = true },
{ key = "max_products", type = "number", default = 100 },
{ key = "webhook_delay", type = "number", default = 5 }
]
```
## Extension Types Reference
### admin_action
Extends Admin UI with custom buttons/actions in product, order, or customer pages.
```typescript
// extensions/admin-action/src/index.tsx
import { extend, Button, Section } from "@shopify/ui-extensions/admin";
export default extend("admin.product-details.action.render", (root, api) => {
root.appendChild(
root.createElement(Button, {
onPress: () => {
api.toast.show("Action triggered!");
},
}, "Custom Action")
);
});
```
### checkout_ui
Customize checkout flow. Limited to specific UI points.
```typescript
// extensions/checkout-ui/src/index.tsx
import { extend, TextField } from "@shopify/ui-extensions/checkout";
export default extend("purchase.checkout.contact-email.render-before", (root) => {
root.appendChild(
root.createElement(TextField, {
label: "Referral Code",
onChange: (value) => console.log(value),
})
);
});
```
### theme_app_extension
Add liquid blocks/sections to theme editor.
```json
// extensions/theme/blocks/custom-section.json
{
"name": "Custom Section",
"target": "section",
"settings": [
{
"type": "text",
"id": "title",
"label": "Section Title"
}
]
}
```
### product_discount / shipping_discount / payment_customization
Pricing functions—JavaScript executed server-side during checkout.
```javascript
// extensions/product-discount/src/index.js
export function run(input) {
return input.lines
.filter(line => line.quantity > 5)
.map(line => ({
cartLineId: line.id,
percentageDecrease: {
value: 10.0
}
}));
}
```
### post_purchase_ui
Show custom page after purchase confirmation.
```typescript
// extensions/post-purchase/src/index.tsx
import { extend, Heading, Button } from "@shopify/ui-extensions/post_purchase";
export default extend("purchase.post-purchase.block.render", (root, api) => {
root.appendChild(
root.createElement(Heading, {}, "Thank you for your purchase!")
);
});
```
## Local Development Workflow
### Start Dev Server
```bash
npm run dev
```
Expected output:
```
✓ Tunnel created at https://RANDOMHASH.lhr.life
✓ App URL: https://RANDOMHASH.lhr.life/api/auth
✓ Admin API credentials loaded
✓ Database: SQLite (dev.db)
✓ Webhooks: 3 subscriptions configured
✓ HMR active on port 3000
✓ Listening on all interfaces
```
### Hot Module Replacement (HMR)
HMR is enabled by default. Changes to:
- `.tsx` files in `/app/routes` → auto-reload
- `.ts` files in `/app` → server restart
- `shopify.app.toml` → restart required
Do NOT manually restart; HMR handles reloads.
### Environment Injection During Dev
The tunnel URL is automatically injected as:
- `HOST=https://RANDOMHASH.lhr.life`
- `SHOPIFY_APP_ID` from shopify.app.toml
- Scopes from shopify.app.toml
### Webhook Testing in Local Dev
Configure webhooks in Partner dashboard to point to tunnel URL:
```
https://RANDOMHASH.lhr.life/api/webhooks/products
```
Test webhook delivery:
```bash
curl -X POST https://RANDOMHASH.lhr.life/api/webhooks/products \
-H "Content-Type: application/json" \
-H "X-Shopify-Hmac-SHA256: SIGNATURE" \
-d '{
"id": "1234567890",
"title": "Test Product",
"handle": "test-product"
}'
```
### 5 Common Dev Failures and Fixes
**Failure 1: Tunnel Connection Lost**
```
Error: Tunnel disconnected
```
Fix: Restart dev server (Ctrl+C, then npm run dev)
**Failure 2: PORT 3000 Already in Use**
```
Error: EADDRINUSE :::3000
```
Fix: lsof -i :3000 | kill -9 <PID> or use PORT=3001 npm run dev
**Failure 3: Database Not Found**
```
PrismaClientInitializationError: Can't reach database server
```
Fix: Run migrations: npx prisma migrate dev
**Failure 4: Invalid Scopes in shopify.app.toml**
```
Error: Invalid scope 'read_prodcuts'
```
Fix: Check spelling in scopes = "..." line (e.g., read_products)
**Failure 5: Webhook Signature Mismatch**
```
Error: HMAC verification failed
```
Fix: Ensure webhook secret in handler matches SHOPIFY_API_SECRET in .env
## Deployment Workflow
### Pre-Deployment Checklist
- Update version in shopify.app.toml: version = "1.0.1"
- Run tests: npm test
- Build: npm run build
- Check for errors: npm run type-check
- Commit changes: git commit -am "Release v1.0.1"
### Deploy Command
```bash
shopify app deploy
```
Output:
```
✓ Validating shopify.app.toml
✓ Building production bundle
✓ Uploading to Partner dashboard
✓ Deployment ID: dpl_XXXXX
✓ View dashboard: https://partners.shopify.com/dashboard/apps
```
### Release (Make Live)
```bash
shopify app release --version 1.0.1
```
This makes the deployed version available to merchants. Without release, the app only exists in your Partner dashboard.
### Rollback to Previous Version
```bash
shopify app deploy --version 1.0.0
shopify app release --version 1.0.0
```
### Environment Management
Set environment variables in Partner dashboard:
1. Go to App Settings → Environment Variables
2. Add: WEBHOOK_QUEUE_URL, ANALYTICS_API_KEY, etc.
3. Redeploy to apply
Access in code:
```typescript
const queueUrl = process.env.WEBHOOK_QUEUE_URL;
```
## Theme CLI
### Theme Development Server
```bash
shopify theme dev --store example.myshopify.com
```
Uploads theme files to live store and watches for changes.
### Pull Theme from Store
```bash
shopify theme pull --theme-id 123456789
```
Downloads all theme files to /theme directory.
### Push Theme to Store
```bash
shopify theme push --force --no-delete
```
Uploads local theme files to store. --no-delete prevents deleting files in store.
### Theme File Structure
```
theme/
├── config/
│ └── settings_schema.json
├── sections/
│ ├── header.liquid
│ └── product.liquid
├── templates/
│ ├── index.json
│ └── product.json
├── snippets/
│ └── product-card.liquid
├── assets/
│ ├── styles.css
│ └── main.js
└── locales/
└── en.json
```
## GraphQL Codegen Setup
### Installation
```bash
npm install -D @shopify/api-codegen-preset graphql-codegen
```
### codegen.ts Configuration
```typescript
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "https://shopify.dev/admin-api-explorer/latest/graphql.json",
documents: ["app/**/*.{ts,tsx}"],
generates: {
"generated/graphql.ts": {
preset: "@shopify/api-codegen-preset",
presetConfig: {
apiVersion: "2026-07",
module: "graphql-request",
},
},
},
};
export default config;
```
### Usage Example
```typescript
// app/routes/products.tsx
import { graphql } from "../generated/graphql";
import { client } from "../shopify.server";
const GetProductsQuery = graphql(`
query GetProducts($first: Int!) {
products(first: $first) {
edges {
node {
id
title
handle
status
}
}
}
}
`);
export async function loader({ context }) {
const data = await client.query({
query: GetProductsQuery,
variables: { first: 10 },
});
return data.products.edges;
}
```
### Generate Types
```bash
npm run graphql-codegen
```
Generates fully-typed GraphQL operations in generated/graphql.ts.
## Common Errors Playbook
### Error 1: "Cannot find module '@shopify/shopify-app-remix'"
**Cause:** Missing package in node_modules
**Fix:**
```bash
npm install
npm install @shopify/shopify-app-remix@^4.1.0
npm run build
```
### Error 2: "Invalid SHOPIFY_API_KEY or SHOPIFY_API_SECRET"
**Cause:** Environment variables not set or incorrect
**Fix:**
1. Verify in .env: SHOPIFY_API_KEY=xxx and SHOPIFY_API_SECRET=yyy
2. Check Partner dashboard App Credentials tab
3. If using Codespace/CI: Add secrets to GitHub Secrets or deployment platform
### Error 3: "Prisma: Could not find the 'libquery_engine' runtime"
**Cause:** Prisma binaries not compiled for your platform
**Fix:**
```bash
node <plugin-root>/scripts/reset-shopify-cache.mjs --include prisma/.prisma
npm install
npx prisma generate
npx prisma migrate dev
```
### Error 4: "Tunnel URL expires in X minutes"
**Cause:** Cloudflare free tier tunnel expires after inactivity
**Fix:**
```bash
shopify app dev --reset
# or switch to ngrok:
shopify app dev --tunnel-provider ngrok
```
### Error 5: "No session found for shop example.myshopify.com"
**Cause:** User not authenticated or session expired
**Fix:**
```typescript
// Ensure middleware is loaded:
import { sessionMiddleware } from "@shopify/shopify-app-remix/server";
export const loader = async ({ context }) => {
const { session } = context;
if (!session) {
return redirect("/api/auth/login");
}
};
```
### Error 6: "Webhook subscription already exists"
**Cause:** Duplicate webhook registration
**Fix:**
```bash
shopify app auth logout
rm dev.db
npm run dev
# Recreates from scratch
```
### Error 7: "Extension type 'admin_action' not supported in API version 2024-10"
**Cause:** Admin actions require API version 2025-01+
**Fix:** Update shopify.app.toml:
```toml
api_version = "2026-07"
```
### Error 8: "CORS error: Origin not allowed"
**Cause:** Admin API CORS policy blocking requests
**Fix:**
1. Ensure requests come from authenticated app context (not localhost)
2. Use shopify.sessionStorage for session retrieval
3. Use shopify.rest.api(session) to create authenticated client
### Error 9: "Cannot read property 'shop' of undefined"
**Cause:** Session object not populated
**Fix:**
```typescript
const session = await shopify.sessionStorage.loadSession(sessionId);
if (!session) throw new Error("Session not found");
const { shop } = session;
```
### Error 10: "Database migration pending"
**Cause:** Schema changes not applied
**Fix:**
```bash
npx prisma migrate dev --name "describe migration"
npm run build
npm run dev
```
## Decision Tree (Command Selection)
User wants to create a new app? Use: shopify app init --template remix
User wants to start local dev? Use: npm run dev (includes tunnel auto-setup)
User wants to generate extension? Options:
- Admin action: shopify generate extension --type admin_action
- Checkout UI: shopify generate extension --type checkout_ui
- Theme extension: shopify generate extension --type theme_app_extension
- Function: shopify generate extension --type shipping_discount
User wants to deploy app? Options:
- First time: shopify app deploy (creates version)
- Update existing: shopify app deploy --force
User wants to make version live? Use: shopify app release --version X.Y.Z
User wants to work with themes? Options:
- Download: shopify theme pull --theme-id 123456789
- Upload: shopify theme push
- Dev server: shopify theme dev --store example.myshopify.com
User wants to test webhooks? Already running in dev server, use curl or Webhook Tester
User wants to generate GraphQL types? Use: npm run graphql-codegen
User wants to troubleshoot? Options:
- Tunnel broken: shopify app dev --reset
- DB broken: rm dev.db && npm run dev
- Auth broken: shopify auth logout && npm run dev
- Port in use: PORT=3001 npm run dev
## Recipes & Cookbook
### Recipe 1: Scaffold New Shopify App from Scratch
Goal: Create a working Shopify app in 5 minutes
```bash
# 1. Init with Remix template
shopify app init --template remix
cd my-app
# 2. Install deps
npm install
# 3. Create .env
cp .env.example .env
# Edit .env: add SHOPIFY_API_KEY and SHOPIFY_API_SECRET from Partner dashboard
# 4. Setup database
npx prisma migrate dev --name "init"
# 5. Start dev server
npm run dev
# 6. Open in browser
# Visit tunnel URL shown in terminal
```
Key Files Created:
- shopify.app.toml (app config)
- app/shopify.server.ts (Shopify setup)
- app/db.server.ts (DB connection)
- prisma/schema.prisma (data models)
- Tunnel automatically created and running
### Recipe 2: Add Admin Action Extension
Goal: Add a "Bulk Edit" button to product details page
```bash
# 1. Generate extension
shopify generate extension --type admin_action
# 2. When prompted:
# Extension handle: bulk-edit-products
# Surface: admin.product-details.action.render
# 3. Generated file: extensions/admin-action/src/index.tsx
# Edit to include proper API calls
# 4. Add to shopify.app.toml:
[[app_extensions]]
type = "admin_action"
handle = "bulk-edit-products"
label = "Bulk Edit Products"
# 5. Deploy
shopify app deploy
```
### Recipe 3: Add Checkout UI Extension
Goal: Add upsell prompt after purchase
```bash
# 1. Generate extension
shopify generate extension --type checkout_ui
# 2. When prompted:
# Extension handle: post-purchase-upsell
# API version: 2026-07 (verify latest stable before use)
# 3. Edit extensions/checkout-ui/src/index.tsx with custom logic
# 4. Add to shopify.app.toml:
[[app_extensions]]
type = "checkout_ui"
handle = "post-purchase-upsell"
# 5. Deploy
shopify app deploy
```
### Recipe 4: Add Discount Function
Goal: Apply 10% discount to orders over $100
```bash
# 1. Generate function
shopify generate extension --type product_discount
# 2. Edit extensions/product-discount/src/index.js with business logic
# 3. Add to shopify.app.toml:
[[app_extensions]]
type = "product_discount"
handle = "min-order-discount"
# 4. Create function config file:
# extensions/product-discount/shopify.function.toml:
type = "product_discount"
api_version = "2026-07"
# 5. Deploy
shopify app deploy
```
### Recipe 5: Set Up Webhook Handler for Orders
Goal: Sync orders to external system when created
```bash
# 1. Add webhook subscription to shopify.server.ts with proper callbacks
# 2. Update shopify.app.toml scopes:
scopes = "read_orders,write_inventory"
# 3. Create webhook handler: app/routes/api/webhooks/orders-create.ts
# with proper signature validation and external sync logic
# 4. Test with curl or Webhook Tester in Partner dashboard
# 5. Deploy
shopify app deploy
```
### Recipe 6: Migrate Config from PHP to Remix
Goal: Move from legacy PHP app to new Remix app
```bash
# 1. Export old PHP app config
# In old app, run: php export-config.php > old-config.json
# 2. Create new Remix app
shopify app init --template remix
# 3. Map config to shopify.app.toml structure
# 4. Map environment variables from old to new format
# 5. Copy and refactor webhook handlers from PHP to TypeScript
# 6. Test thoroughly
npm run dev
# 7. Deploy both side-by-side during transition period
```
---
Version note: examples were refreshed for Admin API 2026-07. Verify current Shopify CLI and package versions before installation.
shopify-functions27.4 KB
---
name: shopify-functions
description: "Build WebAssembly functions for the Shopify checkout and order pipeline. Rust or JavaScript, 5ms execution window, 256KB binary limit. Targets cart transform, discount, validation, payment customization, delivery customization, order routing, fulfillment constraints, and localization. Triggers include: 'Shopify Function', 'WASM', 'Rust function', 'JavaScript function', 'function-runner', 'cart.transform.run', 'discount.run', 'cart.checkout-validation.run', 'cart.delivery-customization.run', 'cart.payment-customization.run', 'cart.lines.discounts.generate.run', 'order.routing.location.rank.run', 'fulfillment-constraints.run', 'localization.generate.run', 'metafield function', 'shopify function'."
---
# Shopify Functions
Shopify Functions are WebAssembly (WASM) units of business logic that extend the Shopify checkout, order, and fulfillment pipelines. They execute in a sandboxed runtime on Shopify's servers and have strict constraints: 5ms execution window, 256KB binary limit, no async I/O, no outbound HTTP to unknown hosts, and 30-point GraphQL query complexity ceiling.
## Architecture & Execution Model
**Function Lifecycle:**
1. Merchant installs your app; metafield definitions are registered
2. App reads merchant configuration from metafields (Shop, Product, Collection scopes)
3. On checkout/order event, Shopify invokes your function with Input JSON payload
4. Function executes WASM bytecode, applies business logic, returns JSON output
5. Output mutations are applied atomically to the checkout/order state
**Execution Constraints (Hard Limits):**
- Max execution time: 5ms (timeout failure = no operation)
- Max binary size: 256KB gzipped
- Max instructions: 11 million
- Max memory: 256KB heap
- Max output JSON: 256KB
- Max GraphQL query complexity: 30 points (estimate 1pt per simple field)
- No async I/O, no event loops, no multi-threading
- No network access except allowed_hosts (NEW 2025-01)
- Deterministic execution only
**Why WASM?** Shopify Functions run in Wasmtime, a fast WebAssembly runtime. This provides:
- Language flexibility: Compile Rust, JavaScript (via AssemblyScript), or Go to WASM
- Isolation: No access to host filesystem, process, or network (except whitelisted hosts)
- Performance: Near-native execution speed; optimized just-in-time compilation
- Security: Sandboxed; input/output validation by Shopify platform
## Function Targets & Checkout Pipeline
The Shopify checkout and order pipeline has **8 invocation points** (targets). Each target receives a specific input schema and must return a specific output schema:
| Target | Phase | Purpose | Input | Output | Latency Budget |
|--------|-------|---------|-------|--------|-----------------|
| `cart.transform.run` | 1. Cart | Transform line items (bundle, rename, change quantity) | Cart items, metafields | Modified items | 5ms |
| `cart.checkout-validation.run` | 2. Validation | Validate cart before payment (inventory, rules) | Cart state, attributes | Errors/blocks (optional) | 5ms |
| `cart.delivery-customization.run` | 3. Delivery | Customize rates, hide options, rank (shipping, pickup) | Delivery options, cart | Customized rates/ranking | 5ms |
| `cart.payment-customization.run` | 4. Payment | Hide payment methods, customize amounts | Payment methods, total | Customized methods/amounts | 5ms |
| `discount.run` | 5. Discount | Apply discounts (% off, $ off, free shipping, gift) | Cart items, rules | Discount targets + value | 5ms |
| `fulfillment-constraints.run` | 6. Fulfillment | Constrain what locations can fulfill each line | Cart items, locations | Location fulfillment rules | 5ms |
| `order.routing.location.rank.run` | 7. Order Routing | Rank locations for fulfillment (priority, cost) | Locations, order lines | Ranked location order | 5ms |
| `localization.generate.run` | 8. Localization | Generate translated/localized checkout labels | Buyer locale, shop context | Localized strings | 5ms |
| `cart.lines.discounts.generate.run` | 5b. Line Discounts | NEW 2025+: Per-line discounts with allocation strategy | Line items, rules | Per-line discounts with allocation | 5ms |
**Pipeline Execution:**
1. Cart Transform → Validation → Delivery/Payment Customization → Discount → Fulfillment → Order Routing → Localization
2. If any function returns error/blocks, pipeline halts and transaction fails
3. All function outputs are applied transactionally; no partial states
## Rust Project Structure (Recommended)
**Shopify CLI v4.0.0+ generates this scaffold:**
```bash
shopify app function create --template rust --name my-function
# Creates:
# my-function/
# ├── Cargo.toml
# ├── src/
# │ ├── main.rs (entry point)
# │ └── input.graphql (input query)
# └── shopify.extension.toml (manifest)
```
**Cargo.toml (Rust 1.75+):**
```toml
[package]
name = "my-discount-function"
version = "0.1.0"
edition = "2021"
[dependencies]
shopify_function = { version = "1.0", features = ["wasm"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
[lib]
crate-type = ["cdylib"]
```
**src/main.rs (Discount Function Example):**
```rust
use shopify_function::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct Input {
pub cart: Cart,
pub metafield: Option<ConfigMetafield>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Cart {
pub lines: Vec<CartLine>,
pub cost: CartCost,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CartLine {
pub id: String,
pub quantity: i32,
pub cost: Cost,
pub merchandise: Merchandise,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Merchandise {
pub id: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Cost {
pub amount: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CartCost {
pub subtotal_amount: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ConfigMetafield {
pub value: String,
}
#[derive(Debug, Serialize)]
pub struct Output {
pub discounts: Vec<Discount>,
pub all_lines: bool,
}
#[derive(Debug, Serialize)]
pub struct Discount {
pub targets: Vec<Target>,
pub value: Value,
pub message: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct Target {
pub line_item_group: LineItemGroup,
}
#[derive(Debug, Serialize)]
pub struct LineItemGroup {
pub id: String,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum Value {
#[serde(rename_all = "camelCase")]
PercentageValue { percentage: String },
#[serde(rename_all = "camelCase")]
FixedAmountValue { fixed_amount: String },
}
#[shopify_function]
fn discount(input: Input) -> FunctionResult<Output> {
let subtotal = input
.cart
.cost
.subtotal_amount
.parse::<f64>()
.unwrap_or(0.0);
if subtotal >= 100.0 {
return Ok(Output {
discounts: vec![Discount {
targets: input
.cart
.lines
.iter()
.map(|line| Target {
line_item_group: LineItemGroup {
id: line.id.clone(),
},
})
.collect(),
value: Value::PercentageValue {
percentage: "10.0".to_string(),
},
message: Some("10% off orders over $100".to_string()),
}],
all_lines: true,
});
}
Ok(Output {
discounts: vec![],
all_lines: false,
})
}
```
**src/input.graphql (Input Query for Discount Function):**
```graphql
query Input {
cart {
lines {
id
quantity
cost {
amount
}
merchandise {
id
}
}
cost {
subtotal_amount
}
}
metafield(namespace: "my-app", key: "discount-config") {
value
}
}
```
**shopify.extension.toml (Function Manifest):**
```toml
name = "My Discount Function"
description = "Applies percentage discount on orders over $100"
type = "function"
api_version = "2025-01"
[[targets]]
target = "discount.run"
[[metafields]]
namespace = "my-app"
key = "discount-config"
description = "JSON config: {\"thresholdAmount\": 100, \"discountPercentage\": 10}"
owner_type = "SHOP"
[[metafields]]
namespace = "my-app"
key = "enabled"
description = "Enable/disable discount"
owner_type = "SHOP"
[network]
allowed_hosts = ["api.external-service.com"]
```
**Build & Test:**
```bash
cd my-discount-function
shopify app function build
# Output: dist/index.wasm (gzipped, typically 50-100KB)
shopify app function run --input input.json
# Run locally with test payload
```
## JavaScript/TypeScript Project Structure
**Scaffold (Remix app with TypeScript):**
```bash
shopify app function create --template javascript --name my-function
```
**package.json:**
```json
{
"name": "my-cart-transform",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "shopify app function build",
"test": "shopify app function run --input test/input.json",
"dev": "shopify app function run --watch"
},
"dependencies": {
"@shopify/function-runner": "^1.0.0",
"@shopify/type-generator": "^1.0.0"
},
"devDependencies": {
"typescript": "^5.2.0",
"@types/node": "^20.0.0"
}
}
```
**src/run.ts (Cart Transform Example: Bundle Related Items):**
```typescript
import { FunctionResult, TargetProduct } from "@shopify/function-runner";
interface Input {
cart: {
lines: Array<{
id: string;
quantity: number;
cost: { amount: string };
merchandise: { id: string; product?: { id: string; title: string } };
}>;
};
metafield?: { value: string };
}
interface Output {
lines: Array<{
id: string;
quantity?: number;
merchandiseId?: string;
}>;
operations: Array<{
add?: {
merchandiseId: string;
quantity: number;
};
remove?: {
lineId: string;
};
}>;
}
export default function run(input: Input): FunctionResult<Output> {
const bundleConfig = input.metafield
? JSON.parse(input.metafield.value)
: { bundleName: "Starter Pack", items: [] };
const lines = input.cart.lines;
const operations: Output["operations"] = [];
// Example: If cart has item A and item B, add item C at discount
const hasItemA = lines.some((l) => l.merchandise.id === "gid://product/A");
const hasItemB = lines.some((l) => l.merchandise.id === "gid://product/B");
if (hasItemA && hasItemB) {
operations.push({
add: {
merchandiseId: "gid://product/C",
quantity: 1,
},
});
}
return {
lines: lines.map((l) => ({ id: l.id })),
operations,
};
}
```
**src/input.graphql:**
```graphql
query Input {
cart {
lines {
id
quantity
cost {
amount
}
merchandise {
id
product {
id
title
}
}
}
}
metafield(namespace: "my-app", key: "bundle-config") {
value
}
}
```
## Metafield Configuration Pattern
**Define Metafield in shopify.extension.toml:**
```toml
[[metafields]]
namespace = "my-app"
key = "discount-rules"
description = "JSON: {\"thresholdAmount\": 100, \"percentage\": 10, \"enabled\": true}"
owner_type = "SHOP"
[[metafields]]
namespace = "my-app"
key = "product-discount-rules"
description = "Product-specific discount config"
owner_type = "PRODUCT"
[[metafields]]
namespace = "my-app"
key = "collection-rules"
description = "Collection-specific rules"
owner_type = "COLLECTION"
```
**Query Metafield in input.graphql:**
```graphql
query Input {
shop {
id
}
metafield(namespace: "my-app", key: "discount-rules") {
value
}
cart {
lines {
id
merchandise {
id
product {
id
metafield(namespace: "my-app", key: "product-discount-rules") {
value
}
collections(first: 5) {
nodes {
id
metafield(namespace: "my-app", key: "collection-rules") {
value
}
}
}
}
}
}
}
}
```
**Parse & Use in Rust/JS Code:**
```rust
#[derive(Deserialize)]
struct DiscountConfig {
threshold_amount: f64,
percentage: f64,
enabled: bool,
}
let config: DiscountConfig = serde_json::from_str(
input.metafield.as_ref().map(|m| m.value.as_str()).unwrap_or("{}")
)?;
if !config.enabled {
return Ok(Output { discounts: vec![] });
}
if subtotal >= config.threshold_amount {
// Apply discount...
}
```
## Network Access (NEW 2025-01)
**Limited Outbound HTTP is Now Available:**
Functions can make HTTP requests to whitelisted hosts. This enables:
- Real-time inventory checks from external systems
- Currency conversion APIs
- Machine learning model inference
- Third-party rule engines
**Declare Allowed Hosts in shopify.extension.toml:**
```toml
[network]
allowed_hosts = [
"api.inventory-service.com",
"ml-models.example.com",
"currency-api.service.io"
]
```
**Rust HTTP Example (using `reqwest` compiled to WASM):**
```rust
use shopify_function::prelude::*;
#[shopify_function]
fn validate(input: Input) -> FunctionResult<Output> {
// Make HTTP call (sync only, no async/await in WASM)
let inventory_url = format!(
"https://api.inventory-service.com/stock/{}",
input.cart.lines[0].merchandise.id
);
// Note: Real WASM HTTP is still limited; most functions use metafield-driven rules
// True HTTP in functions is still experimental; verify with Shopify CLI
Ok(Output { /* ... */ })
}
```
**Timeout & Fallback:**
- HTTP requests timeout at 500ms (must complete within function's 5ms window if combined with other logic)
- If HTTP fails, return safe default (e.g., allow delivery option, skip discount)
- Never block checkout on external HTTP failure
## Testing Functions
**Test Input File (input.json):**
```json
{
"cart": {
"lines": [
{
"id": "gid://shopify/CartLine/1",
"quantity": 2,
"cost": {
"amount": "150.00"
},
"merchandise": {
"id": "gid://shopify/ProductVariant/123"
}
}
],
"cost": {
"subtotal_amount": "150.00"
}
},
"metafield": {
"value": "{\"thresholdAmount\": 100, \"percentage\": 10}"
}
}
```
**Run Function Locally:**
```bash
shopify app function run --input input.json
# Output:
# ✓ Function executed successfully
# {
# "discounts": [
# {
# "targets": [{ "lineItemGroup": { "id": "gid://shopify/CartLine/1" } }],
# "value": { "percentage": "10.0" },
# "message": "10% off orders over $100"
# }
# ],
# "all_lines": true
# }
```
**Replay Recorded Invocations:**
```bash
shopify app function run --replay
# Re-run against real checkout data captured from production
```
**Explain Query Complexity:**
```bash
shopify app function explain-query
# Analyzes input.graphql and reports complexity score (max 30)
```
**Golden Tests Pattern (Recommended):**
Create test cases in `tests/` directory:
```rust
// tests/discount_test.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_discount_applied_over_100() {
let input = Input {
cart: Cart {
lines: vec![CartLine {
id: "line1".to_string(),
quantity: 1,
cost: Cost {
amount: "150.00".to_string(),
},
merchandise: Merchandise {
id: "variant1".to_string(),
},
}],
cost: CartCost {
subtotal_amount: "150.00".to_string(),
},
},
metafield: None,
};
let result = discount(input).unwrap();
assert_eq!(result.discounts.len(), 1);
assert_eq!(result.discounts[0].value, Value::PercentageValue { percentage: "10.0".to_string() });
}
#[test]
fn test_no_discount_under_100() {
let input = Input {
cart: Cart {
lines: vec![],
cost: CartCost {
subtotal_amount: "50.00".to_string(),
},
},
metafield: None,
};
let result = discount(input).unwrap();
assert_eq!(result.discounts.len(), 0);
}
}
```
Run tests:
```bash
cargo test
```
## Full Working Examples
**Example 1: Rust Discount Function (10% off orders > $100)**
File structure:
```
rust-discount/
├── Cargo.toml
├── shopify.extension.toml
├── src/
│ ├── main.rs
│ └── input.graphql
└── tests/
└── discount_test.rs
```
`Cargo.toml`:
```toml
[package]
name = "rust-discount"
version = "0.1.0"
edition = "2021"
[dependencies]
shopify_function = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[profile.release]
opt-level = "z"
lto = true
strip = true
```
`src/main.rs`:
```rust
use shopify_function::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct Input {
pub cart: Cart,
}
#[derive(Debug, Deserialize)]
pub struct Cart {
pub lines: Vec<CartLine>,
pub cost: CartCost,
}
#[derive(Debug, Deserialize)]
pub struct CartLine {
pub id: String,
}
#[derive(Debug, Deserialize)]
pub struct CartCost {
pub subtotal_amount: String,
}
#[derive(Debug, Serialize)]
pub struct Output {
pub discounts: Vec<Discount>,
pub all_lines: bool,
}
#[derive(Debug, Serialize)]
pub struct Discount {
pub targets: Vec<Target>,
pub value: DiscountValue,
pub message: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct Target {
pub line_item_group: LineItemGroup,
}
#[derive(Debug, Serialize)]
pub struct LineItemGroup {
pub id: String,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum DiscountValue {
Percentage { percentage: String },
}
#[shopify_function]
fn discount(input: Input) -> FunctionResult<Output> {
let subtotal = input.cart.cost.subtotal_amount.parse::<f64>().unwrap_or(0.0);
if subtotal >= 100.0 {
return Ok(Output {
discounts: vec![Discount {
targets: input.cart.lines.iter().map(|line| Target {
line_item_group: LineItemGroup { id: line.id.clone() },
}).collect(),
value: DiscountValue::Percentage { percentage: "10.0".to_string() },
message: Some("10% off orders over $100".to_string()),
}],
all_lines: true,
});
}
Ok(Output { discounts: vec![], all_lines: false })
}
```
**Example 2: JavaScript Cart Transform (Auto-Add Bundle Item)**
`src/run.ts`:
```typescript
export default function run(input) {
const cart = input.cart;
const operations = [];
// If cart has specific product, auto-add complementary item
const hasMainProduct = cart.lines.some(
(line) => line.merchandise.id === "gid://shopify/ProductVariant/main123"
);
if (hasMainProduct && cart.lines.length === 1) {
operations.push({
add: {
merchandiseId: "gid://shopify/ProductVariant/bundle456",
quantity: 1,
},
});
}
return {
lines: cart.lines.map((line) => ({ id: line.id })),
operations,
};
}
```
**Example 3: Rust Validation Function (Check Inventory)**
`src/main.rs`:
```rust
use shopify_function::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct Input {
pub cart: Cart,
}
#[derive(Debug, Deserialize)]
pub struct Cart {
pub lines: Vec<CartLine>,
}
#[derive(Debug, Deserialize)]
pub struct CartLine {
pub quantity: i32,
pub merchandise: Merchandise,
}
#[derive(Debug, Deserialize)]
pub struct Merchandise {
pub id: String,
}
#[derive(Debug, Serialize)]
pub struct Output {
pub errors: Vec<ValidationError>,
}
#[derive(Debug, Serialize)]
pub struct ValidationError {
pub message: String,
pub target: String,
}
#[shopify_function]
fn validate(input: Input) -> FunctionResult<Output> {
let mut errors = vec![];
for line in &input.cart.lines {
// Mock inventory check
if line.quantity > 10 {
errors.push(ValidationError {
message: "Quantity exceeds available inventory".to_string(),
target: line.merchandise.id.clone(),
});
}
}
Ok(Output { errors })
}
```
## Deployment & Verification
**Build & Deploy:**
```bash
# 1. Build WASM binary
shopify app function build
# 2. Verify binary size
ls -lh dist/index.wasm
# Should be <256KB (gzipped)
# 3. Test against sample input
shopify app function run --input test-input.json
# 4. Deploy with app
shopify app deploy
# Shopify CLI creates function version + deploys extension
# 5. Enable function in Shopify Admin
# Apps > Your App > Functions > [Function Name] > Enable
```
**Monitor Function Health:**
- Shopify Admin: Apps > Your App > Functions > [Name] > Metrics
- Track: execution count, error rate, latency percentiles
- Set alerts: >5% error rate, >90th percentile latency >3ms
**Version Management:**
- Functions are versioned by Shopify CLI deployment timestamp
- Active version runs on all new checkouts
- Rollback: Admin > Functions > [Name] > Versions > Select Previous
- No breaking changes: Always ship backward-compatible input/output
## Decision Tree: Which Function Target?
```
What do you need to do?
├─ Transform cart items (bundle, rename, remove)?
│ └─ target: cart.transform.run
│ Input: cart.lines (id, quantity, merchandise)
│ Output: modified lines
├─ Validate cart before checkout (inventory, rules)?
│ └─ target: cart.checkout-validation.run
│ Input: cart state
│ Output: validation errors (blocks checkout if present)
├─ Customize delivery options (hide, rate override)?
│ └─ target: cart.delivery-customization.run
│ Input: deliveryOptions[]
│ Output: customized/ranked options
├─ Hide payment methods or customize amounts?
│ └─ target: cart.payment-customization.run
│ Input: paymentMethods[]
│ Output: customized methods
├─ Apply discounts (% off, $ off, free shipping)?
│ └─ target: discount.run
│ Input: cart state, metafield config
│ Output: discount[] with targets & value
├─ Apply per-line discounts (2025+)?
│ └─ target: cart.lines.discounts.generate.run
│ Input: line items
│ Output: per-line discount with allocation
├─ Constrain which locations can fulfill items?
│ └─ target: fulfillment-constraints.run
│ Input: locations[], cart.lines
│ Output: fulfillment rules
├─ Rank locations for order routing (cost, priority)?
│ └─ target: order.routing.location.rank.run
│ Input: locations[], order
│ Output: ranked location[] order
└─ Generate localized checkout labels?
└─ target: localization.generate.run
Input: buyer locale, shop context
Output: localized strings
```
## Troubleshooting & Common Issues
| Issue | Root Cause | Fix |
|-------|-----------|-----|
| **WASM binary exceeds 256KB** | Heavy dependencies, unoptimized build | Set `opt-level = "z"`, `lto = true`, `strip = true` in Cargo.toml; remove unused deps; use `wasm-opt` post-processor |
| **Function timeout (5ms exceeded)** | Complex GraphQL query (30+ points) or heavy loop logic | Simplify input query; pre-aggregate in metafield; reduce loop iterations; profile with `shopify app function run --explain-query` |
| **Syntax error in input.graphql** | Invalid field names or nesting | Verify schema against latest API version; use `shopify app function explain-query` to validate |
| **Metafield returns null/empty** | Metafield not set on Shop/Product/Collection | Check Admin > Settings > Custom data; confirm namespace/key match shopify.extension.toml; set test value |
| **Discount doesn't apply in checkout** | Function returns OK but no discount output | Verify function is enabled in Admin > Apps > Your App > Functions; check discount logic (threshold check, target IDs match) |
| **Cart transform operation fails** | Invalid merchandiseId or operation structure | Verify merchandiseId format (gid://shopify/ProductVariant/XXX); check input query includes variant IDs; test with `shopify app function run` first |
| **Error: "Output exceeds 256KB"** | Returning too much data | Reduce output fields; compress message strings; avoid returning full cart state |
| **Validation function blocks all checkouts** | Always returning errors in Output | Add condition to only return errors when validation fails; default to empty errors[] |
| **Network request from function fails silently** | HTTP request to non-whitelisted host | Add host to `[network] allowed_hosts` in shopify.extension.toml; verify DNS resolution |
| **Graphql query complexity > 30 points** | Too many fields or nested selections | Remove unnecessary fields from input.graphql; use aliases to reduce redundant queries; check Admin API complexity docs |
| **Function runs but output ignored** | Wrong output format or missing required field** | Verify output JSON schema matches target spec (e.g., Discount must have targets[], value); test against sample input |
| **Type mismatch in Rust/TS** | Serde/TypeScript serialization error | Ensure struct field names match GraphQL response (snake_case vs camelCase); add #[serde(rename)] if needed |
## Performance Optimization
**Binary Size Reduction:**
```toml
[profile.release]
opt-level = "z" # Maximum size optimization
lto = true # Link-time optimization
codegen-units = 1 # Single codegen unit for better optimization
strip = true # Strip debug symbols
panic = "abort" # Use abort instead of unwind
```
**Query Optimization:**
- Request only fields needed for logic (each field ≈ 1 complexity point)
- Move repeated queries to metafield (query once, store in metadata)
- Use `first: 1` or `first: 5` limits instead of full collections
- Combine related fields into single query rather than separate queries
**Code Optimization (Rust):**
- Use `&str` instead of `String` where possible
- Pre-allocate Vec capacity if size is known
- Avoid cloning; use references
- Profile with `wasm-opt` post-processor:
```bash
cargo install wasm-opt
wasm-opt -Oz dist/index.wasm -o dist/index.wasm
```
**Output Optimization:**
- Serialize only required fields
- Use compact JSON (no whitespace)
- Limit discount message length
- Pre-compute values before serialization
## API Version & Changelog
**Version note:** The examples below document capabilities introduced in `2025-01`. Use the latest stable version supported by the specific Function target when creating a new extension.
**2025-01 New Features:**
- Network access via `allowed_hosts` declaration
- Per-line discounts via `cart.lines.discounts.generate.run`
- Improved error messages in function runtime
- Function async/await still NOT supported; purely synchronous
**2024-10 (Previous):**
- Original 8 function targets stable
- Metafield support
- GraphQL query complexity ceiling (30 points)
**Upgrading:**
```toml
# In shopify.extension.toml
api_version = "2026-07" # Confirm the latest version supported by this Function target
```
Functions written for 2024-10 continue to work in 2025-01; no breaking changes.
## Resources
- **Shopify Functions Docs:** https://shopify.dev/docs/apps/functions
- **GraphQL Admin API:** https://shopify.dev/docs/api/admin-graphql/latest
- **CLI Reference:** `shopify app function --help`
- **WASM in Rust:** https://www.rust-lang.org/what/wasm/
- **Shopify Community:** https://community.shopify.com/c/shopify-apis-sdks/ct-p/apis-sdks
shopify-mcp22.6 KB
---
name: shopify-mcp
description: "Use this skill for shopify mcp. Triggers include: 'shopify mcp', 'shopify dev mcp', 'storefront mcp', 'merchant-facing mcp', 'well-known mcp', 'shopify mcp configuration', 'custom mcp shopify', 'agentic commerce', 'shopify agent', 'claude code shopify integration', 'mcp.json', 'shopify mcp setup'."
---
# Shopify MCP Integration Guide
Model Context Protocol (MCP) servers connect Claude to Shopify data and operations. There are three deployment patterns: Shopify Dev MCP (developer-centric), Storefront MCP (merchant-centric), and custom MCPs (specialized workflows). This guide covers setup, configuration, implementation patterns, and agentic commerce use cases.
## Shopify Dev MCP: Developer Tools for Claude Code
Shopify Dev MCP is a read-only developer toolkit integrated into Claude Code. It provides access to admin APIs, schema introspection, development store management, and app testing utilities without building a custom MCP.
### Installation and Setup
The Shopify Dev MCP is installed via command-line setup:
```bash
npx -y @shopify/dev-mcp setup
```
This command:
1. Prompts for Shopify organization/store selection
2. Creates a development app or reuses existing one
3. Stores authentication tokens securely in system keychain
4. Registers the MCP server in `~/.claude.json/mcpServers`
5. Restarts Claude Code to load the new MCP
No additional configuration is required post-setup. The MCP automatically handles token refresh and scope validation.
### Claude Code Configuration
After setup, `~/.claude.json/mcpServers` contains:
```json
{
"mcpServers": {
"shopify": {
"command": "npx",
"args": ["-y", "@shopify/dev-mcp", "run"],
"env": {
"SHOPIFY_AUTH_TOKEN": "shpat_...",
"SHOPIFY_STORE": "dev-store-name.myshopify.com",
"SHOPIFY_ORG_ID": "gid://shopify/Organization/12345"
}
}
}
}
```
Manual configuration is unnecessary unless you need environment-specific tokens or custom server paths. For advanced setups (air-gapped networks, custom HTTP proxies), edit the env dict directly.
### Dev MCP Capabilities
The Shopify Dev MCP provides these read-only tools:
**Admin API Query Tool**
- Execute GraphQL queries against the API version configured for the connected development store
- Full schema support (Products, Orders, Customers, Fulfillments, Inventory, etc.)
- Automatic scope validation (app must have required scopes)
- Response size limit: 5MB per query
- Query complexity limit: 100 points (Admin API default)
```graphql
query GetProductMetafields($id: ID!) {
product(id: $id) {
id
title
metafields(first: 10) {
edges {
node {
namespace
key
value
}
}
}
}
}
```
**REST API Endpoint Tool**
- Use legacy Admin REST endpoints only when the connected tool and existing app explicitly support them
- Bearer token authentication handled automatically
- Useful for bulk operations, webhooks, flow triggers
- Rate limit: standard REST bucket holds 40 requests and restores 2 requests/second; read response headers and retry safely
**Store Introspection Tool**
- List installed apps, extensions, functions, webhooks
- Retrieve store metadata (plan, currency, timezone, brand settings)
- Useful for understanding store configuration before building
- No rate limiting
**App Testing Tool**
- Trigger test webhooks (app/installed, app/uninstalled, orders/create)
- List development app credentials
- Rotate API credentials securely
- Simulate store events without live transactions
**Schema Browser Tool**
- Explore GraphQL schema interactively
- Get field descriptions, arguments, return types
- Filter by type (Query, Mutation, Subscription, Object, Enum)
- Search for fields by name or description
### When to Recommend Dev MCP Installation
Recommend Dev MCP when:
- User is building a Shopify app in Claude Code
- User needs to test queries against their dev store without leaving Claude
- User is debugging function behavior or app logic
- User is prototyping Admin API integrations
- User needs quick schema lookups during development
Do NOT recommend if:
- User needs custom logic beyond Admin API (use custom MCP)
- User is building a public app without a Shopify org (no auth available)
- User needs to connect multiple stores (use Storefront MCP + app routes)
### Dev MCP Workflow Example
```
User: "Add a debug webhook that logs all order updates to my app"
Claude uses Dev MCP to:
1. Query store's webhook endpoints (Admin API GET /webhooks.json)
2. Check existing webhooks for duplicates
3. Create new webhook via Admin API POST /webhooks.json
4. Confirm creation and return webhook ID
Claude: "I've registered webhook ID gid://shopify/Webhook/123456
to POST order/update events to your app. Test it by placing an order."
```
## Storefront MCP: Merchant-Facing Agent Tools
Storefront MCP is a custom MCP deployed at `/.well-known/mcp.json` on your storefront. It enables AI agents (Claude, OpenAI Operator, Perplexity Shopping) to browse products, manage carts, apply discounts, and complete purchases on behalf of customers.
### Architecture
Storefront MCP is a lightweight HTTP server serving MCP protocol at `/.well-known/mcp.json`. When an AI agent visits your storefront, it discovers the MCP server via well-known endpoint and establishes communication for tool access.
```
Customer Browser / AI Agent
↓
Storefront (Remix/Next)
↓
/.well-known/mcp.json
↓
MCP Server (Node.js)
↓
Storefront API / Backend DB
```
### Required Storefront API Scopes
Your MCP server must have a Storefront API access token with these scopes:
```
customer-account-api:customer
storefront-api:read_products
storefront-api:read_product_variants
storefront-api:read_collections
storefront-api:read_carts
storefront-api:write_carts
storefront-api:read_customers
```
Scopes are configured in `shopify.app.toml`:
```toml
scopes = "customer-account-api:customer,storefront-api:read_products,storefront-api:read_product_variants,storefront-api:read_collections,storefront-api:read_carts,storefront-api:write_carts,storefront-api:read_customers"
```
### Core Tools
**search_catalog**
- Text search across products/collections
- Returns 10 highest-relevance results
- Includes images, pricing, availability
- Filters by collection/vendor/price range optional
```json
{
"name": "search_catalog",
"description": "Search storefront products by keyword",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "number", "default": 10},
"filter": {"type": "string", "enum": ["in_stock", "sale", "new"]}
}
}
}
```
**get_product**
- Fetch full product details by product ID
- Includes variants, metafields, recommendations, ratings
- Returns available inventory counts per variant
- Shows subscription/prepaid options if available
**lookup_product**
- Find product by SKU, barcode, or vendor ID
- Useful when AI agent has partial product info
- Returns product ID for use with get_product
**get_cart_state**
- Retrieve current customer cart
- Shows line items, subtotal, taxes, shipping estimates
- Includes applied discounts, gift cards, notes
- Returns cart ID for mutations
**add_to_cart**
- Add product variant to cart with quantity
- Creates cart if none exists
- Returns updated cart state
- Validates variant availability before adding
**apply_discount**
- Apply discount code to active cart
- Returns updated totals after discount
- Shows discount description and terms
- Validates code and customer eligibility
**create_checkout**
- Initiate checkout flow for current cart
- Returns checkout URL (redirects to payment)
- Captures customer email if known
- Applies language/currency preferences
### Storefront MCP Implementation (Remix)
Create `/routes/.well-known/mcp.json.ts`:
```typescript
import { json, type LoaderFunction } from "@remix-run/node";
import { storefront } from "~/lib/shopify.server";
export const loader: LoaderFunction = async ({ request }) => {
if (request.method !== "GET") {
return new Response("Method not allowed", { status: 405 });
}
return json({
protocolVersion: "2024-11-05",
name: "my-storefront-mcp",
version: "1.0.0",
capabilities: {
tools: {
listChanged: true,
},
},
tools: [
{
name: "search_catalog",
description: "Search products by keyword",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
limit: { type: "number", default: 10 },
filter: {
type: "string",
enum: ["in_stock", "sale", "new"],
},
},
required: ["query"],
},
},
{
name: "get_product",
description: "Get product details by ID",
inputSchema: {
type: "object",
properties: {
productId: {
type: "string",
description: "Shopify product ID (gid://...)",
},
},
required: ["productId"],
},
},
{
name: "get_cart_state",
description: "Retrieve current cart",
inputSchema: { type: "object", properties: {} },
},
{
name: "add_to_cart",
description: "Add variant to cart",
inputSchema: {
type: "object",
properties: {
variantId: { type: "string" },
quantity: { type: "number", default: 1 },
},
required: ["variantId"],
},
},
{
name: "apply_discount",
description: "Apply discount code",
inputSchema: {
type: "object",
properties: {
code: { type: "string" },
cartId: { type: "string" },
},
required: ["code"],
},
},
],
});
};
```
Create `/routes/api/mcp/tool-call.ts` to handle tool invocations:
```typescript
import { json, type ActionFunction } from "@remix-run/node";
import { storefront } from "~/lib/shopify.server";
export const action: ActionFunction = async ({ request }) => {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const { tool, input, meta } = await request.json();
const cartId = meta?.cartId;
switch (tool) {
case "search_catalog": {
const query = `query SearchProducts($query: String!) {
search(first: ${input.limit || 10}, query: $query) {
edges {
node {
... on Product {
id
title
handle
featuredImage { url }
priceRange {
minVariantPrice { amount currency }
}
}
}
}
}
}`;
const result = await storefront.query(query, {
variables: { query: input.query },
});
return json({
products: result.search.edges.map((e: any) => ({
id: e.node.id,
title: e.node.title,
handle: e.node.handle,
image: e.node.featuredImage?.url,
price: e.node.priceRange.minVariantPrice.amount,
currency: e.node.priceRange.minVariantPrice.currency,
})),
});
}
case "add_to_cart": {
const cartAddQuery = `mutation AddToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart { id lines(first: 10) { edges { node { id quantity variant { id } } } } }
userErrors { message field }
}
}`;
const cartResult = await storefront.mutate(cartAddQuery, {
variables: {
cartId,
lines: [{ variantId: input.variantId, quantity: input.quantity }],
},
});
if (cartResult.cartLinesAdd.userErrors.length > 0) {
return json(
{ error: cartResult.cartLinesAdd.userErrors[0].message },
{ status: 400 }
);
}
return json({ cart: cartResult.cartLinesAdd.cart });
}
case "apply_discount": {
const discountQuery = `mutation ApplyDiscount($cartId: ID!, $discountCode: String!) {
cartDiscountCodesUpdate(cartId: $cartId, discountCodes: [$discountCode]) {
cart { id cost { totalAmount { amount } } }
userErrors { message }
}
}`;
const result = await storefront.mutate(discountQuery, {
variables: { cartId, discountCode: input.code },
});
if (result.cartDiscountCodesUpdate.userErrors.length > 0) {
return json(
{ error: result.cartDiscountCodesUpdate.userErrors[0].message },
{ status: 400 }
);
}
return json({ cart: result.cartDiscountCodesUpdate.cart });
}
default:
return json({ error: "Unknown tool" }, { status: 400 });
}
};
```
## Custom MCP for Shopify Apps
Build a custom MCP when you need specialized agent tools beyond standard Admin API or Storefront API access. Common use cases: workflow automation, data aggregation, custom business logic, integration with third-party systems.
### Custom MCP Structure
```
shopify-app-mcp/
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts # MCP server main entry
│ ├── tools/
│ │ ├── inventory.ts # Inventory management tools
│ │ ├── reporting.ts # Custom reporting tools
│ │ └── automation.ts # Workflow automation tools
│ └── lib/
│ ├── shopify.ts # Admin API client
│ └── db.ts # Database queries
└── stdio.mjs # Node.js stdio transport
```
### Example: Custom Inventory MCP
```typescript
// src/tools/inventory.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
Tool,
TextContent,
} from "@modelcontextprotocol/sdk/types.js";
export const inventoryTools: Tool[] = [
{
name: "adjust_inventory",
description: "Adjust inventory levels for a variant",
inputSchema: {
type: "object",
properties: {
variantId: { type: "string" },
quantityAdjustment: { type: "number" },
reason: {
type: "string",
enum: [
"damaged",
"lost",
"count_correction",
"restock",
"donation",
],
},
},
required: ["variantId", "quantityAdjustment", "reason"],
},
},
{
name: "get_low_stock_variants",
description: "Find variants below minimum threshold",
inputSchema: {
type: "object",
properties: {
threshold: { type: "number", default: 5 },
warehouseId: { type: "string" },
},
},
},
];
export async function handleInventoryTool(
toolName: string,
input: Record<string, any>
): Promise<TextContent> {
const adminClient = createAdminClient(); // use app's admin token
if (toolName === "adjust_inventory") {
const query = `
mutation AdjustInventory($variantId: ID!, $quantity: Int!, $reason: String!) {
inventoryAdjustQuantities(
input: {
changes: [
{
inventoryItemId: "gid://shopify/InventoryItem/${input.variantId}"
availableDelta: ${input.quantityAdjustment}
}
]
reason: "${input.reason.toUpperCase()}"
}
) {
inventoryAdjustmentGroup {
reason
changes { inventoryItem { sku } }
}
userErrors { message }
}
}
`;
const result = await adminClient.mutate(query);
return {
type: "text",
text: `Adjusted inventory: ${JSON.stringify(result, null, 2)}`,
};
}
if (toolName === "get_low_stock_variants") {
const query = `
query LowStockVariants($threshold: Int!) {
productVariants(first: 100, query: "inventory_quantity:<${input.threshold}") {
edges {
node {
id
title
inventoryQuantity
}
}
}
}
`;
const result = await adminClient.query(query, {
variables: { threshold: input.threshold },
});
return {
type: "text",
text: `Low stock variants: ${JSON.stringify(result, null, 2)}`,
};
}
throw new Error(`Unknown tool: ${toolName}`);
}
```
### Registering Custom MCP in Claude Code
Add to `~/.claude.json/mcpServers`:
```json
{
"mcpServers": {
"shopify-inventory": {
"command": "node",
"args": ["path/to/shopify-app-mcp/stdio.mjs"],
"env": {
"SHOPIFY_ACCESS_TOKEN": "shpat_...",
"SHOPIFY_SHOP": "mystore.myshopify.com",
"DATABASE_URL": "postgres://..."
}
}
}
}
```
## Agentic Commerce: AI-Powered Shopping
Agentic commerce uses AI agents (Claude, OpenAI Operator, Perplexity Shopping) to browse storefronts, understand products, manage carts, and complete purchases autonomously. The Storefront MCP enables this workflow.
### Shop AI (Shopify Native Agent)
Shopify's Shop AI is a managed agent available to merchants via Shop app. It enables:
- Natural language search ("show me sustainable leather jackets")
- Product comparison ("compare these two options")
- Customer service ("where's my order?", "return this item")
- Purchase assistance ("add bundle to cart", "apply code SAVE20")
Shop AI uses Storefront API directly (no custom MCP required). Optimize product descriptions and metafields for AI comprehension.
### OpenAI Operator (Agentic Browsing)
OpenAI Operator is an agentic browser that can interact with websites like a human. When Operator visits your storefront:
1. Operator discovers MCP at `/.well-known/mcp.json`
2. Operator loads available tools (search_catalog, add_to_cart, etc.)
3. Operator executes user requests autonomously
4. Requests like "find a gift under $50 and add it" work natively
To optimize for Operator:
- Ensure product metadata is complete (descriptions, tags, ratings)
- Include clear pricing and availability indicators
- Support discount codes discoverable in footer/header
- Test Storefront MCP endpoints for latency < 500ms
- Provide fallback HTML for legacy browsers
### Perplexity Shopping (AI Shopping Assistant)
Perplexity's shopping agent crawls your storefront and catalogs products for shopper recommendations. It:
- Synthesizes product comparisons across results
- Recommends bundles and alternatives
- Applies coupon codes automatically
- Offers price match guarantees (via integrations)
To optimize for Perplexity:
- Use structured data (JSON-LD) for products
- Publish sitemap.xml with all product URLs
- Include original/discounted pricing clearly
- Add customer review counts and ratings
- Avoid JavaScript-only product loading
### Building for Agentic Commerce
Product metadata shapes agent behavior. Ensure:
```json
{
"product": {
"id": "gid://shopify/Product/123456",
"title": "Organic Cotton T-Shirt",
"description": "100% certified organic cotton, GOTS certified. Features: breathable, hypoallergenic, sustainable. Care: machine wash cold, line dry.",
"tags": ["organic", "sustainable", "cotton", "unisex"],
"category": "Clothing > Tops > T-Shirts",
"rating": 4.7,
"reviewCount": 234,
"variants": [
{
"id": "gid://shopify/ProductVariant/789",
"title": "Black / XS",
"price": "32.00",
"compareAtPrice": "45.00",
"available": true,
"sku": "OCTT-BLACK-XS"
}
],
"collections": ["Summer Collection", "Bestsellers"],
"seo": {
"title": "Organic Cotton T-Shirt | Sustainable Fashion",
"description": "Breathable, hypoallergenic organic cotton tees. GOTS certified, ethically made."
}
}
}
```
## Troubleshooting MCP Issues
| Issue | Symptom | Root Cause | Fix |
|-------|---------|------------|-----|
| MCP not discovered | "/.well-known/mcp.json 404" in Claude | Storefront route not created | Create `routes/.well-known/mcp.json.ts` and restart server |
| Authentication failed | "Invalid access token" in tool errors | Outdated token, scope mismatch | Regenerate token, verify scopes in shopify.app.toml |
| Tool call timeout | Tools don't respond after 10s | Slow Storefront API, N+1 queries | Batch queries, add caching, optimize GraphQL |
| CORS blocked | "Cross-Origin Request Blocked" | MCP endpoint enforcing CORS | Add CORS headers: Access-Control-Allow-Origin: * |
| Cart not persisting | Cart ID changes between calls | Stateless cart creation | Store cartId in session/localStorage, reuse in mutations |
| Discount code fails | "Code not valid for this customer" | Code restricted to segments | Test code eligibility, check customer tags match |
| Search returns empty | "Zero results for common query" | Products not indexed, missing tags | Ensure products published, rebuild search index |
| Agent loops indefinitely | Tool calls repeat without progress | Missing error handling in tool | Add explicit error messages, max iteration count |
| Storefront API rate limited | "Rate limit exceeded" after 10 calls | Too many parallel requests | Implement request queue, batch mutations |
| Schema not updating | New fields unavailable in queries | Admin API cache, schema change pending | Restart MCP server, verify API version matches |
## Best Practices
**MCP Server Reliability**
- Implement request queuing to avoid rate limits
- Add exponential backoff for transient failures
- Cache schema queries (schema rarely changes)
- Monitor tool latency; alert if > 1s
- Log all tool calls for debugging agentic behavior
**Security for Agentic Access**
- Storefront MCP should NOT have write access to orders
- Limit tool scope to read + cart mutations only
- Validate cart ownership before mutations (check customer ID)
- Require explicit customer consent for purchase-triggering tools
- Rate limit tool calls per IP (50 calls/minute per user agent)
**Agent Optimization**
- Provide clear tool descriptions (agents use these for routing)
- Return structured, machine-readable responses
- Include confidence scores for search results
- Offer tool combinations (e.g., "search + get_product" for details)
- Test agent flow: search → filter → add → discount → checkout
## Quick Reference: When to Use Which MCP
| Scenario | Recommended MCP | Reason |
|----------|-----------------|--------|
| Developer building Shopify app | Shopify Dev MCP | Read-only, zero config, full schema |
| Enabling AI shopping on storefront | Storefront MCP | Merchant-facing, tool-based, standard setup |
| Custom reporting dashboard | Custom MCP | Specialized queries, app-specific logic |
| Admin agent for store ops | Hybrid (Dev + Custom) | Dev MCP for queries, Custom for mutations |
| AI-powered product recs | Storefront MCP | Catalog search, product details, cart state |
| Workflow automation (reordering) | Custom MCP | Business logic beyond standard APIs |
storefront-api15.4 KB
---
name: storefront-api
description: "Build customer-facing storefront applications with Shopify Storefront API. Access product catalogs, collections, checkout flows, cart management, and customer accounts using public/private tokens. Includes GraphQL queries, Market directives, Customer Account API, and TypeScript examples. Triggers include: 'storefront api', 'customer-facing shopify', 'shopping cart api', 'product catalog query', 'checkout flow', 'customer account api', 'market directive', 'storefront token'."
---
## When to Use Storefront API
Use **Storefront API** for customer-facing applications:
- Building custom storefronts (headless commerce)
- Shopping cart and checkout flows
- Product browsing and search
- Customer account management (orders, addresses)
- Subscription management
- Market-specific pricing and inventory (with Market directives)
- Cart line operations (add, remove, update)
- Customer authentication and profiles
**DO NOT use for:** store management, admin operations, or internal tools (use Admin API instead).
---
## Token Types & Scopes
### Public Access Tokens
**Use for:** Frontend applications, public data access
```javascript
const publicToken = 'Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; // Public token
const shopDomain = 'mystore.myshopify.com';
const endpoint = `https://${shopDomain}/api/2026-01/graphql.json`;
const headers = {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': publicToken,
};
```
**Scopes enabled with public token:**
- Read products
- Read product collections
- Read shop information
- Read customer information (requires customer login)
- Create shopping carts
- Manage shopping carts
- Access checkout URLs
### Private Access Tokens (Storefront)
**Use for:** Backend/server-side access with elevated permissions
```javascript
const privateToken = process.env.SHOPIFY_STOREFRONT_PRIVATE_TOKEN;
const headers = {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': privateToken,
};
```
**Additional scopes with private token:**
- Full customer account access
- All storefront operations
- No rate limiting (unlike public token: 2 requests/second per IP)
---
## Headers & Configuration
**Standard Storefront Headers:**
```javascript
const headers = {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': accessToken,
};
```
**TypeScript Headers Interface:**
```typescript
interface StorefrontHeaders {
'Content-Type': 'application/json';
'X-Shopify-Storefront-Access-Token': string;
'Accept-Language'?: string; // For locale-specific data
}
```
**Add Market/Localization (Market Directive):**
```javascript
const headers = {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': accessToken,
'Accept-Language': 'en-US', // for Market resolution
};
```
---
## Product Queries
### Get Product by Handle
```graphql
query GetProduct($handle: String!) {
product(handle: $handle) {
id
title
description
handle
vendor
productType
images(first: 10) {
edges {
node {
url
altText
}
}
}
priceRange {
minVariantPrice {
amount
currencyCode
}
maxVariantPrice {
amount
currencyCode
}
}
variants(first: 100) {
edges {
node {
id
title
sku
price {
amount
currencyCode
}
availableForSale
quantityAvailable
compareAtPrice {
amount
currencyCode
}
selectedOptions {
name
value
}
}
}
}
collections(first: 5) {
edges {
node {
title
handle
}
}
}
}
}
```
**Variables:**
```json
{
"handle": "wireless-headphones"
}
```
### List Products (Paginated)
```graphql
query ListProducts($first: Int!, $after: String, $query: String) {
products(first: $first, after: $after, query: $query) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
title
handle
priceRange {
minVariantPrice {
amount
currencyCode
}
}
images(first: 1) {
edges {
node {
url
}
}
}
}
}
}
}
```
### Search Products
```graphql
query SearchProducts($query: String!, $first: Int) {
products(first: $first, query: $query) {
edges {
node {
id
title
handle
description
}
}
}
}
```
---
## Collection Queries
### Get Collection Products
```graphql
query GetCollection($handle: String!, $first: Int) {
collection(handle: $handle) {
id
title
description
image {
url
altText
}
products(first: $first) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
title
handle
priceRange {
minVariantPrice {
amount
currencyCode
}
}
}
}
}
}
}
```
### List Collections
```graphql
query ListCollections($first: Int, $after: String) {
collections(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
title
handle
image {
url
}
}
}
}
}
```
---
## Cart & Checkout Workflow
### Create Cart
```graphql
mutation CreateCart($input: CartInput!) {
cartCreate(input: $input) {
cart {
id
checkoutUrl
lines(first: 10) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
price {
amount
currencyCode
}
}
}
}
}
}
cost {
subtotalAmount {
amount
currencyCode
}
totalAmount {
amount
currencyCode
}
}
}
userErrors {
field
message
}
}
}
```
### Add to Cart
```graphql
mutation AddToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
id
lines(first: 10) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
price {
amount
currencyCode
}
}
}
}
}
}
}
userErrors {
field
message
}
}
}
```
**Variables:**
```json
{
"cartId": "gid://shopify/Cart/abc123",
"lines": [
{
"merchandiseId": "gid://shopify/ProductVariant/123456",
"quantity": 2
}
]
}
```
### Update Cart Line
```graphql
mutation UpdateCartLine($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
id
}
userErrors {
field
message
}
}
}
```
### Remove from Cart
```graphql
mutation RemoveFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
id
}
userErrors {
field
message
}
}
}
```
### Get Checkout URL
```graphql
query GetCart($cartId: ID!) {
cart(id: $cartId) {
checkoutUrl
}
}
```
---
## Customer Account API
### Get Current Customer
```graphql
query GetCurrentCustomer {
customer {
id
email
firstName
lastName
phone
createdAt
updatedAt
addresses(first: 10) {
edges {
node {
id
firstName
lastName
address1
address2
city
province
country
zip
isDefaultBillingAddress
isDefaultShippingAddress
}
}
}
orders(first: 10) {
edges {
node {
id
orderNumber
processedAt
totalPrice {
amount
currencyCode
}
financialStatus
fulfillmentStatus
lineItems(first: 10) {
edges {
node {
title
quantity
price {
amount
currencyCode
}
}
}
}
}
}
}
}
}
```
### Update Customer
```graphql
mutation UpdateCustomer($customer: CustomerInput!) {
customerUpdate(customer: $customer) {
customer {
id
email
firstName
lastName
}
userErrors {
field
message
}
}
}
```
### Create Address
```graphql
mutation CreateAddress($address: MailingAddressInput!) {
customerAddressCreate(address: $address) {
customerAddress {
id
address1
city
country
province
zip
}
userErrors {
field
message
}
}
}
```
---
## Market Directives (Multi-Region)
Markets enable locale-specific product data, pricing, and availability.
### Query with Market Context
```graphql
query GetProductByMarket($handle: String!, $country: CountryCode!, $language: LanguageCode) @inContext(country: $country, language: $language) {
product(handle: $handle) {
id
title
priceRange {
minVariantPrice {
amount
currencyCode
}
}
variants(first: 10) {
edges {
node {
id
availableForSale
quantityAvailable
}
}
}
}
}
```
**Variables (for Canadian French market):**
```json
{
"handle": "wireless-headphones",
"country": "CA",
"language": "FR"
}
```
### Supported Markets
```typescript
enum CountryCode {
US = "US",
CA = "CA",
GB = "GB",
AU = "AU",
JP = "JP",
DE = "DE",
FR = "FR",
IT = "IT",
// ... and 150+ more
}
enum LanguageCode {
EN = "EN",
FR = "FR",
DE = "DE",
IT = "IT",
JA = "JA",
ES = "ES",
// ... and 20+ more
}
```
---
## TypeScript Client Example
```typescript
interface StorefrontConfig {
shop: string;
token: string;
apiVersion: string;
}
interface Product {
id: string;
title: string;
handle: string;
description: string;
priceRange: {
minVariantPrice: MoneyV2;
maxVariantPrice: MoneyV2;
};
variants: ProductVariant[];
}
interface ProductVariant {
id: string;
title: string;
sku: string;
price: MoneyV2;
availableForSale: boolean;
quantityAvailable: number;
}
interface MoneyV2 {
amount: string;
currencyCode: string;
}
class StorefrontClient {
private endpoint: string;
private headers: Record<string, string>;
constructor(config: StorefrontConfig) {
this.endpoint = `https://${config.shop}/api/${config.apiVersion}/graphql.json`;
this.headers = {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': config.token,
};
}
async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: this.headers,
body: JSON.stringify({ query, variables }),
});
const data = await response.json();
if (data.errors) {
throw new Error(`GraphQL error: ${data.errors[0].message}`);
}
return data.data;
}
async getProduct(handle: string): Promise<Product> {
const query = `
query GetProduct($handle: String!) {
product(handle: $handle) {
id
title
handle
description
priceRange {
minVariantPrice {
amount
currencyCode
}
maxVariantPrice {
amount
currencyCode
}
}
variants(first: 100) {
edges {
node {
id
title
sku
price {
amount
currencyCode
}
availableForSale
}
}
}
}
}
`;
const result = await this.query(query, { handle });
return result.product;
}
async createCart(variantId: string, quantity: number = 1) {
const mutation = `
mutation CreateCart($input: CartInput!) {
cartCreate(input: $input) {
cart {
id
checkoutUrl
lines(first: 10) {
edges {
node {
id
quantity
}
}
}
}
}
}
`;
const input = {
lines: [
{
merchandiseId: variantId,
quantity,
},
],
};
return this.query(mutation, { input });
}
}
// Usage
const client = new StorefrontClient({
shop: 'mystore.myshopify.com',
token: 'public_token_here',
apiVersion: '2026-01',
});
const product = await client.getProduct('wireless-headphones');
console.log(`Product: ${product.title} - $${product.priceRange.minVariantPrice.amount}`);
const cart = await client.createCart('gid://shopify/ProductVariant/123456', 2);
console.log(`Cart created: ${cart.cart.checkoutUrl}`);
```
---
## Rate Limiting
**Public token rate limits:**
- 2 requests per second per IP address
- 4 requests per second per user token (if customer logged in)
**Private token rate limits:**
- No rate limiting (server-side access)
**Handling rate limits:**
```javascript
async function executeWithRetry(query, variables, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
});
if (response.status === 429) {
const waitTime = Math.pow(2, attempt - 1) * 1000;
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
return response;
}
}
```
---
## Common Errors & Solutions
| Error | Cause | Solution |
|-------|-------|----------|
| **Unauthorized** | Invalid or expired token | Verify token in X-Shopify-Storefront-Access-Token header |
| **Field not available** | Token doesn't have scope | Use private token for full access, or request scope |
| **Product not found** | Wrong product handle | Verify handle exists; check product published to sales channel |
| **Cart checkoutUrl null** | Cart hasn't been created properly | Ensure cartCreate mutation completed successfully |
| **Customer is null** | User not logged in | Authenticate customer first (requires customer token) |
| **Variant not available** | Out of stock | Check availableForSale and quantityAvailable fields |
---
## Reference URLs
- [Shopify Storefront API Docs](https://shopify.dev/api/storefront/2026-01)
- [Storefront GraphQL Queries](https://shopify.dev/api/storefront/2026-01/queries)
- [Storefront GraphQL Mutations](https://shopify.dev/api/storefront/2026-01/mutations)
- [Customer Account API](https://shopify.dev/api/customer/2026-01)
- [Market Directives Guide](https://shopify.dev/api/storefront/2026-01/guide-markets)
- [Cart Operations Guide](https://shopify.dev/api/storefront/2026-01/guide-cart)
- [Authentication Flows](https://shopify.dev/api/storefront/2026-01/guide-authentication)
- [Rate Limiting Documentation](https://shopify.dev/api/storefront/2026-01#rate-limits)
top-app-ux-patterns24.7 KB
---
name: top-app-ux-patterns
description: "Use when designing or reviewing the UX of a Shopify app and you want to mirror what the top-grossing apps do (Klaviyo, Gorgias, Judge.me, Loox, Vitals, PageFly). Covers IA, first-30s flow, empty states, setup data collection, the aha moment, pricing presentation, listing page conventions, and the 20 reusable UX patterns common across top apps. Triggers: 'what do top shopify apps do', 'best in class shopify ui', 'klaviyo ux', 'gorgias ux', 'modern shopify app pattern', 'shopify app ia', 'app store screenshot pattern', 'polaris convention'. MUST trigger when designing first-run, dashboard, settings, or pricing screens."
---
# Top App UX Patterns — What the Best-in-Class Shopify Apps Actually Do
A teardown-driven playbook for designing or reviewing Shopify app UX. Built from a live analysis of six top-grossing apps (Klaviyo, Gorgias, Judge.me, Loox, Vitals, PageFly) representing an estimated $70M–$110M of combined Shopify-channel MRR. Use this skill to lift the converged patterns instead of inventing UX from scratch.
---
## 1. When to use
Trigger this skill whenever you are:
- Designing or reviewing a Shopify App Store **listing page** (hero, gallery, description, pricing cards, "Works with")
- Designing the **first-run / install / OAuth → first value** flow
- Designing **empty states** for any in-app screen (dashboard, settings, builder, etc.)
- Designing the **pricing page** or choosing a pricing structure (flat / tiered / slot-based / usage-based)
- Picking the **single addictive metric** for the merchant's dashboard
- Reviewing UX for **install→activate→pay** conversion leaks
- Comparing a competitor's UX against the converged best-practice set
- Auditing copy on the listing or in-app for "outcome vs feature" framing
- Choosing how to handle theme integration (theme code edits vs App Blocks / OS 2.0)
- Deciding what onboarding data to collect at install vs. defer
Do not use this skill for: backend architecture, billing implementation details (use `shopify-app-builder:app-billing`), or scope minimization (use `shopify-app-builder:audit-scopes`). This skill is purely about **what merchants see and feel.**
---
## 2. The six case studies
Each case study compresses the teardown into the patterns worth stealing. Numbers are estimates from the source teardown dated 2026-05-15.
### 2.1 Klaviyo — Email Marketing & SMS
- **Rating / scale:** 4.6 (2,731 reviews). Est. $35M–$50M MRR from Shopify channel.
- **BFS badge:** No (legacy).
- **Pricing entry:** Free to install. Email from $20/mo (251–500 contacts). SMS from $15/mo.
- **Primary job:** Turn anonymous traffic + one-time buyers into a list to re-monetize across email + SMS + WhatsApp.
**Key takeaways:**
1. Lead the tagline with **outcome + AI**, not feature — "AI marketing… to grow faster" beats "Send emails."
2. **Permanent free tier** (250 contacts forever) is the install hook. Reduces friction to near-zero.
3. **Defer credential friction** — DNS records / sender domain asked only at first send, not install.
4. **Bury complexity behind "See all pricing options"** — three clean cards on the listing, full matrix one click away.
5. The aha moment is the **Abandoned Cart attributed revenue** widget within 72 hours of install.
### 2.2 Gorgias — AI Helpdesk & Chat
- **Rating / scale:** 4.3 (635 reviews — lowest 5-star share in this set). Est. $14M–$20M MRR.
- **BFS badge:** No.
- **Pricing entry:** $10/mo Starter (3 agents, 50 tickets) → $900/mo Advanced. Per-ticket overage.
- **Primary job:** Replace a tangle of inboxes (email + chat + social + voice + SMS) and auto-resolve the repetitive 60% with AI.
**Key takeaways:**
1. **"View demo store"** secondary CTA — uniquely powerful for complex / abstract apps. Lets prospects feel it before installing.
2. **Minimal gallery (2 images)** + bet on the demo + description.
3. **Tiered overage that gets cheaper per unit at higher tiers** is a clean upgrade-pressure mechanic.
4. The aha is the **first AI auto-resolved ticket** with a green "saved ~3 min of agent time" banner.
5. The addictive dashboard metric is **deflection rate.**
### 2.3 Judge.me — Product Reviews
- **Rating / scale:** 5.0 (39,203 reviews — largest absolute review count in this set). Est. $4M–$6M MRR.
- **BFS badge:** Yes — also 2025 Build Award winner.
- **Pricing entry:** **Forever Free** (unlimited reviews, widgets, rich snippets). $15/mo "Awesome" unlocks AI + integrations.
- **Primary job:** Get reviews on the store yesterday without Yotpo prices, with star snippets in Google.
**Key takeaways:**
1. **Stack trust badges visibly** — BFS + Build Award + 5.0 + 39k reviews within the first 200 pixels.
2. **Flat $15 pricing** is a structural moat against per-contact / per-impression competitors.
3. **Win the migration market** — one-click import from CSV, AliExpress, Loox, Yotpo, Stamped, Amazon, Etsy.
4. **Auto-place widgets via OS 2.0 theme blocks** — zero theme editing.
5. Auto-send the first review request 7 days after first fulfilled order. Aha = first photo review live on a product page.
### 2.4 Loox — Visual Product Reviews
- **Rating / scale:** 4.9 (7,854 reviews). Est. $8M–$12M MRR.
- **BFS badge:** Yes.
- **Pricing entry:** Free Beginner (100 emails/mo) → Convert $49.99 → Unlimited $299.99. Order-volume gated.
- **Primary job:** Photo + video reviews for visual product categories (apparel, beauty, decor).
**Key takeaways:**
1. **Longest gallery in this set (8+ slides)** — for visual products, more is more.
2. **Psychology lede** opens the description — "Visual reviews are the strongest form of social proof."
3. **Pick-a-widget empty state** — first action is a low-stakes visual choice with live preview.
4. **Order-volume pricing** aligns billing with merchant success.
5. **Outcome-named tiers** — Beginner / Convert / Unlimited. The tier name is part of the pitch.
### 2.5 Vitals — All-in-One CRO Suite
- **Rating / scale:** 4.9 (2,598 reviews — 97% five-star). Est. $4M–$7M MRR.
- **BFS badge:** Yes.
- **Pricing entry:** Flat $29.99/mo. One plan. 7-day free trial.
- **Primary job:** Replace 10+ apps in a CRO stack with a single $30 bundle.
**Key takeaways:**
1. **Bundling is positioning-as-product** — "replace 10 apps for $30" is the entire pitch.
2. **40+ tile grid empty state** — surface every feature visually, let merchant choose, no forced flow.
3. **Lazy setup beats front-loaded questionnaires** when the value surface is exploratory.
4. **One price, one tier, one decision** — eliminates pricing paralysis.
5. **Visible storefront changes within 5 minutes** drive retention (sticky ATC, trust badges, etc.).
### 2.6 PageFly — Landing Page Builder
- **Rating / scale:** 4.9 (5,703 reviews). Est. $6M–$10M MRR.
- **BFS badge:** Yes.
- **Pricing entry:** Free (1 slot) → Builder $24 → Optimize $39 → Accelerate $99 / $990/yr. Slot-gated.
- **Primary job:** Build conversion-ready landing pages without theme limitations, no code.
**Key takeaways:**
1. **"What do you want to build?" picker empty state** — the single best onboarding flow in this teardown.
2. **Slot-based metering** is the cleanest predictable scaling unit for builder-style apps.
3. **Frame the app as additive, not replacement** — "Go beyond themes" defuses fear of rebuilding the store.
4. **Editor screenshot IS the hero shot** — when the product is a UI, show the UI.
5. **24/7 live chat on every tier including Free** — a moat against bootstrapped competitors.
---
## 3. Convergent patterns — what they all do
These are the patterns that appear in 5 of 6 or 6 of 6 listings. Treat as baseline, not differentiation.
### Hero anatomy (every listing follows this six-line stack)
1. App icon + name
2. Built for Shopify badge (if earned) + award ribbon (if earned)
3. Star rating + review count
4. "Free trial / Free plan / Pricing from $X" label
5. Region trust signal ("Based in United States" — all six)
6. Single primary CTA: **Install** (sometimes paired with "View demo store")
### Gallery anatomy
- **4–11 images, always benefit-named.** Every alt text sells one job. Never "Dashboard view." Always "Collect unlimited reviews automatically from email, SMS, QR code."
- Gallery is the pitch deck. Read more often than the description.
- For visual products: 8+ slides. For utility apps: 4–6.
### Description anatomy
- Two-paragraph block, **duplicated** (visible + after "more"). 5 of 6 apps lean into this Shopify quirk as repetition reinforcement.
- 5 bold capability bullets directly under the gallery — each is a **benefit statement**, not a feature.
### "Works with" curation
- **All 6** list complements; **none** list direct competitors. Klaviyo lists Gorgias. Judge.me and Loox never list each other. Curated trust.
### Pricing presentation
- **Tier-banded with anchoring.** Free → entry paid → mid → premium. Middle tier always carries the visual weight.
- **5 of 6 stack Free Plan + Free Trial** — belt-and-suspenders friction removal.
- Annual discount of **17%** is the App Store norm (Gorgias, PageFly).
### Reviews surface
- Star-distribution **histogram** + Shopify Magic's **"What merchants think"** AI summary. Even with 1-star outliers, the central tendency is the first thing the merchant sees.
### BFS badge leverage
- 4 of 6 (Judge.me, Loox, Vitals, PageFly) display BFS adjacent to the title. Functions as a meta-trust signal that overrides skepticism. Apply for it.
### Single primary CTA
- One Install button. Optional "View demo store" secondary. No competing CTAs, no newsletter signup, no "Learn more" button cluttering the hero.
---
## 4. Differentiators that drive install → activate → pay
What separates the top-1% apps from the merely good. These are the asymmetric bets to consider for a new entrant.
1. **Judge.me's flat $15** in a per-contact / per-impression category. **Pricing model is the differentiator.** If competitors meter usage, go flat. If competitors are flat, go usage-based. Pick the opposite axis.
2. **Vitals' bundle thesis** — "replace 10 apps for $30" is positioning-as-product. The differentiator is the meta-decision, not any individual feature.
3. **PageFly's "what do you want to build?"** picker. No competitor cracks the empty state this cleanly.
4. **Loox's gallery-as-pitch-deck** (8+ slides) for visual products. Most competitors ship 3-image galleries and lose the storytelling battle before the merchant clicks Install.
5. **Klaviyo's permanent free tier** vs. Mailchimp / Omnisend's trials. Merchants can stay free until they scale.
6. **Gorgias' "View demo store"** link. Uniquely useful for complex / abstract apps where screenshots fail to convey value.
7. **BFS badge + Build Award stacked** (Judge.me). A trust signal competitors literally cannot copy without earning the same badges. Plan for both.
8. **24/7 live chat on the Free tier** (PageFly, Loox). A bootstrapped competitor can't afford this — it's a structural moat.
9. **One-click migration from named competitors** (Judge.me, Loox). Switching cost approaches zero.
10. **Outcome-named pricing tiers** (Loox: Beginner / Convert / Unlimited). The tier name sells the merchant's destination at the pricing decision moment.
---
## 5. Twenty reusable UX patterns
Tagged with example apps + a Polaris / Shopify implementation hint. Steal liberally.
### Listing-page patterns
**1. Stack three trust signals in the hero.**
Examples: Judge.me, Loox, PageFly.
Polaris hint: This is App Store metadata, not Polaris. Plan the BFS application early, drive 100+ five-star reviews in the first 90 days, set up a Build Award submission once you cross 1k installs.
**2. Lead the tagline with the outcome verb.**
Examples: Judge.me ("Sell more"), Loox ("Convert more shoppers"), PageFly ("Go beyond themes").
Polaris hint: Listing copy field. Start with Sell / Convert / Save / Grow / Replace / Automate / Recover. No feature nouns first.
**3. Every gallery image is a job statement.**
Examples: All 6.
Polaris hint: Write the alt text first, design the screenshot second. The alt text is the slide.
**4. Use 6–9 gallery images, not 2–3.**
Examples: Loox (8+), Vitals (9+), Judge.me (6+).
Polaris hint: Budget for a full pitch-deck-in-screenshots during launch — not a minimal gallery. Gorgias' 2-image listing is the exception, not the model, and they offset it with the demo store.
**5. The editor UI is the hero shot for builder-style apps.**
Examples: PageFly.
Polaris hint: If you use AppProvider, embed a real Polaris UI screenshot, not a marketing illustration.
**6. Add "View demo store" alongside Install.**
Examples: Gorgias, Judge.me, Loox, Vitals.
Polaris hint: Spin up a development store with your app pre-installed, populate sample data, link it from the listing. Use Shopify's free dev store program.
**7. Curate "Works with" to feature complements, not competitors.**
Examples: All 6.
Polaris hint: List 6–10 named integrations weighted toward popular complements (Klaviyo, Judge.me, PageFly, Gorgias, Shop App, Meta).
**8. Open the description with a psychology lede.**
Examples: Loox ("Visual reviews are the strongest form of social proof").
Polaris hint: 1–2 sentences reframing the merchant's problem before listing capabilities. Reframe → then features.
**9. Duplicate the description block.**
Examples: 5 of 6.
Polaris hint: This is a Shopify App Store template quirk — lean in. Visible block sells, expanded block reinforces.
### Pricing patterns
**10. Free plan + free trial stacked.**
Examples: Klaviyo, Judge.me, Loox, PageFly.
Polaris hint: Use `appSubscriptionCreate` with `trialDays`. Pair with a feature-gated Free plan that doesn't require billing.
**11. Flat pricing as positioning moat (or usage-based when competitors are flat).**
Examples: Judge.me $15, Vitals $29.99.
Polaris hint: Recurring `appSubscriptionCreate` with a fixed `price.amount`. Avoid `cappedAmount` unless you're going usage-based.
**12. Slot-based metering for predictable scaling.**
Examples: PageFly (1 / 5 / 20 / unlimited slots).
Polaris hint: Meter by a countable, predictable unit ("slots," "stores," "active campaigns") — not abstract API calls or contacts the merchant cannot count in their head.
**13. Outcome-named tiers.**
Examples: Loox (Beginner / Convert / Unlimited).
Polaris hint: Set `name` on each `appSubscription` plan to a destination noun, not "Pro" / "Plus" / "Enterprise."
**14. Bury full pricing matrix behind "See all pricing options."**
Examples: Klaviyo, Loox.
Polaris hint: Show 3–4 clean tier cards on the listing. Link to a deep matrix page on your marketing site for the long tail.
**15. Annual discount = 17%.**
Examples: Gorgias, PageFly.
Polaris hint: Use the App Store norm. `appSubscriptionCreate` with `interval: ANNUAL` priced at 0.83 × (12 × monthly).
### In-app onboarding patterns
**16. Picker-first empty state.**
Examples: PageFly ("What do you want to build?"), Loox (widget picker), Vitals (40-tile grid).
Polaris hint: Polaris `EmptyState` is wrong here. Build a custom card grid (`Layout` + `Card` + `MediaCard` tiles). Never ship a blank canvas as the first screen.
**17. Auto-place widgets via Online Store 2.0 theme blocks.**
Examples: Judge.me, Loox.
Polaris hint: Use Theme App Extensions (`shopify app generate extension --type=theme_app_extension`). Define App Blocks in `blocks/*.liquid`. Skip Asset API and theme code edits entirely — they are the #1 abandon point.
**18. Defer credential friction.**
Examples: Klaviyo (DNS at first send), PageFly (Pixel at first publish).
Polaris hint: At install ask only for Shopify OAuth scopes. Ask for everything else at the exact JIT moment using a Polaris `Banner` with action, or a `Modal` triggered by the relevant CTA.
**19. One-click migration from named competitors.**
Examples: Judge.me, Loox.
Polaris hint: Build a Settings → Import wizard with named tabs per competitor. CSV upload + API import + screenshot-based fallback. List every competitor by name — it converts.
**20. Pick one addictive dashboard metric and show it daily.**
Examples: Gorgias (deflection rate), Klaviyo (attributed revenue), Loox (photo reviews this week).
Polaris hint: Build a hero `Card` with a single big number, a delta vs. last 7 days, and a sparkline. Wire the same metric into a weekly digest email. Pick ONE — not three.
---
## 6. Listing-page patterns (deep dive)
The same teardown reveals consistent listing-page choices worth codifying separately, because the listing converts strangers before they ever see your in-app UX.
### Above-the-fold checklist
- App icon (1024×1024, recognizable at favicon size — most icons fail this)
- App name (≤30 chars; merchants scan)
- BFS badge (apply early)
- Award ribbon (if applicable)
- Star rating + count
- "Free plan available. Free trial available." label
- Single Install CTA
- Optional "View demo store" secondary
### Gallery sequencing rule
Order the slides as the merchant's purchase journey:
1. Connect / setup ("Connect [App] and Shopify in minutes")
2. Core value moment (the screenshot of the product doing its job)
3. Outcome metric / dashboard
4. Differentiator (AI / migration / mobile / speed)
5. Multi-channel reach (Google / Meta / TikTok if applicable)
6. Support / trust / migration
7. (Optional, visual products) lifestyle / before-after
### Description structure
```
[1-2 sentence psychology lede — reframe the problem]
[Capability paragraph — what it does in plain English]
[5 bold benefit bullets, each starting with a verb]
[Optional CTA repeat: "Get started in minutes"]
```
### "Works with" picks
Weight toward popular complements. Always include: Shop App, Checkout, Customer Accounts, Shopify Flow, the dominant ESP (Klaviyo), the dominant reviews app (Judge.me), the dominant page builder (PageFly), the dominant helpdesk (Gorgias) — wherever applicable. Never list direct competitors.
### Pricing card composition
Each card carries:
- Tier name (outcome-noun)
- Monthly price + annual savings
- Included usage unit (slots / contacts / tickets / orders)
- 3–5 included features (benefit-named, not feature-named)
- Trial length on paid tiers
- Single CTA: "Install" or "Start free trial"
### Reviews region
- Histogram + Magic AI summary appear automatically — do not fight Shopify on this.
- Reply to every 1-star review with a fix and a follow-up CTA. The reply is visible to future merchants and converts.
---
## 7. Decision tree: "I'm building X screen"
Use this lookup to jump straight to the right case study.
**I'm building the listing-page hero** → look at **Judge.me**. Stack BFS + Build Award + 5.0 + review count. Tagline starts with "Sell more."
**I'm building the listing-page gallery for a visual product** → look at **Loox**. 8+ slides, each a benefit statement, screenshot is the proof.
**I'm building the listing-page gallery for a utility / multi-feature app** → look at **Vitals**. 9+ slides, each slide is one sub-feature, closing slide is the kill shot ("Replace 10+ other apps").
**I'm building the listing-page gallery for a builder / UI-heavy app** → look at **PageFly**. Lead with the editor screenshot itself. Don't abstract.
**I'm writing the listing description** → look at **Loox** for the psychology lede, **PageFly** for the additive framing.
**I'm building the pricing page (simple flat)** → look at **Vitals** ($29.99 single tier) or **Judge.me** (flat $15 paid).
**I'm building the pricing page (tiered, predictable units)** → look at **PageFly**'s slot-based 1/5/20/unlimited.
**I'm building the pricing page (usage-based)** → look at **Loox** (orders-included) or **Gorgias** (tickets-included with per-unit overage that gets cheaper at higher tiers).
**I'm building the pricing page (named tiers)** → look at **Loox** (Beginner / Convert / Unlimited).
**I'm designing the install / OAuth flow** → look at **Judge.me**. Ask for the minimum at install (sender email + brand color, both pre-filled). Defer everything else.
**I'm designing the first-run empty state for a builder** → look at **PageFly**. "What do you want to build?" picker → 8 tiles → template gallery filtered by choice.
**I'm designing the first-run empty state for a feature-pickable suite** → look at **Vitals**. 40-tile grid with toggles, recommended starter set, no forced linear flow.
**I'm designing the first-run empty state for a widget-driven app** → look at **Loox**. Choose a widget style → live preview on the storefront → approve.
**I'm designing the onboarding checklist** → look at **Klaviyo** (5-step persistent checklist with green checkmarks) or **Gorgias** ("Get started" widget tracking 5 tasks).
**I'm designing the dashboard hero metric** → look at **Gorgias** (deflection rate), **Klaviyo** (attributed revenue), **Loox** (photo reviews this week). Pick ONE number that grows daily.
**I'm designing the migration / import wizard** → look at **Judge.me**. Named tabs per competitor (CSV, AliExpress, Loox, Yotpo, Stamped, Amazon, Etsy). Make switching cost zero.
**I'm designing theme integration** → look at **Judge.me** / **Loox**. Theme App Extensions + App Blocks. Never edit theme code.
**I'm designing a settings page** → look at **Gorgias**. Sectioned by channel (email, chat, social, voice) with a status indicator per channel.
**I'm designing the "first value" moment** → look at the app whose pattern matches your category:
- Re-monetization → Klaviyo (attributed revenue widget within 72h)
- Support → Gorgias (first AI auto-resolved ticket banner)
- Reviews → Judge.me / Loox (first photo review live on a product page)
- CRO suite → Vitals (3 features enabled, visible storefront changes in 5 minutes)
- Pages → PageFly (publish first page, see live URL within 15 minutes)
---
## 8. Anti-patterns observed in top apps
Even category leaders ship friction. Don't copy these.
**1. Hidden scaling curves on the listing.**
Klaviyo's headline price ($20) hides the steep climb for a 10k-contact merchant (~$150/mo). The listing never reveals this. Result: surprise-bill 1-star reviews.
**Don't do this** — surface a representative scaling example on the listing or in the upgrade modal. An honest curve converts better than the bait-and-switch.
**2. Implausibly low entry-tier limits.**
Gorgias' $10 Starter (3 agents, 50 tickets) is a trojan horse. Loox's Free (100 emails/mo) forces upgrade within weeks.
**Don't do this** for the free tier — Judge.me proves a genuinely useful free tier converts better long-term. Entry paid tiers can have caps, but make them realistic.
**3. Opaque add-on pricing.**
Gorgias' "Automation add-on" is the load-bearing AI feature, sold separately on a sales call, with no pricing on the listing.
**Don't do this** — if the feature is the differentiator, price it on the listing. Sales-call pricing leaks merchants to competitors with self-serve pricing.
**4. Vague enterprise asterisks.**
Vitals' "Additional charges apply as your store generates revenue and impact" — no thresholds disclosed.
**Don't do this** — disclose the threshold ("After $X GMV/month, custom pricing applies").
**5. Beta-labeled headline features.**
PageFly's "AI conversion rate optimization (Beta)" is on two tiers. Beta lowers expectations but undercuts the buying argument.
**Don't do this** — either ship the feature or don't sell it on the pricing page.
**6. Crippled free tiers when a real free tier is on-brand.**
Loox's 100 emails/mo Free is so limited it feels punitive next to Judge.me's genuinely useful Free.
**Don't do this** — if you offer Free, make it good enough to retain. The free tier is your acquisition engine, not your upsell trap.
**7. Two-image galleries.**
Gorgias gets away with it because of the demo store. Most apps cannot.
**Don't do this** — ship 6+ slides.
**8. Skipping the region trust signal.**
Apps based outside the US sometimes fail to display a "Based in [country]" line and lose merchants worried about support hours.
**Don't do this** — always surface your region.
**9. Front-loaded questionnaires before any value.**
Forces a merchant to answer 6 questions before seeing the product. Vitals' lazy / on-demand setup beats this.
**Don't do this** — if you need data for personalization, ask only what's needed for the next 60 seconds of value.
**10. Theme code edits as install step.**
Any app that asks the merchant to paste code into theme.liquid loses 30%+ at that step.
**Don't do this** — Theme App Extensions and App Blocks are non-negotiable for any new app in 2026.
---
## Closing principle
The six apps in this teardown represent $70M–$110M of combined Shopify-channel MRR. They share more than they differ because the App Store has converged on a tight set of patterns. The conservative path is to **adopt every converged pattern as baseline** and **place 1–2 differentiator bets** from section 4. Resist the urge to be different on the things merchants don't care about (hero layout, pricing card structure, BFS badge) and concentrate originality on the empty-state moment and the pricing model — the two surfaces where the leaders are still beatable.
using-shopify-app-builder4.27 KB
--- name: using-shopify-app-builder description: "Use at the start of any Shopify app engineering, debugging, review, launch, listing, or growth task. Routes the request to the smallest relevant Shopify App Builder skills and enforces credential, verification, deployment, publication, and paid-spend boundaries. Triggers include: 'Shopify app', 'Shopify extension', 'Shopify API', 'App Bridge', 'Polaris', 'Built for Shopify', 'App Store listing', and 'Shopify app ads'." --- # Using Shopify App Builder Treat this skill as the router for the toolkit. Select focused skills before proposing code or operational changes. ## Routing workflow 1. Inspect the repository, framework, Shopify configuration, and the user's stated outcome. 2. Classify the request with the routing table below. 3. Read the selected skill files completely. Use the smallest set that covers the request. 4. Verify time-sensitive platform behavior against current official Shopify documentation. 5. Implement only what the user authorized, then run proportionate checks on the real affected surface. ## Skill routing table | Request | Start with | Add when needed | | --- | --- | --- | | New app, scaffold, extension, or deployment plan | `shopify-cli` | `app-validation`, `app-niche-finder`, `app-auth`, `app-billing` | | Admin data or GraphQL error | `admin-graphql` | `metafields-metaobjects`, `webhooks`, `dev-troubleshooting` | | Legacy REST migration | `admin-rest` | `admin-graphql`, `migrate-rest-to-graphql` command in Claude/Cursor | | OAuth, token exchange, HMAC, or session issue | `app-auth` | `dev-troubleshooting`, `webhooks` | | Billing, plans, trials, or usage charges | `app-billing` | `app-pricing-strategy`, `merchant-pain-prevention` | | Embedded admin UI | `app-bridge` and `polaris-ui` | `ux-polaris-antipatterns`, `app-accessibility`, `app-performance` | | Storefront or theme work | `storefront-api`, `hydrogen-storefront`, or `liquid-themes` | `metafields-metaobjects` | | Checkout or backend customization | `shopify-functions` | `admin-graphql`, `dev-troubleshooting` | | Webhook delivery or signature verification | `webhooks` | `app-auth`, `dev-troubleshooting` | | Pre-ship or App Store review | `built-for-shopify-standards` | `merchant-pain-prevention`, `app-accessibility`, `app-performance`, UX skills | | Listing, name, price, or market validation | `app-listing-optimization`, `app-naming`, `app-pricing-strategy`, or `app-validation` | `app-niche-finder` | | App Store advertising | `shopify-app-store-ads` | `app-listing-optimization`, `app-pricing-strategy` | | Shopify MCP or agentic commerce | `shopify-mcp` | Relevant API and authentication skills | ## Operating guidelines - Inspect first. Do not assume the app template, API version, package version, scopes, or deployment provider. - Use official Shopify documentation as the authority for unstable platform facts. - Keep OAuth scopes minimal and explain every requested write scope. - Treat GraphQL HTTP success separately from GraphQL `errors`, mutation `userErrors`, and throttle metadata. - Verify webhooks with the raw request body and constant-time HMAC comparison. - Never expose, echo, commit, or publish tokens, app secrets, session data, `.env` contents, or personal filesystem paths. - Do not deploy, publish, submit, alter billing, or enable paid advertising unless the user explicitly authorizes that action. - Preserve unrelated work in dirty repositories and avoid destructive cleanup. - Report live evidence for deployments and dashboard changes; source edits or a passing build alone are not proof of live success. ## Harness behavior - Claude Code can use the bundled slash commands and specialist agents in addition to skills. - Codex and OpenCode should invoke focused skills by name or natural-language intent; Claude-specific agents are optional reference material, not native subagents. - Cursor can load the native plugin surfaces or the portable skills collection. - Gemini CLI receives this routing policy through `GEMINI.md` and reads focused `SKILL.md` files as needed. - Command Code and other Agent Skills-compatible harnesses use the portable skills collection. ## Completion gate Before reporting success, state what changed, which checks ran, what live surface was verified, and what remains unverified. Never convert an inference into a completion claim.
ux-empty-error-states26.4 KB
---
name: ux-empty-error-states
description: "Use when designing empty states, loading states, error states, and partial-failure states in a Shopify embedded app. Covers Polaris EmptyState, SkeletonPage/SkeletonBodyText, Banner tones (critical/warning/info/success), Toast vs Banner vs Modal decision, optimistic UI in Remix, network-down handling, partial bulk-failure recipes, GraphQL '200 OK with errors' gotcha. Triggers: 'empty state', 'loading state', 'error state', 'polaris banner', 'skeleton', 'toast', 'optimistic ui', 'partial failure', 'network down', 'remix loading ux', 'graphql error handling'."
---
# Empty, Loading & Error State UX for Shopify Polaris Apps
A practical, opinionated reference for the three states that make or break a Shopify embedded app: when there's nothing yet, when something is on its way, and when something went sideways. Pulled from Polaris docs, Remix pending UI docs, and real-world patterns.
---
## 1. EmptyState — when, how, and what to put inside
### What it is
`EmptyState` is Polaris' purpose-built component for "this page/section is empty." It composes a centered illustration, a heading, optional body text, and a primary action (and optional secondary action). It is intended for whole-page-empty experiences, not tiny empty slots inside a card sidebar.
### When to use it
Use `EmptyState` when:
- A list/table/chart has zero rows for first-time merchants (no products, no orders, no campaigns).
- A filtered/searched view returns zero matches (different copy, same component).
- A feature requires setup before it can be used (no connected account, no plan selected).
- A merchant landed on a page that needs onboarding context before they can act.
Do **not** use `EmptyState` for:
- Small empty slots inside a `Card` where a simple "No notes yet" sentence is enough.
- Form fields that aren't filled in — that's just normal state.
- Loading. Use `SkeletonPage` instead.
- Errors. Use a `Banner` (or a full-page error view) instead.
### Anatomy
```tsx
import { EmptyState, Page } from "@shopify/polaris";
<Page>
<EmptyState
heading="Manage your inventory transfers"
action={{ content: "Add transfer", onAction: () => navigate("/transfers/new") }}
secondaryAction={{
content: "Learn more",
url: "https://help.shopify.com/manual/inventory/transfers",
external: true,
}}
image="https://cdn.shopify.com/s/files/.../empty-state.svg"
>
<p>Track and receive your incoming inventory from suppliers.</p>
</EmptyState>
</Page>
```
### Content rules
- **Heading** — a friendly sentence fragment, not a label. "Manage your inventory transfers," not "Transfers."
- **Body** — one or two sentences. Explain the value, not the mechanism.
- **Primary action** — verb-first, the single most important thing they can do. "Add transfer," "Import products," "Connect Stripe."
- **Secondary action** — almost always a "Learn more" link to Shopify docs or your own help article. Use `external: true` and the new-window icon will render.
- **Image** — a Polaris illustration or your own brand-consistent SVG. ~400×250px works well. Polaris recommends ~40px of white space above when nested inside a `Card` or `Modal`.
### Two flavors you must handle
1. **First-run empty** (no data has ever existed). Copy is teaching-oriented. CTA is "Create your first X."
2. **Filtered empty** (data exists, the filter killed it). Copy is "No results match your filters." CTA is "Clear filters" or "Reset search." Do NOT show the onboarding CTA here — it confuses returning users.
Distinguish them in code:
```tsx
const showFilteredEmpty = products.length === 0 && hasActiveFilters;
const showFirstRunEmpty = products.length === 0 && !hasActiveFilters;
```
---
## 2. SkeletonPage / SkeletonBodyText — loading patterns
Polaris ships four skeleton components: `SkeletonPage`, `SkeletonBodyText`, `SkeletonDisplayText`, `SkeletonTabs`, and `SkeletonThumbnail`. The point is **perceived performance**: merchants tolerate a 600ms load that shows shape over a 200ms load that shows a blank screen.
### When to use each
| Component | Use for |
|---|---|
| `SkeletonPage` | Wrap the whole route while the loader runs. Pass `primaryAction` and `title` as booleans to render their skeleton equivalents. |
| `SkeletonBodyText` | Multi-line text blocks. `lines={3}` default — match it to the real content's line count. |
| `SkeletonDisplayText` | One large piece of dynamic text (a product name, an order number). `size="small" | "medium" | "large"`. |
| `SkeletonTabs` | The tab strip on a tabbed page. |
| `SkeletonThumbnail` | Product image placeholders in lists. |
### Anatomy of a skeleton route
```tsx
import {
SkeletonPage,
Layout,
Card,
SkeletonBodyText,
SkeletonDisplayText,
} from "@shopify/polaris";
export function ProductsSkeleton() {
return (
<SkeletonPage primaryAction title="Products">
<Layout>
<Layout.Section>
<Card>
<SkeletonBodyText lines={8} />
</Card>
<Card>
<SkeletonDisplayText size="small" />
<SkeletonBodyText lines={3} />
</Card>
</Layout.Section>
<Layout.Section variant="oneThird">
<Card>
<SkeletonBodyText lines={2} />
</Card>
</Layout.Section>
</Layout>
</SkeletonPage>
);
}
```
### Skeleton rules
- **Use skeletons for dynamic content only.** A static page title can render its real text immediately; only the changing parts need skeletons.
- **Match the shape.** Don't show 3 skeleton lines when the real card has 8. Merchants notice the jump.
- **Don't combine skeletons with spinners on the same view.** Pick one.
- **Don't skeleton for <300ms loads.** It flashes and feels broken. Use a spinner or just render nothing.
- **Don't skeleton for >10s loads.** That's a slow query — show a progress message ("Importing 4,200 products…").
### Remix integration
In Remix, render the skeleton when `useNavigation().state === "loading"` for the route you're loading into, or use a `<Suspense fallback={<Skeleton />}>` boundary around a deferred loader value.
```tsx
import { useNavigation } from "@remix-run/react";
export default function Products() {
const navigation = useNavigation();
const isLoading = navigation.state === "loading";
return isLoading ? <ProductsSkeleton /> : <ProductsContent />;
}
```
---
## 3. Error banners — tone hierarchy and recovery actions
Polaris `Banner` has five tones, each with semantics, color, icon, and screen-reader behavior:
| Tone | Use for | A11y role | Dismissible? |
|---|---|---|---|
| `critical` | Blocking errors, payment failure, action impossible | `role="alert"` (announced immediately) | No — only if merchant can dismiss safely |
| `warning` | Something needs their attention soon (trial ending, deprecation) | `role="alert"` | Often yes |
| `info` | Status updates, neutral context ("Sync in progress") | `role="status"` (announced after critical) | Yes |
| `success` | Confirmation of a multi-step or async win | `role="status"` | Yes |
| `neutral` | Defaults, low-priority context | `role="status"` | Yes |
### Anatomy
```tsx
<Banner
tone="critical"
title="Could not publish 3 products"
action={{ content: "Retry failed", onAction: retryFailed }}
secondaryAction={{ content: "View errors", onAction: showLog }}
onDismiss={() => setDismissed(true)}
>
<p>
These products failed validation: <Link url="/products?failed=true">view the list</Link>.
</p>
</Banner>
```
### Banner content rules
- **Title** — what happened, not what to do. "Could not publish 3 products," not "Please try again."
- **Body** — one sentence explaining cause if you know it. If you don't, say so honestly ("We're not sure why").
- **Primary action** — always include a recovery path when one exists. "Retry," "Reconnect," "Try again."
- **Secondary action** — "View details," "Contact support," "Learn more."
- **Critical banners** for form submission errors should be placed at the top of the form, and focus should be moved to the banner programmatically when the form is submitted with errors.
### Inline errors vs. banner errors
- **Inline error** (`InlineError` or the `error` prop on `TextField`) — for field-level validation. Sits directly below the input. Wire `aria-describedby` to the input.
- **Banner critical** — for form-level summary OR for errors that aren't tied to a single field (API failure, permission denied).
Use both together for long forms: inline errors at each broken field + a critical banner at the top saying "Fix 3 errors below."
---
## 4. 5xx vs 4xx UX — what to show users
### 4xx — the merchant's request was bad
Categories:
- **400 / 422 Validation** — show inline errors on the exact fields. Banner only if there are multiple.
- **401 Unauthenticated** — silently redirect to auth (App Bridge will usually handle this for embedded apps).
- **403 Forbidden / scope missing** — `Banner tone="warning"` with "Reconnect" or "Grant permission" action. Tell them what permission is missing. Never blame them.
- **404 Not found** — full-page empty state with "Back to [parent]" action. Don't apologize, don't be cute.
- **409 Conflict** — modal asking them to choose ("Overwrite" / "Keep both" / "Cancel").
- **429 Rate limited** — `Banner tone="warning"` "Too many requests. Try again in 60 seconds." Show a countdown if you can. Auto-retry in the background.
### 5xx — Shopify or your server is broken
- **500 Generic error** — full-page error view OR `Banner tone="critical"` depending on whether the page rendered. Always include a "Retry" button. Log the request ID and surface it in a `<details>` so support can correlate.
- **502 / 503 / 504 Gateway / unavailable** — retry once or twice in the background, then show a banner: "Shopify is having trouble right now. We'll keep trying." Link to `https://status.shopify.com`.
### GraphQL caveat (Shopify Admin API)
The GraphQL Admin API can return HTTP 200 with errors in the response body. Always check `data.userErrors` (mutation user errors) and the top-level `errors` array (request errors) before treating a response as success. A 200 status is not a green light.
```ts
const res = await admin.graphql(MUTATION, { variables });
const json = await res.json();
if (json.errors?.length) throw new Error(json.errors[0].message);
if (json.data?.productCreate?.userErrors?.length) {
return { ok: false, errors: json.data.productCreate.userErrors };
}
```
### What to show, by error class
| Class | Page state | Component | Tone | Recovery |
|---|---|---|---|---|
| Validation (400/422) | Form stays, errors inline | `TextField error` + `Banner` summary | critical | Fix inline |
| Auth (401) | Redirect | — | — | App Bridge handles |
| Permission (403) | Page renders, blocked card | `Banner` | warning | "Reconnect" action |
| Not found (404) | Full-page empty | `EmptyState` with `image` | — | "Back to [list]" |
| Conflict (409) | Modal | `Modal` | — | Choice buttons |
| Rate limit (429) | Page renders | `Banner` | warning | Auto-retry, show countdown |
| Server (5xx) | Depends | `Banner` or full-page error | critical | "Retry" + status link |
---
## 5. Toast vs Banner vs Modal
The three feedback patterns are not interchangeable. Pick wrong and merchants miss the message or get blocked unnecessarily.
### Toast
- **Purpose** — brief, non-blocking confirmation of an action. 3-second auto-dismiss.
- **Length** — 3 words ideally, 6 max.
- **Use for** — "Product saved," "Order archived," "Settings updated," "Copied to clipboard."
- **Do NOT use for** — errors that need action, persistent state, anything a merchant needs to remember after they look away.
- **Do NOT use for** — "Internet disconnected" if they need to do something about it. Toast is fine for the moment-of-disconnect; persistent connectivity issues belong in a banner.
```tsx
import { Toast, Frame } from "@shopify/polaris";
<Toast content="Product saved" onDismiss={hide} />
// Error variant:
<Toast content="Could not save" error onDismiss={hide} action={{ content: "Retry", onAction: retry }} />
```
### Banner
- **Purpose** — persistent, in-context message that needs the merchant's attention but doesn't block the page.
- **Use for** — form errors, billing warnings, sync status, partial failures, feature announcements, deprecation notices.
- **Lives at** — top of page or top of section. Stays until dismissed or until the underlying condition changes.
### Modal
- **Purpose** — block all other interaction until the merchant makes a choice.
- **Use for** — destructive confirmations ("Delete 47 products?"), conditional changes that need explicit consent, focused single-task flows (a 1-step wizard).
- **Do NOT use for** — complex multi-step forms (use a dedicated page).
- **Do NOT use for** — anything you could put in a banner. Modals are disruptive — reserve them.
### Decision flow
```
Need to interrupt the user? → Modal
Persistent, needs action or attention? → Banner
Confirmation of a thing they just did, no action needed? → Toast
```
---
## 6. Optimistic update pattern in Remix
Optimistic UI = updating the screen immediately based on what you know the user just submitted, before the server confirms. The merchant sees instant feedback; you reconcile when the response arrives.
### When to do it
- Toggles (publish / unpublish, archive / restore).
- Quick edits with tiny payloads (rename, change tag).
- Add-to-list actions where the new item shape is predictable.
### When NOT to do it
- Anything that returns data only the server knows (auto-generated IDs you display, computed totals, side-effects).
- Anything where rollback would confuse the user (payment confirmation).
- Slow-failing operations (file uploads where you won't know success for 30 seconds).
### The pattern with `useFetcher`
```tsx
import { useFetcher } from "@remix-run/react";
function PublishToggle({ product }: { product: Product }) {
const fetcher = useFetcher();
// Optimistic value: if the fetcher is submitting, use what it sent.
const isPublished =
fetcher.formData
? fetcher.formData.get("published") === "true"
: product.published;
return (
<fetcher.Form method="post" action={`/products/${product.id}/publish`}>
<input type="hidden" name="published" value={String(!isPublished)} />
<Button submit pressed={isPublished}>
{isPublished ? "Published" : "Draft"}
</Button>
</fetcher.Form>
);
}
```
### Handling failure
The fetcher exposes `fetcher.data` once the server responds. If `data.ok === false`, render an inline error or fire a toast and let React revert (the optimistic value derived from `formData` clears when the submission finishes).
```tsx
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.ok === false) {
showToast({ content: "Could not publish", error: true });
}
}, [fetcher.state, fetcher.data]);
```
### List add/remove with `useFetchers`
To handle multiple in-flight optimistic actions at once (e.g., bulk publishing), `useFetchers()` returns every active fetcher. You can merge their `formData` into your rendered list to show pending items immediately.
---
## 7. Network-down behavior
### Detection
`navigator.onLine` and the `online`/`offline` window events are your starting point — but `onLine === true` only means the device is on *some* network, not that it can reach your server. Verify with a real ping (a HEAD to your `/healthz` or a cheap GraphQL query) when it matters.
```ts
useEffect(() => {
const handleOnline = () => verifyAndResume();
const handleOffline = () => setOffline(true);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
```
### The three-step UX
1. **Detect and inform** — small persistent banner at the top: "You're offline. Some features won't work."
2. **Queue or block** — for read-only views, let them keep browsing cached data. For writes, disable mutate buttons and show a tooltip ("Save when reconnected"). If you support background queueing (rare in admin apps), tell them: "Changes will sync when you're back online."
3. **Reassure when back** — toast: "Back online." If you queued anything, fire it and show a banner: "Syncing 3 pending changes…" → "All changes saved."
### Rules
- Never use a modal for connectivity. It blocks all interaction including the retry attempt.
- Use a banner (`tone="warning"`) for persistent offline state.
- A toast is appropriate for the moment of disconnect ("Internet disconnected") but not for the persistent condition.
- Don't trigger destructive cleanup on disconnect. The connection might come back in 2 seconds.
---
## 8. Partial failure — "7 of 10 products imported"
This is one of the most under-handled states in Shopify apps. A bulk operation rarely either succeeds completely or fails completely — it almost always succeeds for some items and fails for others. Treat partial success as a first-class state, not an edge case.
### The pattern
```tsx
<Banner
tone="warning"
title="Imported 7 of 10 products"
action={{ content: "Retry failed", onAction: retryFailed }}
secondaryAction={{ content: "Download error report", onAction: downloadCsv }}
>
<p>3 products could not be imported. Common cause: missing SKU.</p>
<List type="bullet">
<List.Item>Acme Widget — duplicate handle</List.Item>
<List.Item>Beta Gadget — missing price</List.Item>
<List.Item>Gamma Tool — invalid weight unit</List.Item>
</List>
</Banner>
```
### Rules
- **Count the wins first.** "Imported 7 of 10," not "Failed to import 3 of 10." Merchants need to know what worked before they fix what didn't.
- **List the failures with reasons.** Up to 10 inline. Beyond that, offer a CSV download.
- **Single retry action.** Re-process only the failed items, not all 10.
- **Don't auto-dismiss.** This is a persistent banner until merchant acknowledges.
- **Preserve order in retries.** Retried failures should appear in the same place if they succeed on second pass.
### Data shape
Return both arrays from your action so the UI can show both:
```ts
return json({
succeeded: [{ id, handle }, ...],
failed: [{ row, reason, payload }, ...],
});
```
---
## 9. Fifteen state UX rules
A condensed cheat sheet to keep next to your editor.
1. **Every page has 5 states.** Empty, loading, partial, error, and full. Design all five before shipping.
2. **First-run empty is different from filtered empty.** Different copy, different CTAs.
3. **Skeleton for dynamic content, not static.** A static title can render immediately.
4. **No skeleton under 300ms.** It flashes.
5. **No spinner over 10 seconds.** That's a slow process — show progress.
6. **Critical banners get screen-reader priority.** Use `tone="critical"` only when it really is.
7. **Toast = confirmation, Banner = condition, Modal = blocker.** Don't mix them up.
8. **Inline errors live with their field.** Banners summarize.
9. **Every error has a recovery action.** "Retry," "Reconnect," "Contact support," "Go back."
10. **A 200 from GraphQL is not a green light.** Check `userErrors` and `errors`.
11. **Optimistic UI only when you can predict the result.** No optimistic IDs.
12. **Failure rollback must be silent unless the user needs to act.** Toast on revert, not a modal.
13. **Partial failure is the default outcome of bulk ops.** Design for it.
14. **Verify connectivity with a fetch, not just `navigator.onLine`.**
15. **Tell merchants what to do, not just what happened.** "Reconnect Stripe" beats "Stripe error."
---
## 10. Concrete recipes
### Recipe A — Empty product list (first run)
```tsx
import { Page, EmptyState } from "@shopify/polaris";
import { useNavigate } from "@remix-run/react";
export function EmptyProductList() {
const navigate = useNavigate();
return (
<Page title="Products">
<EmptyState
heading="Start by adding your first product"
action={{ content: "Add product", onAction: () => navigate("/products/new") }}
secondaryAction={{
content: "Import from CSV",
onAction: () => navigate("/products/import"),
}}
image="/empty-products.svg"
>
<p>Products you add will show up here. You can also import a CSV.</p>
</EmptyState>
</Page>
);
}
```
### Recipe B — Empty orders (filtered)
```tsx
<EmptyState
heading="No orders match these filters"
action={{ content: "Clear filters", onAction: clearFilters }}
image="/empty-search.svg"
>
<p>Try widening your date range or removing tags.</p>
</EmptyState>
```
Notice: no onboarding CTA. The merchant has orders — the filter just hid them.
### Recipe C — Failed API call (single action)
```tsx
import { Banner } from "@shopify/polaris";
function ProductSyncCard({ fetcher }: { fetcher: FetcherWithComponents<any> }) {
const failed = fetcher.state === "idle" && fetcher.data?.error;
if (!failed) return <SyncButton fetcher={fetcher} />;
return (
<Banner
tone="critical"
title="Could not sync products"
action={{ content: "Try again", onAction: () => fetcher.submit(null, { method: "post" }) }}
secondaryAction={{
content: "Contact support",
url: "mailto:support@yourapp.com?subject=Sync%20failed",
}}
>
<p>
Shopify returned an error. Request ID:{" "}
<code>{fetcher.data.requestId}</code>
</p>
</Banner>
);
}
```
### Recipe D — Slow query (long-running export)
For operations expected to take more than a few seconds, swap the skeleton/spinner for a progress message and let the merchant leave the page.
```tsx
<Card>
<BlockStack gap="200">
<InlineStack gap="200" blockAlign="center">
<Spinner size="small" />
<Text as="p">Generating export…</Text>
</InlineStack>
<Text as="p" tone="subdued">
This usually takes 2-3 minutes. We'll email you when it's ready — feel free to navigate away.
</Text>
<ProgressBar progress={percent} size="small" />
</BlockStack>
</Card>
```
For Remix specifically, kick the work off in an action that enqueues a background job, return immediately, and poll status via a `useFetcher` set on a 5-second interval. Don't tie up a request for 3 minutes.
### Recipe E — Bulk import with partial failure
```tsx
function ImportResult({ result }: { result: ImportResult }) {
if (result.failed.length === 0) {
return (
<Banner tone="success" title={`Imported ${result.succeeded.length} products`} />
);
}
return (
<Banner
tone="warning"
title={`Imported ${result.succeeded.length} of ${result.succeeded.length + result.failed.length} products`}
action={{ content: "Retry failed", onAction: () => retry(result.failed) }}
secondaryAction={{
content: "Download error CSV",
onAction: () => downloadCsv(result.failed),
}}
>
<List type="bullet">
{result.failed.slice(0, 5).map((f) => (
<List.Item key={f.row}>
Row {f.row}: {f.reason}
</List.Item>
))}
{result.failed.length > 5 && (
<List.Item>…and {result.failed.length - 5} more</List.Item>
)}
</List>
</Banner>
);
}
```
### Recipe F — Offline indicator
```tsx
import { Banner, Frame, Toast } from "@shopify/polaris";
function OfflineBanner({ offline }: { offline: boolean }) {
if (!offline) return null;
return (
<Banner tone="warning" title="You're offline">
<p>Some actions are disabled until you reconnect.</p>
</Banner>
);
}
```
Pair with a toast when state flips:
```tsx
useEffect(() => {
if (justReconnected) showToast({ content: "Back online" });
}, [justReconnected]);
```
### Recipe G — 404 page
```tsx
<Page>
<EmptyState
heading="We couldn't find that product"
action={{ content: "Back to products", onAction: () => navigate("/products") }}
image="/empty-404.svg"
>
<p>It may have been deleted or the link may be wrong.</p>
</EmptyState>
</Page>
```
### Recipe H — Form with both inline errors and a banner
```tsx
<Form method="post">
{actionData?.errors && (
<Banner tone="critical" title="Fix the errors below">
<p>{actionData.errors.length} fields need your attention.</p>
</Banner>
)}
<TextField
label="Product title"
value={title}
onChange={setTitle}
error={actionData?.errors?.title}
autoComplete="off"
/>
<TextField
label="Price"
value={price}
onChange={setPrice}
error={actionData?.errors?.price}
autoComplete="off"
type="currency"
/>
</Form>
```
Move focus to the banner on submit-with-errors so screen-reader users hear the summary first:
```tsx
const bannerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (actionData?.errors) bannerRef.current?.focus();
}, [actionData]);
```
---
## Sources
- [Empty state — Shopify Polaris React](https://polaris-react.shopify.com/components/layout-and-structure/empty-state)
- [Skeleton page — Shopify Polaris React](https://polaris-react.shopify.com/components/feedback-indicators/skeleton-page)
- [Skeleton body text — Shopify Polaris React](https://polaris-react.shopify.com/components/feedback-indicators/skeleton-body-text)
- [Skeleton tabs — Shopify Polaris React](https://polaris-react.shopify.com/components/feedback-indicators/skeleton-tabs)
- [Banner — Shopify Polaris React](https://polaris-react.shopify.com/components/feedback-indicators/banner)
- [Error messages — Shopify Polaris React](https://polaris-react.shopify.com/content/error-messages)
- [Inline error — Shopify Polaris React](https://polaris-react.shopify.com/components/selection-and-input/inline-error)
- [Toast — Shopify Polaris React](https://polaris-react.shopify.com/components/deprecated/toast)
- [Modal — Shopify Polaris React](https://polaris-react.shopify.com/components/deprecated/modal)
- [Index table — Shopify Polaris React](https://polaris-react.shopify.com/components/tables/index-table)
- [useFetcher — Remix](https://remix.run/docs/en/main/hooks/use-fetcher)
- [useNavigation — Remix](https://remix.run/docs/en/main/hooks/use-navigation)
- [Pending and Optimistic UI — Remix](https://remix.run/docs/en/main/discussion/pending-ui)
- [Shopify API Response Statuses and Error Codes](https://www.cleverence.com/articles/shopify-dev-documentation/shopify-api-response-status-and-error-codes-5831/)
- [Offline UX design guidelines — web.dev](https://web.dev/articles/offline-ux-design-guidelines)
- [Fixing what's broken: in-product error messages — Shopify Design](https://medium.com/shopify-ux/fixing-whats-broken-how-to-improve-your-in-product-error-messages-f723508055bc)
ux-modern-app-feel32.7 KB
---
name: ux-modern-app-feel
description: "Use when you want a Shopify embedded app to feel modern, fast, and opinionated like Linear, Notion, Vercel, or Cron — speed-first, keyboard-first, calm UI, opinionated defaults, no-config success path. Covers keyboard shortcuts inside App Bridge, command palette patterns, micro-interactions Polaris allows, density vs spacious tradeoffs, brand expression within Polaris tokens, and 15 concrete patterns to copy from modern SaaS into Polaris-compliant Shopify apps. Triggers: 'modern shopify app', 'fast app', 'linear-style ux', 'notion-style ux', 'keyboard shortcuts shopify app', 'command palette', 'calm ui', 'minimalist polaris', 'opinionated defaults', 'fewer settings', 'speed first'."
---
# Modern App Feel for Shopify Embedded Apps
Most Shopify apps feel like 2015 — wall-of-settings, slow page loads, mouse-only, no keyboard, no taste. The modern reference is Linear, Notion, Vercel, Cron, Raycast, Superhuman, Arc — apps that feel calm, instant, opinionated, and keyboard-first. This skill is how to bring that feel inside Polaris and App Bridge 4.x without breaking the Built for Shopify (BFS) badge or merchant expectations.
The premise: Polaris is the floor, not the ceiling. You can absolutely build a Linear-tier embedded app within Polaris — you just have to be deliberate about the seven things that actually make an app feel modern, and ruthless about everything else.
---
## When to Use This Skill
Use when:
- Building a new Shopify embedded app and you want it to feel categorically better than the competitor apps in the same category
- Refactoring an existing app that feels slow, cluttered, or "Shopify-default" with no taste
- Asked "how do I make this feel more like Linear / Notion / Vercel inside a Shopify app"
- Adding keyboard shortcuts, a command palette, optimistic UI, or other speed-feel patterns to an embedded app
- Deciding between dense vs spacious layouts for a merchant-facing dashboard
- Asked about brand expression — when can you override Polaris colors, and when shouldn't you
- About to ship an "infinite settings" page and want the heuristic for whether each setting earns its place
- Considering a custom design system on top of Polaris (almost always wrong — this skill explains why)
Do NOT use when:
- Building a checkout extension or storefront block — those have their own constraints (Checkout UI Extensions, theme app extensions)
- Building a non-embedded surface (POS, mobile app shell)
- The merchant has explicitly asked for a Shopify-default-looking app for trust reasons (rare, but real for some legal/finance apps)
---
## The Six Modern SaaS UX Principles (And How Each Lands Inside Polaris)
Every modern SaaS app worth copying — Linear, Notion, Vercel, Cron, Raycast, Superhuman, Arc — converges on these six principles. The trick is translating each into something Polaris-compliant.
### 1. Speed Above Everything
Linear's mantra is "fast is a feature." Anything under 100ms feels instant, anything over 300ms feels slow, anything over 1s loses the user. Modern apps engineer the perceived performance budget aggressively — optimistic UI updates, prefetching, route-level caching, skeleton screens that match real layout.
**Inside Polaris:** Polaris doesn't fight you here. It ships fast-rendering primitives. The bottlenecks are usually (a) your Remix loaders waiting on Shopify GraphQL, (b) re-renders from unmemoized state, (c) no optimistic updates on mutations. Polaris does NOT ship a built-in optimistic UI helper — you wire it yourself with Remix `useFetcher` or TanStack Query.
### 2. Density (When the Merchant Is Power)
Linear, Notion, Airtable use **information-dense** layouts. More rows, smaller padding, tighter typography. The merchant who lives in your app 4 hours a day wants more data per pixel, not more whitespace.
**Inside Polaris:** Polaris spacing tokens default to spacious (gap `400` = 24px). For power-user surfaces, drop to `200` or `300`. `IndexTable` is denser than `ResourceList`. Use `Text variant="bodySm"` (13px) for table cells. Polaris allows this — just stay consistent within a surface.
### 3. Keyboard-First
Linear is famous for: `C` to create, `/` to search, `Cmd+K` to command palette, `?` to show all shortcuts. Every action is reachable without the mouse. Superhuman built a $30/month email business on this principle alone.
**Inside Polaris:** Polaris/App Bridge 4.x has no first-class shortcut API. You add shortcuts yourself with `react-hotkeys-hook` or `Mousetrap`, scoped to the embedded iframe. App Bridge does intercept `Cmd+S` for its save bar — respect that. Otherwise the keyboard is yours.
### 4. Opinionated Defaults
Notion ships with sensible defaults for almost everything. The new doc is named, the cover image is reasonable, the database has 3 useful columns. Compare to Jira: 47 fields, none filled in, you do all the work.
**Inside Polaris:** Pre-fill every form field with the best guess. Pick the most common option in dropdowns. Auto-detect the merchant's brand color from theme. Skip the "configure first" step entirely when possible. Polaris `TextField` accepts a `value` — use it.
### 5. No-Config Success Path
Cron, Linear, and Arc all open with you already at first value. No questionnaire. No empty setup wizard. PageFly's "What do you want to build?" picker is the Shopify-specific version of this — no blank canvas, ever.
**Inside Polaris:** Use the App Bridge install handshake to seed everything you need (OAuth grants scopes, you read the shop, you fetch a few products). The first screen is the product working, not a checklist of things to do.
### 6. Calm UI
Vercel and Linear are quiet. Almost monochrome. One accent color. Generous typography hierarchy. No drop shadows on every card. No animated gradients. No 8 different button styles. The visual noise floor is near-zero, so real content stands out.
**Inside Polaris:** Polaris is already calm — that's its strength. The mistake is OVER-decorating to "stand out" — gradient backgrounds, custom icons, full-bleed hero images. Resist. Polaris's restraint is a gift; lean in. Pick one accent (`--p-color-bg-fill-brand`), use it sparingly, and let the content speak.
---
## Speed: The "Feels Instant" Rule
### The Latency Budget
| Latency | User perception |
|---|---|
| 0–100ms | Feels instant (target this) |
| 100–300ms | Feels responsive |
| 300ms–1s | Noticeable delay |
| 1s+ | Lost the user |
Every interaction in your app should land under 100ms perceived latency. Real network latency from Shopify GraphQL is usually 200–800ms — so perceived speed comes from optimistic UI, not faster network.
### Optimistic UI in Remix
```tsx
import { useFetcher } from '@remix-run/react';
import { useState } from 'react';
export function ToggleSwitch({ id, initialEnabled }) {
const fetcher = useFetcher();
const [optimistic, setOptimistic] = useState(initialEnabled);
const enabled = fetcher.formData
? fetcher.formData.get('enabled') === 'true'
: optimistic;
const handleToggle = () => {
const next = !enabled;
setOptimistic(next);
fetcher.submit(
{ id, enabled: String(next) },
{ method: 'POST', action: '/api/toggle' }
);
};
return <Checkbox checked={enabled} onChange={handleToggle} />;
}
```
The checkbox flips instantly. The network request happens in the background. If it fails, you revert with a toast.
### Prefetch on Intent (Remix)
```tsx
import { Link } from '@remix-run/react';
<Link to="/products/123" prefetch="intent">
View product
</Link>
```
`prefetch="intent"` triggers the loader on hover/focus — by the time the merchant clicks, the page is already loaded. Use this on every nav link. It costs almost nothing and makes navigation feel teleportational.
### Skeleton Screens That Match Real Layout
Polaris ships `SkeletonBodyText`, `SkeletonDisplayText`, `SkeletonThumbnail`. Use them, but match the real layout dimensions. A skeleton that resizes when content loads is worse than a slightly slow content load — the layout shift is the jarring bit.
```tsx
{isLoading ? (
<SkeletonBodyText lines={3} />
) : (
<Text as="p">{data.description}</Text>
)}
```
### What NOT to Optimize
- Don't add loading spinners under 300ms — they make the app feel slower, not faster
- Don't animate everything — every animation over 200ms is friction
- Don't cache aggressively across shops — stale data is worse than slow data in B2B
---
## Keyboard Shortcuts Inside App Bridge
App Bridge 4.x reserves a small set of system shortcuts (Cmd+S for save bar, Escape for modals). Everything else is yours.
### Recommended Library: react-hotkeys-hook
```bash
npm install react-hotkeys-hook
```
```tsx
import { useHotkeys } from 'react-hotkeys-hook';
export function ProductsPage() {
const navigate = useNavigate();
const [paletteOpen, setPaletteOpen] = useState(false);
useHotkeys('mod+k', (e) => {
e.preventDefault();
setPaletteOpen(true);
});
useHotkeys('c', () => {
navigate('/products/new');
}, { enableOnFormTags: false });
useHotkeys('/', (e) => {
e.preventDefault();
document.getElementById('search-input')?.focus();
});
useHotkeys('shift+?', () => {
setShortcutsHelpOpen(true);
});
return <Page>...</Page>;
}
```
`mod+k` translates to Cmd+K on Mac, Ctrl+K on Windows/Linux — never hardcode `meta` or `ctrl`.
### Suggested Shortcut Set (Linear-Style)
| Key | Action |
|---|---|
| `Cmd+K` | Open command palette |
| `/` | Focus search |
| `C` | Create new (primary entity for current page) |
| `G` then `D` | Go to Dashboard |
| `G` then `P` | Go to Products |
| `G` then `O` | Go to Orders |
| `E` | Edit selected row |
| `Shift+?` | Show shortcuts help |
| `Esc` | Close modal/palette |
### Accessibility Considerations
1. **Never bind unmodified letters globally without checking focus.** Use `enableOnFormTags: false` so `C` doesn't fire while the merchant is typing a product name.
2. **Always provide a visible alternative.** Every shortcut must map to a visible button or menu item. A keyboard-only feature is an accessibility failure.
3. **Show the shortcut next to the button.** Use Polaris `KeyboardKey` component or a small `<kbd>` tag: `Save ⌘S`.
4. **Don't override system shortcuts.** Cmd+T, Cmd+W, Cmd+R, Cmd+L belong to the browser. Cmd+S belongs to App Bridge.
5. **Respect `prefers-reduced-motion`.** If the user has reduced motion on, skip the palette open animation.
### The "?" Help Dialog
Modern apps treat `?` as "show all shortcuts." Build a Polaris `Modal` listing every shortcut, grouped by section. Open it on `Shift+?`. Make it the discovery surface for everything keyboard.
```tsx
<Modal open={helpOpen} onClose={() => setHelpOpen(false)} title="Keyboard shortcuts">
<Modal.Section>
<BlockStack gap="400">
<Text variant="headingSm">Navigation</Text>
<InlineStack gap="400">
<Text>Go to Dashboard</Text>
<kbd>G</kbd> <kbd>D</kbd>
</InlineStack>
...
</BlockStack>
</Modal.Section>
</Modal>
```
---
## Command Palette Inside Polaris
The command palette is the single highest-leverage modern UX pattern. It collapses navigation, search, and actions into one keyboard-driven surface.
### Option A: Polaris Modal + Combobox (Quick, BFS-Safe)
The easiest implementation uses Polaris primitives directly — `Modal` as the container, `Combobox` as the search-with-results.
```tsx
import { Modal, Combobox, Listbox, Icon } from '@shopify/polaris';
import { SearchIcon } from '@shopify/polaris-icons';
export function CommandPalette({ open, onClose }) {
const [query, setQuery] = useState('');
const commands = [
{ id: 'nav-dashboard', label: 'Go to Dashboard', shortcut: 'G D', action: () => navigate('/') },
{ id: 'nav-products', label: 'Go to Products', shortcut: 'G P', action: () => navigate('/products') },
{ id: 'create-product', label: 'Create product', shortcut: 'C', action: () => navigate('/products/new') },
{ id: 'create-discount', label: 'Create discount', action: () => navigate('/discounts/new') },
{ id: 'search-orders', label: 'Search orders', action: () => navigate('/orders?focus=search') },
];
const filtered = commands.filter(c =>
c.label.toLowerCase().includes(query.toLowerCase())
);
return (
<Modal open={open} onClose={onClose} title="" small>
<Modal.Section>
<Combobox
activator={
<Combobox.TextField
prefix={<Icon source={SearchIcon} />}
onChange={setQuery}
value={query}
placeholder="Type a command or search..."
autoComplete="off"
autoFocus
/>
}
>
<Listbox onSelect={(id) => {
const cmd = commands.find(c => c.id === id);
cmd?.action();
onClose();
}}>
{filtered.map(cmd => (
<Listbox.Option key={cmd.id} value={cmd.id}>
{cmd.label}{cmd.shortcut && ` — ${cmd.shortcut}`}
</Listbox.Option>
))}
</Listbox>
</Combobox>
</Modal.Section>
</Modal>
);
}
```
This is good. Not as fast as a real command palette (Modal has open animation, Combobox has its own focus model), but it ships in an afternoon and stays inside Polaris.
### Option B: Custom Palette With cmdk (Linear-Tier)
For a Linear/Raycast-feel palette, use [cmdk](https://cmdk.paco.me/) by Paco Coursey — the same library powering Linear, Vercel, and Raycast palettes. Headless, accessible, fuzzy search built in.
```bash
npm install cmdk
```
```tsx
import { Command } from 'cmdk';
import '@shopify/polaris/build/esm/styles.css';
export function FastPalette({ open, onClose }) {
return (
<Command.Dialog open={open} onOpenChange={onClose} label="Command palette">
<Command.Input placeholder="Type a command..." />
<Command.List>
<Command.Empty>No results found.</Command.Empty>
<Command.Group heading="Navigation">
<Command.Item onSelect={() => navigate('/products')}>
Products
<kbd>G P</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Actions">
<Command.Item onSelect={() => navigate('/products/new')}>
Create product
<kbd>C</kbd>
</Command.Item>
</Command.Group>
</Command.List>
</Command.Dialog>
);
}
```
You must then style cmdk to match Polaris — use Polaris tokens (`var(--p-color-bg-surface)`, `var(--p-color-text)`, `var(--p-border-radius-200)`) for the wrapper, input, items. The result feels like Linear AND looks like Shopify.
### Palette Rules
1. **Open from Cmd+K, close from Esc.** No exceptions.
2. **Fuzzy match, not exact.** "crt prd" should find "Create product." cmdk does this natively.
3. **Group commands by section.** Navigation, Actions, Search results. Linear does this; Notion does this.
4. **Show shortcuts next to each command.** Teaches users the keybinds passively.
5. **Recents first when query is empty.** Surface the 3 most-used commands at the top of an empty palette.
6. **Keep it under 8 visible items.** More is overwhelming.
7. **No animation on open.** Or under 100ms fade-in. Anything slower breaks the speed feel.
---
## Density vs Spacious
A common mistake: assume Polaris's default spacious feel suits every surface. It doesn't. Pick density per surface based on the merchant's relationship with that surface.
### Use Spacious (gap 400+, padding 400+, bodyMd text) When:
- The merchant visits this surface rarely (settings, billing, account)
- This is an onboarding or first-time-use surface
- The merchant is making an irreversible decision (delete, upgrade, archive)
- Mobile-first surface where touch targets need 44px+
- Marketing-feeling surfaces (welcome, what's new)
### Use Dense (gap 200, padding 200, bodySm text, IndexTable not ResourceList) When:
- The merchant lives here daily (dashboard, orders list, products list)
- This is a power-user view with bulk actions
- The user is comparing many rows or columns
- Desktop-only or desktop-primary surface (most embedded admin apps)
- The merchant has expressed they want "more on screen" (common in 1-star reviews)
### Spacious Default — Reference
```tsx
<Page title="Settings">
<Layout>
<Layout.Section>
<BlockStack gap="400">
<Card>
<BlockStack gap="400">
<Text variant="headingMd">Brand</Text>
<TextField label="Store name" />
</BlockStack>
</Card>
</BlockStack>
</Layout.Section>
</Layout>
</Page>
```
### Dense Power-User — Reference
```tsx
<Page title="Orders" fullWidth>
<Card padding="200">
<IndexTable
condensed
resourceName={{ singular: 'order', plural: 'orders' }}
itemCount={orders.length}
headings={[
{ title: 'Order' },
{ title: 'Date' },
{ title: 'Customer' },
{ title: 'Total' },
{ title: 'Status' },
]}
>
{orders.map(order => (
<IndexTable.Row key={order.id} id={order.id}>
<IndexTable.Cell>
<Text variant="bodySm" fontWeight="medium">{order.name}</Text>
</IndexTable.Cell>
<IndexTable.Cell>
<Text variant="bodySm" tone="subdued">{order.date}</Text>
</IndexTable.Cell>
...
</IndexTable.Row>
))}
</IndexTable>
</Card>
</Page>
```
`IndexTable` with `condensed` + `padding="200"` + `bodySm` cells gets you ~40% more rows on screen vs default Polaris.
---
## Brand Expression Within Polaris
Polaris allows brand expression in a few sanctioned ways. Use these; don't go further or you risk BFS rejection.
### Sanctioned Customization
1. **Override `--p-color-bg-fill-brand` and friends.** Polaris exposes brand color tokens as CSS variables. Override at the app root.
```css
:root {
--p-color-bg-fill-brand: #6E56CF;
--p-color-bg-fill-brand-hover: #7C66D9;
--p-color-bg-fill-brand-active: #5A45B5;
--p-color-text-brand-on-bg-fill: #FFFFFF;
}
```
This recolors primary buttons, selected states, and brand accents while leaving everything else Polaris-default. Safe, tasteful, BFS-compliant.
2. **Custom logo / icon in title bar.** Use App Bridge `<ui-title-bar>` slot for a small brand mark next to the page title.
3. **Branded empty states.** Polaris `EmptyState` accepts a custom `image` prop. Use it for a single tasteful illustration per surface.
4. **One accent color, used sparingly.** Pick one accent, apply it to maybe 3 places: primary button, active nav item, brand mark. Don't paint everything.
### Off-Limits Customization (Breaks BFS)
- Replacing Polaris typography with a custom font family
- Changing border radius globally (Polaris has a tight radius system — don't redefine `--p-border-radius-*`)
- Custom Button components that don't match Polaris button anatomy
- Custom Card components with gradients, shadows, or borders not in the Polaris palette
- Dark mode that Polaris hasn't sanctioned
- Custom modal/dialog primitives (use `<ui-modal>` from App Bridge or Polaris `Modal`)
- Replacing IndexTable with a custom table component
### The Rule of Thumb
**Override tokens, not components.** Polaris is fine with you re-coloring a button. Polaris is not fine with you replacing the button. If you find yourself building a "FancyButton" wrapper, you're crossing the line. If you find yourself adding a CSS variable override at the root, you're inside the lines.
---
## Micro-Interactions Polaris Allows
Modern apps feel alive because of small motion details — a hover lift, a focus ring, a check animation. Polaris ships some of these and tolerates others.
### Built Into Polaris
- Button hover (subtle bg color shift, ~150ms)
- Focus ring on Tab navigation (a11y mandatory — never disable)
- Modal fade-in / scale-in
- Toast slide-up
- Loading spinner on Button (when `loading` prop is true)
### Acceptable Custom Micro-Interactions
```css
/* Subtle hover lift on cards in a grid */
.dashboard-card {
transition: transform 150ms ease, box-shadow 150ms ease;
}
.dashboard-card:hover {
transform: translateY(-1px);
box-shadow: var(--p-shadow-200);
}
/* Number count-up animation for stats (use Framer Motion or react-spring) */
/* OK if the duration is < 600ms and respects prefers-reduced-motion */
/* Optimistic checkmark fade-in on save success */
.save-checkmark {
animation: fadeIn 200ms ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
```
### Always Honor `prefers-reduced-motion`
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
### Anti-Pattern: Animation Overload
Don't animate everything. If three elements animate at once on page load, the app feels noisy, not modern. Linear has near-zero motion. Notion has near-zero motion. Vercel has near-zero motion. Restraint signals taste.
---
## Opinionated Defaults > Infinite Settings
The settings page is where SaaS apps go to die. Every config option is a decision the merchant didn't ask to make. The Linear/Notion answer: ship with the best default, surface a setting only if you can prove it matters.
### Heuristic: Earn the Setting
Before adding a setting, ask:
1. **Does this setting cause a 1-star review when it's wrong?** If no, don't add it.
2. **Will more than 20% of merchants change the default?** If no, don't add it — ship the default and let the rare exceptions email support.
3. **Can you auto-detect the right value?** Auto-detect beats asking. (Brand color from theme, currency from shop, timezone from shop.)
4. **Can you defer the question to when it actually matters?** Don't ask at install — ask at first use.
5. **Could two settings be one?** "Email frequency: daily/weekly" + "Email enabled: yes/no" should collapse to "Email frequency: off/daily/weekly."
### Sensible Default Examples
| Setting | Bad default | Good default |
|---|---|---|
| Email sender | empty, requires merchant input | shop owner's name + shop name |
| Brand color | #000000 | auto-detected from theme primary color |
| Timezone | UTC | shop's configured timezone |
| Currency | USD | shop's configured currency |
| Notification frequency | "Please choose" | "Daily digest" |
| First widget placement | "Choose where" | auto-placed via theme app extension |
### The Settings Page Anatomy
If you must have a settings page:
- Group settings into 3–5 sections max
- Each section ≤ 5 settings
- The most-changed setting at the top of each section
- A "Reset to defaults" button (Polaris `Button variant="tertiary"`)
- A search input if you have > 15 total settings (and if you have > 15, you have too many)
---
## 15 Modern UX Patterns to Copy
Each pattern lists the source app, what it does, and the Polaris/App Bridge implementation.
### 1. Command Palette (Cmd+K)
**Source:** Linear, Vercel, Raycast, Superhuman, Notion
**Pattern:** Cmd+K opens a fuzzy-searchable list of every command and navigation target in the app.
**Polaris implementation:** Polaris `Modal` + `Combobox` for a quick version, or `cmdk` library styled with Polaris tokens for the Linear-tier version. See "Command Palette Inside Polaris" above.
### 2. Optimistic UI on Toggles
**Source:** Linear (issue status), Vercel (deploy toggles), Notion (checkboxes)
**Pattern:** Click flips state instantly, network request runs in background, revert on failure.
**Polaris implementation:** `useFetcher` from Remix + local state. See "Optimistic UI in Remix" above.
### 3. Prefetch on Hover
**Source:** Vercel, Linear, Arc
**Pattern:** Hover a link, the destination preloads, click feels teleportational.
**Polaris implementation:** `<Link prefetch="intent">` from Remix. Apply to every nav item.
### 4. Slash Menu for Inline Actions
**Source:** Notion, Linear (in comments)
**Pattern:** Type `/` in any text field to insert blocks, mentions, or quick actions.
**Polaris implementation:** Polaris `TextField` with a custom listener for `/`, opening a `Popover` with `Listbox` of actions.
### 5. Inline Edit in Tables
**Source:** Airtable, Notion, Linear
**Pattern:** Click a table cell to edit it in place — no modal, no detail page.
**Polaris implementation:** `IndexTable.Cell` with a `TextField` that activates on click, blur saves, Esc cancels.
### 6. Breadcrumb-Driven Hierarchy
**Source:** Notion, Linear (project > issue path)
**Pattern:** Breadcrumb shows nested path, each segment is clickable, last segment is current page.
**Polaris implementation:** Polaris `Page` accepts `backAction` for one-level back; for multi-level, use App Bridge `<ui-title-bar>` with custom breadcrumb in the title slot.
### 7. Quiet Empty States
**Source:** Linear, Cron
**Pattern:** Empty state is a single sentence + one CTA. No illustrations, no marketing copy.
**Polaris implementation:** Polaris `EmptyState` with `image` prop set to a minimal SVG (or none), `heading` one short sentence, `action` one button.
### 8. Persistent Sidebar Search
**Source:** Notion (top of sidebar), Linear (Cmd+/)
**Pattern:** A search input persistently visible in the nav — not buried behind an icon.
**Polaris implementation:** App Bridge `<ui-nav-menu>` doesn't support inline search natively. Add it just below the nav using Polaris `TextField` with `prefix={<Icon source={SearchIcon} />}`.
### 9. Right-Side Detail Panel
**Source:** Linear (click an issue, panel slides in from right), Notion (page peek)
**Pattern:** Click a row, a panel slides in from the right with full detail. No navigation away.
**Polaris implementation:** App Bridge does not ship a slide-over. Build with Polaris `Modal` set to `large` size and right-aligned via custom CSS, OR build a custom drawer with proper focus trap and `aria-modal`.
### 10. Status Badges With Meaning
**Source:** Linear (priority colors), Vercel (deploy status)
**Pattern:** Tiny colored dot + label. Consistent color = consistent meaning across the app.
**Polaris implementation:** Polaris `Badge` with `tone` prop (`success`, `warning`, `critical`, `info`, `attention`). Use the same tone for the same meaning everywhere.
### 11. Keyboard Sequence Shortcuts
**Source:** Linear (`G` then `D` = go to dashboard, Vim-style)
**Pattern:** Two-key sequence shortcuts free up single keys. `G` opens a "go to" prompt; the next key picks the destination.
**Polaris implementation:** `react-hotkeys-hook` supports sequences: `useHotkeys('g>d', ...)`.
### 12. Live-Updating Numbers
**Source:** Linear (issue counts), Vercel (deploy logs)
**Pattern:** Numbers in the UI update in real time as data changes — no refresh button.
**Polaris implementation:** Remix `useRevalidator` + polling, or `useFetcher` with interval, or websockets. Polaris doesn't fight you; the data layer does the work.
### 13. Single Accent Color
**Source:** Vercel (black + one purple), Linear (subtle indigo)
**Pattern:** One accent, used in ≤ 3 places. Everything else is grayscale.
**Polaris implementation:** Override `--p-color-bg-fill-brand` to your accent. Don't recolor anything else.
### 14. Confirm-by-Type for Destructive Actions
**Source:** Vercel, GitHub
**Pattern:** Deleting a project? Type the project name to confirm. No accidental destruction.
**Polaris implementation:** Polaris `Modal` with `TextField` inside, primaryAction `disabled` until the typed value matches.
### 15. Toast Stack With Undo
**Source:** Linear (every action has undo), Notion
**Pattern:** Action succeeds, toast appears with "Undo" button, undo reverses the change for 5 seconds.
**Polaris implementation:** Use App Bridge `shopify.toast()` with a custom action button, OR Polaris `Toast` with an `action` prop pointing to your undo handler.
---
## Anti-Patterns (Things That Break BFS or the Modern Feel)
### 1. Custom Design System on Top of Polaris
You build `MyButton`, `MyCard`, `MyModal`, all wrapping Polaris with "improvements." Six months in, you've drifted from Polaris and the BFS reviewer flags inconsistency with Shopify Admin. **Don't.** Override tokens, not components.
### 2. Replacing Polaris Typography
Custom fonts feel like "branding" but break the calm visual hierarchy Polaris enforces. Polaris uses Shopify's `Inter`-based stack; matching merchant typography expectations across the Admin is part of the BFS criteria.
### 3. Animation Overload
Hero on page load, cards fade in staggered, sidebar slides, button pulses on hover, success checkmark spins. By the time the merchant has seen one screen they're motion-sick. **Pick one or two micro-interactions per surface, max.**
### 4. Dark Mode Without Polaris's Blessing
Polaris dark mode is still partial as of v12. Custom dark mode breaks token contracts. Wait for Polaris to ship full dark mode support.
### 5. Custom Modal/Dialog Primitives
Building a slide-over drawer with your own focus trap, escape handler, and backdrop is harder than it looks. Use App Bridge `<ui-modal>` or Polaris `Modal`. The custom version always misses an a11y case.
### 6. Hiding Pricing Tier Behind a Custom UI
Polaris has `Banner` for upgrade prompts; App Bridge has `<ui-modal>`. Don't build a custom paywall component — it'll feel un-Shopify.
### 7. Infinite Settings
40 settings on a page means you didn't make decisions. The merchant pays for your indecision in cognitive load. Cut.
### 8. Confirmation Modals for Reversible Actions
Linear never asks "Are you sure?" for things you can undo. Modern apps trust users and provide undo. Confirm only for truly destructive actions.
### 9. Loading Spinners Under 300ms
Adding a spinner for a 150ms request makes the request feel slower, not faster. Use optimistic UI or no indicator at all.
### 10. Breaking Cmd+S / Esc / Tab Order
App Bridge owns Cmd+S. Polaris Modal owns Esc. Tab order is governed by DOM order. Don't fight these.
---
## Decision Tree
Use this when deciding whether to add a "modern feel" feature to your app.
```
Question: Should I add this UX pattern to my Shopify app?
├─ Does Polaris ship a primitive for it?
│ ├─ Yes → Use the Polaris primitive. Stop. ✓
│ └─ No → continue
│
├─ Is the pattern a keyboard shortcut?
│ ├─ Yes → Add via react-hotkeys-hook. Ensure visible alternative.
│ │ Add to the "?" help dialog. Stop. ✓
│ └─ No → continue
│
├─ Is the pattern a command palette?
│ ├─ Yes → Start with Polaris Modal + Combobox.
│ │ Upgrade to cmdk only if you need fuzzy/grouped/recents. Stop. ✓
│ └─ No → continue
│
├─ Is the pattern a micro-interaction (hover, focus, transition)?
│ ├─ Yes → ≤ 200ms duration? Respects prefers-reduced-motion?
│ │ ├─ Yes → Ship it. Stop. ✓
│ │ └─ No → Don't ship.
│ └─ No → continue
│
├─ Is the pattern a brand color / accent override?
│ ├─ Yes → Override the relevant --p-color-* token at the root.
│ │ Don't override component-level styles. Stop. ✓
│ └─ No → continue
│
├─ Is the pattern a custom layout / typography / component?
│ ├─ Yes → STOP. You're outside Polaris. BFS risk.
│ │ Re-frame the problem: can you achieve this with Polaris primitives?
│ │ If truly impossible, document why and proceed cautiously.
│ └─ No → continue
│
└─ Is the pattern an opinionated default replacing a setting?
├─ Yes → Ship it. The merchant gets less choice, more speed. ✓
└─ No → re-read this skill. The answer is probably "ship a default."
```
---
## Checklist: Modern Shopify App Feel
Before shipping, verify:
- [ ] App Bridge 4.x web components used for save bar, modal, toast (not custom)
- [ ] Polaris 12.x for all UI primitives (Button, Card, TextField, IndexTable, etc.)
- [ ] Cmd+K opens a command palette
- [ ] `/` focuses the primary search input on every list page
- [ ] `C` creates a new entity on every list page
- [ ] `?` opens a keyboard shortcuts help dialog
- [ ] Every primary action button shows its shortcut next to the label
- [ ] Every nav `<Link>` has `prefetch="intent"`
- [ ] Optimistic UI on every toggle / status change
- [ ] No loading spinners shown for requests under 300ms
- [ ] Skeleton screens match real layout dimensions (no layout shift)
- [ ] Power-user surfaces use IndexTable with `condensed` + `padding="200"`
- [ ] Settings surfaces use spacious defaults (gap 400, bodyMd)
- [ ] Brand accent overrides `--p-color-bg-fill-brand` only
- [ ] No custom font families (use Polaris/Shopify default)
- [ ] All micro-interactions ≤ 200ms and honor `prefers-reduced-motion`
- [ ] Every destructive action is undoable OR uses confirm-by-type
- [ ] Toast with "Undo" appears after every reversible action
- [ ] Settings page has ≤ 3 sections, ≤ 5 settings per section
- [ ] First-run experience has no questionnaire — app works immediately
- [ ] Empty states are one sentence + one CTA, no illustrations beyond a single tasteful SVG
- [ ] One accent color used in ≤ 3 places across the app
If every box is checked, your Shopify app feels like Linear inside Polaris. Which is the goal.
ux-onboarding27.3 KB
---
name: ux-onboarding
description: "Use when designing the first-run/onboarding experience of a Shopify embedded app. Covers the first-30-second rule, required-vs-optional setup, checklist vs wizard vs deferred-config vs sample-data, time-to-value targets, the 'aha moment' pattern, personalization using shop's currency/language/niche, activation events to instrument, and 15 onboarding pattern recipes built on Polaris components. Triggers: 'shopify app onboarding', 'first run', 'first time experience', 'app activation', 'onboarding checklist', 'wizard', 'empty state first install', 'time to value', 'aha moment', 'shopify app welcome screen', 'guide merchant'."
---
# Shopify Embedded App: First-Run Onboarding UX
Onboarding is the single highest-leverage screen in your app. A merchant who never reaches "aha" within their first session uninstalls inside 7 days at rates of 40-60% across the App Store. The goal of this skill is to get every merchant to one observable, on-store value moment in under 5 minutes, with zero theme code edits and zero credit-card friction.
This skill is opinionated. It is built on (1) the Shopify Built for Shopify requirements, (2) the Polaris onboarding guidance, (3) a teardown of 6 top-grossing apps (Klaviyo, Gorgias, Judge.me, Loox, Vitals, PageFly).
---
## 1. When to use
Pull this skill in when you are:
- Designing the welcome screen / first-run flow of a brand new Shopify embedded app
- Reworking an existing app's activation funnel because installs are not converting to paid
- Adding a "setup guide" or "getting started checklist" to an existing dashboard
- Deciding whether to gate the app behind a wizard or drop merchants into a tile-grid
- Pursuing the Built for Shopify badge (which has explicit onboarding requirements)
- Writing the empty state for the merchant's first visit before any data exists
- Auditing your own app and asking "why is our 7-day retention so bad"
Skip this skill if you are working on a public storefront (Hydrogen/Liquid) - that is a different audience and a different onboarding model.
---
## 2. The "first 30 seconds" rule
When a merchant clicks Install on the App Store and approves your scopes, your app loads inside an iframe in the Shopify admin. The first thing they see is yours to design. Built for Shopify reviewers grade this screen. Real merchants decide whether to keep your app based on what they see in roughly 30 seconds.
**What must be visible in the first 30 seconds:**
1. **A clear app title** - use the App Bridge `<ui-title-bar>` web component or Polaris `<Page title>`. The merchant should see your app name and nothing else competing for it.
2. **A one-sentence promise** - the same outcome verb you led with on the App Store listing ("Convert more shoppers", "Replace 10 apps", "Send your first review request in 60 seconds"). Use Polaris `<Text variant="bodyLg" tone="subdued">`.
3. **One primary CTA** - a single Polaris `<Button variant="primary">` that starts the activation path. Never two competing primary actions on the welcome screen.
4. **Proof the app is connected** - a small `<Badge tone="success">Connected to {shop}</Badge>` confirms OAuth worked. Removes the merchant's "did it install correctly" anxiety.
5. **A skip / dismiss option** - per Built for Shopify guidance, onboarding must be dismissible. A "Skip for now" Polaris `<Button variant="plain">` in the top-right.
**What must NOT be in the first 30 seconds:**
- A video that auto-plays
- A modal popup ("Welcome!" interrupting the welcome screen is hostile)
- A form with more than 3 fields
- Any request for credit card, billing, or upgrade
- A request for OAuth scopes you didn't ask for at install
- An external link that opens a new tab (the iframe context dies)
**Iframe-aware loading:**
The embedded app loads inside `https://admin.shopify.com/store/{shop}/apps/{your-app}`. The iframe has constraints: third-party cookies are not guaranteed, the URL has the `shop`, `host`, and `embedded` query params attached. Use App Bridge 4.x via the CDN script tag - do not try to navigate the parent window. For any "open in new tab" link, use `shopify.navigate({ to: '...', newContext: true })` rather than a raw `<a target="_blank">`.
**Skeleton, not spinner:**
If the first paint is gated on a backend call (e.g., fetching the shop's products to populate a picker), render a Polaris `<SkeletonPage>` with `<SkeletonBodyText>` and `<SkeletonDisplayText>` immediately. A spinner says "wait", a skeleton says "your screen is here and almost ready" - the perceived speed gap is significant.
---
## 3. Required vs optional steps
The biggest onboarding mistake is treating every nice-to-have as a required step. Built for Shopify explicitly caps onboarding at **5 steps**. Most apps need 1-3 required steps and should defer the rest.
**Gate (require) only the steps without which the app physically cannot deliver value.** Everything else is optional or deferred.
| Step type | Example | Treat as |
|---|---|---|
| App can't function without this | Connect at least one inbox (Gorgias), Pick a primary product (upsell apps) | **Required** |
| App works but produces poor results without this | Brand color, sender email, business industry | **Optional with smart default** |
| Needed at the moment of action, not install | Email DNS/sender records (Klaviyo), Facebook Pixel (PageFly) | **Deferred** - ask at point of need |
| Helps your analytics, not the merchant | Industry, monthly revenue, current ESP | **Optional, after first value** |
| Risky / scary asks | Credit card, billing, additional scopes | **Never at install** |
**Required step checklist:**
- Each required step has a clear "why we need this" tooltip
- Each required step has a sensible default if any default is possible (e.g., pre-fill sender email from `shop.email`)
- Required steps total no more than 3 fields per screen, no more than 5 steps total
- Each step shows a `<ProgressBar progress={n/total * 100} />` so merchants see they're not in a loop
**Bad: requiring a "How big is your store?" multi-choice before the app does anything.** This is information gathering for you, friction for them.
**Good: deferring the same question to a tooltip on the dashboard after first value, framed as "want better recommendations?"**
---
## 4. The four onboarding patterns
There are four legitimate onboarding patterns. Pick exactly one. Mixing patterns produces incoherent UX.
### A. Checklist (the default - use this 60% of the time)
A persistent card with 3-5 task items, each with a checkbox state, that lives on the dashboard until 100% complete. The merchant can do tasks in any order, dismiss the card, and return to it. Used by Klaviyo (5 tasks), Gorgias (5 tasks), Shopify's own admin home.
**When it fits:**
- App has 3-5 setup tasks that are independent of each other
- Merchant might want to do tasks across multiple sessions
- Some tasks are required, others are optional-but-recommended
**Polaris components:**
- `<Card>` as the container
- `<BlockStack gap="400">` for vertical layout
- `<InlineStack>` for each task row (icon + label + status)
- `<Icon source={CircleTickIcon} tone="success">` for completed, `<Icon source={CircleIcon} tone="subdued">` for pending
- `<ProgressBar progress={pctComplete}>` at the top of the card
- `<Button variant="plain">Dismiss</Button>` in the card header
**Code pattern (sketch):**
```jsx
<Card>
<BlockStack gap="400">
<InlineStack align="space-between">
<Text variant="headingMd">Get started ({completed}/{total})</Text>
<Button variant="plain" onClick={dismissChecklist}>Skip for now</Button>
</InlineStack>
<ProgressBar progress={(completed / total) * 100} />
{tasks.map(task => (
<InlineStack key={task.id} gap="200" align="space-between" blockAlign="center">
<InlineStack gap="200">
<Icon source={task.done ? CircleTickIcon : CircleIcon}
tone={task.done ? "success" : "subdued"} />
<Text>{task.label}</Text>
</InlineStack>
{!task.done && (
<Button onClick={task.action}>{task.cta}</Button>
)}
</InlineStack>
))}
</BlockStack>
</Card>
```
### B. Wizard (use sparingly, ~15% of apps)
A linear, multi-step flow where each step blocks until completed. Step 1 of N. Used for apps where the steps must be done in order and where the merchant can't usefully see the dashboard without all data.
**When it fits:**
- Steps have hard dependencies (Step 2 needs answer from Step 1)
- App is unusable until all required data is collected (e.g., a customs-paperwork generator that needs shipping origin, destination, product HS codes before any UI works)
- The total flow is genuinely under 5 steps and under 3 minutes
**When it does NOT fit:**
- You can show useful preview even with partial data
- Merchant wants to explore before committing to setup
- More than 5 steps
**Polaris components:**
- `<Page>` wrapper with `<Page.Header title="Step 2 of 4">`
- `<Card>` containing the step's form
- `<FormLayout>` for fields
- `<InlineStack align="space-between">` footer with `<Button>Back</Button>` and `<Button variant="primary">Continue</Button>`
- `<ProgressBar progress={stepNum / totalSteps * 100} />` at the top
**Critical wizard rule:** A "Skip" button on every step that lets the merchant exit to the dashboard. Built for Shopify requires dismissibility. A wizard that traps users is a Built for Shopify rejection.
### C. Deferred config (~15% of apps)
The merchant lands directly on a working dashboard. Setup tasks are surfaced only at the moment the merchant tries to use a feature that needs them. The app appears to "just work" because all defaults are sensible.
**When it fits:**
- App has many independent features (Vitals model - 40+ sub-features in a tile grid)
- Merchant exploration is the natural first action
- Defaults are genuinely sensible for the median merchant
**Polaris components:**
- Direct rendering of dashboard with `<EmptyState>` cards for empty zones
- `<Banner tone="info">` only when something specific needs setup, e.g., "Connect Klaviyo to enable email triggers" - shown on the feature that needs it
- `<Modal>` triggered when the merchant clicks a feature that requires setup, surfacing the config inline
### D. Sample data (~10% of apps, especially analytics/dashboards)
For apps whose value is visual (charts, recommendations, lists), show the dashboard pre-populated with realistic sample data on first load. The merchant sees what success looks like before they have any real data. A clear "This is sample data - connect your store to see your real numbers" banner makes the swap legible.
**When it fits:**
- App's value is shown through visualizations or lists
- New stores have zero data and would see an empty chart otherwise
- The shape of the data is the product (analytics, recommendations, attribution)
**Polaris components:**
- `<Banner tone="info" onDismiss={...}>` at the top: "You're seeing sample data. Real data appears after your first order."
- Normal dashboard rendering below, with sample values
- A subtle `<Badge tone="info">Sample</Badge>` next to each chart title
**Sample data discipline:**
- Use believable, realistic numbers (not "$1,000,000 revenue" - use $4,247)
- Make sample data match the shop's currency and timezone from the start
- Replace seamlessly with real data the moment it exists - no "switch to real data" button needed
---
## 5. Time-to-value (TTV) targets by app category
Time to value = seconds between app install and the merchant seeing one tangible, on-store or on-dashboard outcome that maps to their reason for installing. Different app categories have different physically achievable TTV ceilings.
| App category | TTV target | Aha moment example |
|---|---|---|
| Reviews | < 7 days for first photo review (auto-send happens after first fulfilled order) | First 5-star photo review goes live on product page |
| Email marketing | < 24h for welcome flow live | Welcome email sent to first new subscriber |
| Page builder | < 15 min to first published page | Merchant pastes their custom page URL into browser and sees it live |
| Helpdesk | < 1h to first inbound ticket centralized | First customer email lands in the helpdesk inbox |
| Upsell / CRO | < 10 min to first widget visible on storefront | Sticky add-to-cart appears on product page |
| Bundles / discounts | < 5 min to first bundle live | Bundle visible on product page |
| Analytics / dashboards | < 60 seconds to first dashboard view | Sample data renders, real data fills in over the next hour |
| Inventory / fulfillment | < 30 min to first sync complete | Inventory levels match storefront |
| Loyalty | < 1 day to first earning event | A customer earns their first point |
| Subscriptions | < 1 hour to first subscription product configured | A product is enabled for subscription on the PDP |
**Rule of thumb:** Whatever your category's TTV target is, your onboarding flow should clear the way to that moment - not gate it behind information collection.
**Instrument TTV as a metric.** For every install, log timestamp_install and timestamp_first_value_event. Aggregate the median and the 75th percentile. Watch them trend after every onboarding change. If median TTV goes up, your last change was a regression.
---
## 6. The "aha moment"
Define one specific, observable event that captures "this app delivered what I came for." Then engineer your onboarding to make that event happen as fast as humanly possible.
**Examples of well-defined aha moments:**
- Klaviyo: First abandoned-cart email attributed revenue dollar appears on dashboard
- Gorgias: First AI-resolved ticket banner appears
- Judge.me: First photo review goes live on a product page
- Loox: First photo review submission appears in moderation queue
- PageFly: First published page URL renders correctly in a browser
- Vitals: Three features enabled and visible on storefront within 5 minutes
**How to engineer toward the aha moment:**
1. Write the aha event as a single sentence with a verb and a subject. "Merchant sees their first review on their product page."
2. List every step the merchant must complete to reach that event. Cut steps until only the irreducible minimum remains.
3. Pre-fill or default every value you possibly can. Use `shop.email`, `shop.currency`, `shop.primary_domain`, `shop.country_code`, `shop.plan_display_name` from the Shopify GraphQL Admin API to skip questions.
4. Auto-trigger the event when possible. Judge.me doesn't ask the merchant to send a review email - it auto-sends 7 days after the first fulfilled order. The merchant takes zero actions and gets the aha event.
5. Push a notification the moment the aha event happens. Polaris `<Toast>` or App Bridge `shopify.toast.show()` works in-app. An email or Slack ping pulls the merchant back if they've closed the tab.
**Instrument:** fire a single analytics event called `aha_moment_reached` with the timestamp delta from install. This is your most important activation KPI.
---
## 7. Personalization at first-run
You get five free, useful values from the Shopify Admin GraphQL API immediately after OAuth. Use them.
**Query at install:**
```graphql
query ShopBootstrap {
shop {
id
name
email
currencyCode
primaryDomain { url host }
plan { displayName }
billingAddress { countryCodeV2 }
ianaTimezone
contactEmail
}
shopLocales { locale primary published }
}
```
**What to do with each value:**
| Value | First-run use |
|---|---|
| `shop.name` | Welcome screen: "Welcome to {App}, {shop.name}!" |
| `shop.email` / `shop.contactEmail` | Pre-fill sender email field - don't ask |
| `shop.currencyCode` | Format all dollar/euro/yen/INR examples in the merchant's currency from screen 1 |
| `shop.primaryDomain` | Show example URLs as `{primaryDomain}/products/example` rather than `your-store.com/products/example` |
| `shop.plan.displayName` | Hide upsells for features the merchant's Shopify plan doesn't support (e.g., don't pitch Shopify Markets features to a Basic merchant) |
| `shop.billingAddress.countryCodeV2` | Default tax/shipping/legal copy to the right jurisdiction. Show "Including GST" in India, "Including VAT" in EU, etc. |
| `shop.ianaTimezone` | All dates/times display in the merchant's timezone, not UTC. "Sent at 2:14 PM your time" |
| `shopLocales` | If the primary locale is not English, load the matching Polaris locale (`@shopify/polaris/locales/fr.json` etc.) and translate your own copy if you support it |
**Niche/category personalization (optional):**
Query the shop's first 50 products with `productType` and `tags`. Infer the niche (apparel, beauty, home goods, electronics, B2B). Use the inference to pick the best onboarding sample / template / recommendation. Loox does this to pick a default widget style; PageFly does this to filter the template gallery.
**Anti-pattern:** asking the merchant "What do you sell?" when their store already says it. The Shopify Admin API is your form-fill engine.
---
## 8. 15 onboarding recipes
Each recipe is a tested pattern with Polaris components and a concrete use case. Mix and match - most apps use 4-7 of these.
### Recipe 1: Welcome banner with a single CTA
A dismissible `<Banner tone="info">` at the top of the dashboard on first load. One sentence promise + one button. Disappears after dismissed once (persist state to your DB keyed by shop).
```jsx
<Banner tone="info" onDismiss={dismissWelcome}
action={{ content: 'Start setup', onAction: startSetup }}>
Welcome to {appName}, {shopName}. Set up takes about 3 minutes.
</Banner>
```
### Recipe 2: Persistent setup-checklist card
The "Recipe A" pattern from section 4. A card with 3-5 tasks, progress bar, dismissible. Klaviyo / Shopify admin home pattern.
### Recipe 3: Sample data toggle
When the dashboard renders for the first time, show realistic sample data with a `<Badge tone="info">Sample</Badge>` next to each metric. Auto-swap to real data the moment any exists.
### Recipe 4: "Skip for now" everywhere
Every onboarding step has a small `<Button variant="plain">Skip for now</Button>`. This is non-negotiable for Built for Shopify. Track skip rates per step - the step with the highest skip rate is the one to redesign or remove.
### Recipe 5: Contextual tooltip with Polaris Popover
Don't explain features upfront. Attach a `<Popover>` to the first usage of each feature. The merchant clicks the small `<Icon source={QuestionCircleIcon}>` next to a field and gets the explanation only if they want it.
```jsx
<Popover active={popoverActive} activator={
<Button variant="plain" onClick={togglePopover}>
<Icon source={QuestionCircleIcon} />
</Button>
} onClose={togglePopover}>
<Box padding="400">
<Text>Sender email is the From: address used on review request emails. Defaults to your Shopify account email.</Text>
</Box>
</Popover>
```
### Recipe 6: Picker-first empty state
Replace any blank canvas with a picker. "What do you want to build?" / "Pick a widget style" / "Choose a template". Use `<InlineGrid columns="3">` with `<Card>` tiles, each tile a choice. Loads a sensible default when clicked. Source: PageFly, Loox.
### Recipe 7: Sandbox / preview mode
Let the merchant try the app on a sample order or sample product before touching real data. A `<Banner tone="info">You're in preview mode</Banner>` makes the state legible. Useful for review apps, email apps, anything destructive.
### Recipe 8: Inline help links on every field
Each form field's `helpText` prop on Polaris `<TextField>` carries a one-line explanation. No separate help docs needed for the common case. Reserve `<Link url="...">Learn more</Link>` for advanced edge cases.
### Recipe 9: Auto-detect and confirm
Detect a value (theme, currency, niche, store size) and show the merchant your guess with a "yes / change" choice. "We detected your store is in INR. Show prices in INR? [Yes] [Use different currency]". Lower friction than asking, higher accuracy than assuming.
### Recipe 10: Theme app extension auto-install
For any storefront-visible widget, use the Theme App Extension (Online Store 2.0 app blocks) and surface a button labeled "Add to your theme" that opens the theme editor at the right block. No code editing required. Polaris `<Button>` linking to the theme editor's deep link URL.
### Recipe 11: Defer the scary ask
DNS records, SMTP setup, billing, additional OAuth scopes, payment methods - none of these belong at install. Ask at the moment the merchant tries to use the feature that needs them. Klaviyo defers DNS to first campaign send.
### Recipe 12: One-click migration from named competitors
If your category has incumbents, list each by name in a "Switching from another app?" card. CSV import, API import, screenshot upload - whatever it takes. Brings switching cost close to zero. Source: Judge.me, Loox.
### Recipe 13: First-day welcome email
Within 1 hour of install, send the merchant a short welcome email. Three lines max: one personal sentence, one link to the setup checklist deep-link, one reply-to address. This pulls back the 30-40% of merchants who close the tab right after install.
### Recipe 14: Dashboard-as-onboarding (deferred config)
Skip the welcome screen entirely. Land the merchant directly on the dashboard with `<EmptyState>` cards filling zones that need setup. Each `<EmptyState>` has a single action button. Source: Vitals.
### Recipe 15: The "aha metric" dashboard tile
Pick the one number you want the merchant to watch every day (deflection rate, attributed revenue, reviews collected this week, sticky-ATC adds today). Make it the biggest visual element on the dashboard with a Polaris `<Text variant="heading2xl">` value and a one-word label. This is the addictive surface that drives retention. Source: Gorgias, Klaviyo, Loox.
---
## 9. Activation events to instrument
You can't optimize what you don't measure. At minimum, instrument these events with timestamps and a shop ID:
| Event | When it fires |
|---|---|
| `app_installed` | OAuth completes |
| `welcome_screen_viewed` | First admin page loads |
| `onboarding_step_completed` | Each step in checklist / wizard, with step name |
| `onboarding_step_skipped` | Each "Skip for now" click, with step name |
| `onboarding_dismissed` | Whole onboarding flow dismissed |
| `feature_first_used` | Per major feature - merchant uses it for the first time |
| `aha_moment_reached` | Your single defined aha event |
| `theme_block_added` | Merchant adds your theme app extension block |
| `external_credential_connected` | E.g., connects Klaviyo / Meta / Google |
| `billing_charge_accepted` | Merchant approves a paid plan |
| `app_uninstalled` | App removed (Shopify webhook) |
**Derived metrics from these events:**
- Median TTV = median(timestamp(aha_moment_reached) - timestamp(app_installed))
- Activation rate D1, D7, D30 = % of installs that reached aha_moment_reached within 1/7/30 days
- Step skip rate by step = skips / (skips + completions)
- Step funnel = step1_completed > step2_completed > step3_completed
- Free-to-paid conversion = billing_charge_accepted / aha_moment_reached
Watch the step skip rate weekly. Any step over 30% skip rate is a candidate to remove, default, or defer.
---
## 10. Anti-patterns
Concrete patterns that look helpful but actively hurt activation. Don't ship these.
- **Auto-playing full-screen welcome video.** Merchants on slow connections see a loading spinner; merchants in coffee shops get audio they can't stop fast enough. If you have a video, make it a thumbnail with a play button, max 90 seconds.
- **Modal popup on first load.** Modals are interruptions. Use a Banner or a Card. Reserve Modal for explicit user actions ("Edit settings", "Delete confirmation").
- **Asking for credit card before any value.** Built for Shopify standard: no payment friction at install. Trial expirations are fine; payment-up-front is not.
- **Multi-step wizard with no skip button.** Built for Shopify rejection. Always-dismissible.
- **More than 5 steps total.** Built for Shopify explicit cap.
- **Asking the merchant what their currency / language / store name is.** You have all of this from the Admin API. Asking signals you didn't bother to integrate properly.
- **Onboarding that resets every visit.** Persist state in your DB keyed by shop. A merchant who completes Step 2 and comes back should see Step 3, not Step 1 again.
- **"Connect Facebook / Klaviyo / TikTok / Google" required at install.** Defer to the moment that integration is actually needed.
- **Generic "Welcome!" with no action.** A welcome screen with no CTA is a wasted screen. Every screen must have exactly one obvious next action.
- **Onboarding copy that brags about your features.** Merchants don't care that you have 40 features. They care about the one outcome they came for. Copy should be in the merchant's voice, not yours.
- **Spinner instead of skeleton screen.** Skeleton screens (Polaris `<SkeletonPage>`, `<SkeletonBodyText>`) feel ~30% faster than the equivalent spinner.
- **"Watch this video" as a step.** Videos are not steps; they're optional context. Don't gate the next button behind watching.
- **Hiding the dismiss button.** Make "Skip for now" visually obvious, not buried in a corner at low contrast.
- **Asking unrelated questions to gather analytics data.** "What size is your store" / "How did you hear about us" - put these in a settings page later or in a post-aha survey, never in install flow.
---
## 11. Decision tree: which pattern do I use?
Walk this tree top-down to pick your onboarding pattern.
**1. Does your app physically require specific configuration to function at all (e.g., must have an inbox / must have a primary product picked)?**
- Yes -> Continue to 2
- No -> Pattern: **Deferred config** (Recipe 14). Drop the merchant on the dashboard.
**2. Are the required configuration steps dependent on each other (Step 2 needs answer from Step 1)?**
- Yes, hard dependencies -> Pattern: **Wizard** (Section 4B). Keep it under 5 steps with a skip on every step.
- No, steps are independent -> Continue to 3
**3. Is the app's value visualized through charts/lists where empty state would look broken?**
- Yes -> Pattern: **Sample data** (Section 4D) + **Checklist** (Section 4A) overlaid. Show sample data while merchant works through a small checklist of setup tasks.
- No -> Pattern: **Checklist** (Section 4A) alone.
**4. Independent of the above, every flow gets these:**
- Personalization at first-run using `shop.*` (Section 7)
- Welcome banner with one CTA (Recipe 1)
- Skip-for-now on every step (Recipe 4)
- Aha metric tile on the dashboard (Recipe 15)
- Activation events instrumented (Section 9)
**5. Optional additions based on category:**
- Storefront-visible widget? Add Theme app extension auto-install (Recipe 10)
- Competitive category with incumbents? Add one-click migration (Recipe 12)
- Complex / visual product? Add sandbox preview mode (Recipe 7)
- Anything destructive or scary? Add contextual Popover tooltips (Recipe 5)
- Want to recapture closed-tab merchants? Add first-day welcome email (Recipe 13)
**6. Test:**
- Install your own app in a new dev store
- Time the seconds from "click Install" to "see the aha moment" with a stopwatch
- If you're over your category TTV target (Section 5), cut steps until you're under
---
## References
- Built for Shopify onboarding requirements: https://shopify.dev/docs/apps/launch/built-for-shopify/requirements
- Polaris onboarding guidance: https://shopify.dev/docs/apps/design/user-experience/onboarding
- Polaris components: https://polaris-react.shopify.com/components
- App Bridge web components: https://shopify.dev/docs/api/app-home/polaris-web-components
- Internal: `research/03_frontend.md` (Polaris component reference)
- Internal: `research2/10_top_app_ux_teardown.md` (top app onboarding teardowns)
ux-polaris-antipatterns31.6 KB
---
name: ux-polaris-antipatterns
description: "Use when reviewing or writing Polaris UI for common design mistakes — using deprecated Stack instead of BlockStack/InlineStack, modal overuse, blocking validation, wrong tone (success/critical/warning/info), custom CSS overrides instead of tokens, off-brand colors, mis-sized cards, missing helpText, no FormLayout, mobile responsive failures, accessibility failures inside Polaris components, drifting between Polaris versions. Triggers: 'polaris mistake', 'polaris anti-pattern', 'polaris stack deprecated', 'polaris design review', 'shopify ui code review', 'polaris best practice', 'polaris vs custom', 'polaris tokens'."
---
# Polaris UI Anti-Patterns (v12+)
A field guide to the 20 most common ways Shopify app teams break the Polaris contract — and the exact fix for each. Use this when reviewing a PR, writing a new screen, or auditing an app pre-submission for Built for Shopify.
The principle behind every rule: **Shopify admin is a shared design surface.** Merchants have already learned its patterns. The closer your app feels to the rest of admin, the higher your install-to-activation rate. The further you drift, the more your app feels like a third-party graft — and the more support tickets you get from confused merchants.
---
## 1. When to use
Pull this skill in when you are:
- Reviewing a pull request that touches `@shopify/polaris` components
- Writing a new screen and unsure whether a custom component is warranted
- Migrating from Polaris 10 or 11 to 12+
- Preparing an app for App Store submission or Built for Shopify certification
- Debugging "why does our app look off-brand?" complaints
- Diagnosing accessibility audit failures
- Deciding between `<Modal>`, `<Banner>`, `<Toast>`, or inline error
- Choosing between custom CSS and design tokens
If the user mentions Polaris, Stack, BlockStack, design tokens, `--p-color-*`, `className` on Polaris components, Modal overuse, or "this feels off-brand" — trigger this skill.
---
## 2. The 20 anti-patterns, ranked by severity
Ranked by how badly each one degrades the merchant experience (1 = catastrophic, 20 = nit). Fixes are in the "do this instead" column.
| # | Anti-pattern | Severity | Do this instead |
|---|---|---|---|
| 1 | Using `<Stack>` / `<LegacyStack>` / `<VerticalStack>` / `<HorizontalStack>` | Build-breaking on v12+ | `<BlockStack>` (vertical) or `<InlineStack>` (horizontal). Run `npx @shopify/polaris-migrator` |
| 2 | Wrapping Polaris components in custom `className` for styling | High — most Polaris components do not accept `className` | Use the component's built-in props (`tone`, `variant`, `padding`, `gap`). For exceptions, use `<Box>` with token props |
| 3 | Hex codes (`color: #008060`) instead of design tokens | High — instantly off-brand on theme changes | `var(--p-color-bg-fill-success)` or a `tone` prop |
| 4 | Modal for non-blocking errors or confirmations | High — blocks the merchant; high abandonment | `<Banner>` for in-context info, `<Toast>` for transient confirmations, inline error for forms |
| 5 | Toast for errors that need explanation | High — disappears in 3s, easily missed | `<Banner tone="critical">` with title + body + recovery action |
| 6 | Using `tone="success"` for neutral or informational | Medium — semantic noise | `tone="info"` for info, no tone for neutral |
| 7 | Using `tone="critical"` for warnings | Medium — desensitizes the merchant to real errors | `tone="warning"` for cautionary, `tone="critical"` only for destructive/error |
| 8 | Missing `<FormLayout>` around form fields | Medium — inconsistent spacing | Always wrap related fields in `<FormLayout>` inside `<Form>` |
| 9 | Blocking validation on every keystroke | Medium — feels hostile, breaks flow | Validate on blur or on submit; clear errors on next change |
| 10 | Missing `helpText` on non-obvious fields | Medium — drives support tickets | `helpText` describing format, examples, or constraints |
| 11 | Forgetting to import `@shopify/polaris/build/esm/styles.css` | High — entire app looks unstyled | Import once in the app root, before `<AppProvider>` |
| 12 | More than 2 filled/shaped buttons in one Card | Medium — destroys hierarchy | One primary, one secondary, rest as plain or inside `<ActionList>` |
| 13 | `<IndexTable>` on mobile without responsive plan | Medium — horizontal scroll hell on phones | Hide columns or switch to `<ResourceList>` below the mobile breakpoint |
| 14 | Modal sized larger than the mobile viewport | Medium — content clipped on phones | `size="small"` for mobile flows; never assume desktop |
| 15 | Icon-only buttons missing `accessibilityLabel` | Medium — screen readers see nothing | Always pass `accessibilityLabel` to icon-only `<Button>` |
| 16 | Error states relying on color alone | Medium — fails WCAG, fails colorblind users | Pair red border with text error message and icon |
| 17 | Importing old icon names (`DeleteMinor`, `EditMajor`) | Medium — works on v10–11, gone in v12+ | Use new icon names (`DeleteIcon`, `EditIcon`) |
| 18 | Custom focus styles that override Polaris focus rings | Medium — keyboard users get lost | Leave focus rings alone, or use `--p-focused` tokens |
| 19 | Card with no padding wrapper | Low — content touches Card edge | `<Box padding="400">` inside, or use `<Card padding="400">` (v12+) |
| 20 | Mixing `tone="subdued"` with low-contrast text on top | Low — fails AA contrast | Don't stack subdued tones; use one level of de-emphasis |
---
## 3. Layout anti-patterns (deepest dive)
### 3.1. The deprecated Stack family
In Polaris 10 there was `<Stack>` with `vertical` and `distribution` props. Polaris 11 split it into `<VerticalStack>` and `<HorizontalStack>`. Polaris 12 renamed those to `<BlockStack>` and `<InlineStack>` to match CSS logical-property language (block axis = vertical, inline axis = horizontal).
Anything still using the old names is either dead code or a build error on v12.
**Wrong (Polaris 10):**
```tsx
<Stack vertical spacing="loose">
<TextField label="Name" value={name} onChange={setName} />
<TextField label="SKU" value={sku} onChange={setSku} />
</Stack>
```
**Wrong (Polaris 11):**
```tsx
<VerticalStack gap="4">
<TextField label="Name" value={name} onChange={setName} />
</VerticalStack>
```
**Right (Polaris 12+):**
```tsx
<BlockStack gap="400">
<TextField label="Name" value={name} onChange={setName} />
<TextField label="SKU" value={sku} onChange={setSku} />
</BlockStack>
```
Note the gap scale also changed: `"loose"` / `"4"` became `"400"` (the numeric token scale). Migrator handles this:
```bash
npx @shopify/polaris-migrator react-rename-component@v12 \
--renameFrom=VerticalStack --renameTo=BlockStack \
"src/**/*.tsx"
```
### 3.2. Modal overuse
Shopify's own guidance: modals block the merchant until they make a decision. They are a power move. Reach for them only when:
1. The action is consequential and reversible needs explicit consent (delete, publish, send invoice)
2. You need a focused mini-form that does not fit in the existing page
3. You are showing a confirmation step that genuinely benefits from an interruption
Do **not** use modals for:
- Telling a merchant that something went wrong (use `<Banner>` in-context)
- Confirming a successful save (use `<Toast>`)
- Showing extra information the merchant can read later (use `<Popover>` or expand a section)
- "Are you sure?" on non-destructive actions (just do the action; offer undo via Toast)
### 3.3. Oversized cards
A common mistake: stuffing an entire feature into a single Card with deeply nested sections. This breaks visual hierarchy and forces merchants to scroll past unrelated content.
**Wrong:**
```tsx
<Card>
<Card.Section title="Basic info">...</Card.Section>
<Card.Section title="Pricing">...</Card.Section>
<Card.Section title="Inventory">...</Card.Section>
<Card.Section title="Shipping">...</Card.Section>
<Card.Section title="SEO">...</Card.Section>
<Card.Section title="Advanced">...</Card.Section>
</Card>
```
**Right:**
```tsx
<BlockStack gap="400">
<Card>
<BlockStack gap="300">
<Text variant="headingMd" as="h2">Basic info</Text>
{/* fields */}
</BlockStack>
</Card>
<Card>
<BlockStack gap="300">
<Text variant="headingMd" as="h2">Pricing</Text>
{/* fields */}
</BlockStack>
</Card>
{/* one Card per logical section */}
</BlockStack>
```
Rule of thumb: a Card is a unit of meaning. If two sections do not belong on the same screen for the same task, they should be separate Cards. If a Card has more than 3 sections, split it.
---
## 4. Form anti-patterns
### 4.1. Wrong field types
Shopify admin merchants type tens of fields per day. Wrong field types cost them speed and trust.
| Wrong | Right | Why |
|---|---|---|
| `<TextField>` for currency without `prefix="$"` and `type="number"` | `<TextField type="currency" prefix="$">` | Mobile keyboard, alignment, parsing |
| `<TextField>` for a fixed choice | `<Select>` or `<ChoiceList>` | Free-text invites errors |
| `<Select>` with 2 options | `<Checkbox>` or `<RadioButton>` | Two-state UI should not require a dropdown |
| `<Checkbox>` for "either A or B" | `<RadioButton>` group | Checkbox implies independent toggles |
| Inline `<input>` mixed with Polaris fields | `<TextField>` | Inconsistent focus rings, sizing, error states |
### 4.2. Blocking validation
**Wrong (validates on every keystroke):**
```tsx
const handleChange = (value: string) => {
setName(value);
if (value.length < 3) {
setNameError("Must be at least 3 characters");
}
};
```
The merchant types "Ab" and immediately sees an error before they can type the third letter. This is hostile.
**Right (validates on blur, clears on change):**
```tsx
const handleChange = (value: string) => {
setName(value);
if (nameError) setNameError(""); // clear on next change
};
const handleBlur = () => {
if (name.length > 0 && name.length < 3) {
setNameError("Must be at least 3 characters");
}
};
<TextField
label="Name"
value={name}
onChange={handleChange}
onBlur={handleBlur}
error={nameError}
/>
```
For form-level validation (required fields, cross-field rules) — validate on submit, surface all errors via `<Banner tone="critical">` at the top of the form **plus** inline `error` props on each field.
### 4.3. Missing FormLayout
`<FormLayout>` enforces the correct vertical rhythm and field grouping. Without it, fields touch each other or float with wrong spacing.
**Wrong:**
```tsx
<Form onSubmit={handleSubmit}>
<TextField label="Name" value={name} onChange={setName} />
<TextField label="Email" value={email} onChange={setEmail} />
<Button submit>Save</Button>
</Form>
```
**Right:**
```tsx
<Form onSubmit={handleSubmit}>
<FormLayout>
<TextField label="Name" value={name} onChange={setName} />
<TextField label="Email" value={email} onChange={setEmail} />
</FormLayout>
<Button submit>Save</Button>
</Form>
```
Also use `<FormLayout.Group>` to put related short fields on the same row (first name + last name, city + zip).
---
## 5. Color and tone anti-patterns
Polaris uses semantic `tone` props rather than direct colors. Four tones cover almost everything:
| Tone | Meaning | Use for |
|---|---|---|
| `success` | Positive completion | Order shipped, settings saved, payment received |
| `critical` | Destructive or error | Delete, payment failed, validation error |
| `warning` | Cautionary, reversible | Low stock, plan limit, draft will be lost |
| `info` | Neutral information | New feature notice, helpful tip, ongoing sync |
Plus tone modifiers on text: `tone="subdued"` for de-emphasized copy.
**Common misuses:**
```tsx
// WRONG — success tone for a neutral confirmation
<Banner tone="success">Your settings are saved automatically.</Banner>
// RIGHT
<Banner tone="info">Your settings are saved automatically.</Banner>
```
```tsx
// WRONG — critical for a warning
<Banner tone="critical">You have 3 days left in your trial.</Banner>
// RIGHT
<Banner tone="warning">You have 3 days left in your trial.</Banner>
```
```tsx
// WRONG — no tone, custom red color
<Banner style={{ borderColor: "red" }}>Action required</Banner>
// RIGHT
<Banner tone="warning" title="Action required">...</Banner>
```
Rule: if you reach for a hex code in a Polaris context, you are probably picking the wrong tone instead.
---
## 6. Custom CSS overrides — when prohibited
Polaris components are designed as a closed contract. Most do **not** accept a `className` prop. Even when you can shim CSS in via wrappers, doing so is an anti-pattern.
**Hard no:**
- Overriding internal selectors via `.Polaris-Button { ... }` in global CSS
- Wrapping in a `<div className="my-custom-styles">` and targeting child Polaris classes
- Using `!important` to win against Polaris styles
- Patching at runtime via `style={{ }}` on Polaris components that do not document a `style` prop
**Acceptable:**
- Using `<Box>` with token props (`padding`, `background`, `borderRadius`, `borderColor`) to wrap Polaris content
- Adding custom CSS to your own non-Polaris components, using `var(--p-*)` tokens
- Using documented props (`tone`, `variant`, `size`, `gap`)
- Wrapping the entire app shell in a layout div with custom CSS (outermost only)
**The decision rule:** if you want to change how a Polaris component looks, your three options are:
1. Use a documented prop (`tone`, `variant`, `size`).
2. Wrap with `<Box>` and adjust spacing/background.
3. Build a custom component using `<Box>` + Polaris tokens and stop using the Polaris component for that case.
There is no fourth option. Reaching past the contract via CSS will break on every Polaris minor release.
---
## 7. Off-brand styles — use design tokens
Polaris exposes its design tokens as CSS custom properties prefixed `--p-`. They are the single source of truth for color, spacing, radius, shadow, type, motion.
### 7.1. Most-used token families
| Family | Examples |
|---|---|
| Color — surface | `--p-color-bg-surface`, `--p-color-bg-surface-secondary`, `--p-color-bg-surface-hover` |
| Color — fill | `--p-color-bg-fill`, `--p-color-bg-fill-success`, `--p-color-bg-fill-warning`, `--p-color-bg-fill-critical` |
| Color — text | `--p-color-text`, `--p-color-text-subdued`, `--p-color-text-success`, `--p-color-text-critical` |
| Color — border | `--p-color-border`, `--p-color-border-subdued`, `--p-color-border-focused` |
| Spacing | `--p-space-100` (4px), `--p-space-200` (8px), `--p-space-300` (12px), `--p-space-400` (16px), `--p-space-500` (20px), `--p-space-600` (24px), `--p-space-800` (32px) |
| Border radius | `--p-border-radius-100`, `--p-border-radius-200`, `--p-border-radius-300` |
| Shadow | `--p-shadow-100`, `--p-shadow-200`, `--p-shadow-300` |
| Typography | `--p-font-size-300`, `--p-font-weight-medium`, `--p-font-line-height-500` |
### 7.2. Off-brand examples
```css
/* WRONG — hardcoded brand color, breaks if Shopify rebrands or merchant theme changes */
.my-success-pill {
background: #008060;
color: white;
padding: 8px 12px;
border-radius: 4px;
}
/* RIGHT — uses Polaris tokens, follows admin theme automatically */
.my-success-pill {
background: var(--p-color-bg-fill-success);
color: var(--p-color-text-on-color);
padding: var(--p-space-200) var(--p-space-300);
border-radius: var(--p-border-radius-200);
}
```
**Even better:** don't write the CSS at all. Use `<Badge tone="success">Active</Badge>`.
Always prefer **semantic** tokens (`--p-color-bg-fill-success`) over **primitive** tokens (`--p-color-green-500`). Primitive tokens may shift; semantic tokens hold their meaning across themes and versions.
---
## 8. Mobile responsive failures
Shopify admin is used on mobile by ~40% of merchants on certain workflows (order check, inventory tweak, support reply). Embedded apps that assume desktop fail in production.
### 8.1. IndexTable on narrow viewports
`<IndexTable>` is wide by default. On a 375px viewport, anything past 3 columns scrolls horizontally inside an iframe — confusing and easy to miss.
**Fixes, ranked:**
1. **Hide columns below a breakpoint.** Polaris does not have a built-in `hideOnMobile`; use a conditional render based on viewport width.
```tsx
const isMobile = useMediaQuery("(max-width: 768px)");
const headings = isMobile
? [{ title: "Name" }, { title: "Status" }]
: [
{ title: "Name" },
{ title: "SKU" },
{ title: "Inventory" },
{ title: "Status" },
{ title: "Last updated" },
];
```
2. **Switch to `<ResourceList>` for mobile.** Lists stack naturally; tables do not.
3. **Pin the most important column.** Polaris IndexTable supports sticky columns since v12.
### 8.2. Modal sizing
```tsx
// WRONG — assumes desktop; clips on phones
<Modal open={open} onClose={close} title="Edit" size="large">
// RIGHT — small modal on mobile, large on desktop
<Modal
open={open}
onClose={close}
title="Edit"
size={isMobile ? "small" : "large"}
>
```
### 8.3. Card padding on small screens
Generous desktop padding (e.g., `padding="500"`) eats the small screen. Use responsive padding:
```tsx
<Card padding={{ xs: "300", md: "500" }}>
...
</Card>
```
### 8.4. Layout sections
Two-column layouts must collapse to one column under ~768px. `<Layout>` and `<InlineGrid>` handle this automatically only if you use the responsive `columns` prop:
```tsx
// WRONG — always two columns, broken on mobile
<InlineGrid columns="1fr 1fr" gap="400">
// RIGHT — collapses to one column on mobile
<InlineGrid columns={{ xs: 1, md: 2 }} gap="400">
```
---
## 9. Accessibility failures inside Polaris
Polaris is WCAG 2.1 AA compliant out of the box. App teams break that compliance with custom code around Polaris.
### 9.1. Missing labels
```tsx
// WRONG — no label, no accessibility label
<TextField placeholder="Search..." value={query} onChange={setQuery} />
// RIGHT
<TextField
label="Search products"
labelHidden
placeholder="Search..."
value={query}
onChange={setQuery}
/>
```
`labelHidden` keeps the visual layout clean while still providing an accessible label to screen readers.
### 9.2. Icon-only buttons
```tsx
// WRONG — screen reader hears "Button"
<Button icon={DeleteIcon} onClick={handleDelete} />
// RIGHT
<Button
icon={DeleteIcon}
accessibilityLabel="Delete product"
onClick={handleDelete}
/>
```
### 9.3. Focus order
When you build a modal or popover, the first focusable element should be the most likely action. Polaris handles this for `<Modal>` (first input or primary action gets focus). But if you build a custom flow:
- First focusable on open = main task action or first input
- Tab should move forward through fields in reading order
- Escape closes the modal
- Focus returns to the trigger element on close
If you implement a custom dropdown or popover, replicate this. Better: just use `<Popover>` and `<ActionList>`.
### 9.4. Contrast
Polaris tokens are designed to pass AA. Two common ways teams break contrast:
1. **Stacking subdued tones.** `<Text tone="subdued">` on `<Box background="bg-surface-secondary">` can drop below 4.5:1.
2. **Custom colors over branded backgrounds.** If you change a Card background via `<Box background="...">`, re-test all text inside it.
Run an Axe audit before submission. The most common embedded-app failures are: missing form labels, icon-only buttons without aria-label, and color-only error indication.
---
## 10. Migration drift — Polaris 10 → 11 → 12+
If your codebase has been around for more than 18 months, you have drift. Symptoms: components that worked have started warning, build size grew, some screens use new patterns and some use old.
### 10.1. Component renames (10 → 11 → 12)
| Polaris 10 | Polaris 11 | Polaris 12+ |
|---|---|---|
| `<Stack vertical>` | `<VerticalStack>` | `<BlockStack>` |
| `<Stack>` (horizontal) | `<HorizontalStack>` | `<InlineStack>` |
| `<Stack.Item>` | (n/a — children are direct) | (n/a) |
| `<TextStyle variation="strong">` | `<Text fontWeight="semibold">` | `<Text fontWeight="semibold">` |
| `<DisplayText>` | `<Text variant="headingXl">` | `<Text variant="headingXl">` |
| `<Heading>` | `<Text variant="headingMd">` | `<Text variant="headingMd">` |
| `<Subheading>` | `<Text variant="headingSm">` | `<Text variant="headingSm">` |
| `<Caption>` | `<Text variant="bodySm">` | `<Text variant="bodySm">` |
| `<Visually Hidden>` | `<VisuallyHidden>` | (use `labelHidden` or `Text visuallyHidden`) |
| `<Card sectioned>` | `<Card><Card.Section>` | `<Card padding="400">` |
| `<Stack spacing="loose">` | `gap="4"` | `gap="400"` |
### 10.2. Icon renames
Old: `DeleteMinor`, `EditMajor`, `SaveMinor`, `SearchMinor`, `CancelMinor`, `PlusMinor`, `MinusMinor`, `RefreshMinor`, `ChevronDownMinor`.
New (v12+): `DeleteIcon`, `EditIcon`, `SaveIcon`, `SearchIcon`, `XIcon`, `PlusIcon`, `MinusIcon`, `RefreshIcon`, `ChevronDownIcon`.
The "Minor / Major" suffix is gone. Always `*Icon`.
```tsx
// WRONG (Polaris 10–11)
import { DeleteMinor, EditMajor } from "@shopify/polaris-icons";
// RIGHT (Polaris 12+)
import { DeleteIcon, EditIcon } from "@shopify/polaris-icons";
```
### 10.3. Use the migrator
```bash
# Install once
npm install --save-dev @shopify/polaris-migrator
# Run all v11 → v12 migrations
npx @shopify/polaris-migrator migrate v11-react-rename-components "src/**/*.tsx"
npx @shopify/polaris-migrator migrate v12-react-replace-icons "src/**/*.tsx"
npx @shopify/polaris-migrator migrate v12-react-update-spacing-tokens "src/**/*.tsx"
```
After running, do a manual audit of:
- `<Card>` instances (sectioned → padding)
- Custom CSS using old spacing names
- Storybook stories
- Snapshot tests
---
## 11. Anti-pattern → fix table with before/after code
### 11.1. Deprecated Stack
```tsx
// BEFORE
<Stack vertical spacing="tight">
<Stack.Item><TextField label="Name" /></Stack.Item>
<Stack.Item><TextField label="SKU" /></Stack.Item>
</Stack>
// AFTER
<BlockStack gap="200">
<TextField label="Name" />
<TextField label="SKU" />
</BlockStack>
```
### 11.2. Modal overuse for confirmation
```tsx
// BEFORE — interrupts the merchant
<Modal
open={savedOpen}
onClose={() => setSavedOpen(false)}
title="Saved!"
primaryAction={{ content: "OK", onAction: () => setSavedOpen(false) }}
>
<Modal.Section>Your changes are saved.</Modal.Section>
</Modal>
// AFTER — Toast for transient success
shopify.toast.show("Changes saved", { duration: 3000 });
// or in React:
<Toast content="Changes saved" onDismiss={dismiss} />
```
### 11.3. Toast for an error that needs detail
```tsx
// BEFORE — disappears in 3s, no recovery path
<Toast content="Failed" error onDismiss={dismiss} />
// AFTER — Banner with title, body, and recovery action
<Banner
tone="critical"
title="Could not save product"
action={{ content: "Retry", onAction: handleRetry }}
>
<p>The inventory API timed out. Your changes are not saved.</p>
</Banner>
```
### 11.4. Wrong tone
```tsx
// BEFORE
<Banner tone="success">You have 3 days left in your trial.</Banner>
// AFTER
<Banner tone="warning" title="Trial ending soon">
You have 3 days left in your trial.
</Banner>
```
### 11.5. Hex colors
```tsx
// BEFORE
<div style={{ backgroundColor: "#d3f9d8", padding: "12px" }}>
Connected
</div>
// AFTER (option A — use the component)
<Badge tone="success">Connected</Badge>
// AFTER (option B — use Box with tokens)
<Box background="bg-fill-success" padding="300">
<Text tone="success">Connected</Text>
</Box>
```
### 11.6. className override
```tsx
// BEFORE — does nothing on most Polaris components; relies on fragile selectors
<Button className="my-custom-button">Save</Button>
// in CSS:
.my-custom-button { background: orange !important; }
// AFTER — use documented props
<Button variant="primary" tone="success">Save</Button>
```
### 11.7. No FormLayout
```tsx
// BEFORE
<Form onSubmit={submit}>
<TextField label="Name" value={name} onChange={setName} />
<TextField label="Email" value={email} onChange={setEmail} />
<Checkbox label="Subscribe" checked={sub} onChange={setSub} />
<Button submit>Save</Button>
</Form>
// AFTER
<Form onSubmit={submit}>
<FormLayout>
<TextField label="Name" value={name} onChange={setName} />
<TextField label="Email" value={email} onChange={setEmail} />
<Checkbox label="Subscribe" checked={sub} onChange={setSub} />
</FormLayout>
<Box paddingBlockStart="400">
<Button submit variant="primary">Save</Button>
</Box>
</Form>
```
### 11.8. Blocking keystroke validation
```tsx
// BEFORE
<TextField
label="Name"
value={name}
onChange={(v) => {
setName(v);
setError(v.length < 3 ? "Too short" : "");
}}
error={error}
/>
// AFTER
<TextField
label="Name"
value={name}
onChange={(v) => { setName(v); if (error) setError(""); }}
onBlur={() => {
if (name.length > 0 && name.length < 3) setError("Must be at least 3 characters");
}}
error={error}
helpText="At least 3 characters"
/>
```
### 11.9. Old icon names
```tsx
// BEFORE
import { DeleteMinor, EditMajor, SaveMinor } from "@shopify/polaris-icons";
// AFTER
import { DeleteIcon, EditIcon, SaveIcon } from "@shopify/polaris-icons";
```
### 11.10. IndexTable on mobile
```tsx
// BEFORE — 6 columns, horizontal scroll on mobile
<IndexTable
headings={[
{ title: "Name" }, { title: "SKU" }, { title: "Vendor" },
{ title: "Inventory" }, { title: "Price" }, { title: "Status" }
]}
...
/>
// AFTER — collapse to 2 columns under md
const isMobile = useBreakpoints().smDown;
<IndexTable
headings={
isMobile
? [{ title: "Name" }, { title: "Status" }]
: [
{ title: "Name" }, { title: "SKU" }, { title: "Vendor" },
{ title: "Inventory" }, { title: "Price" }, { title: "Status" }
]
}
...
/>
```
---
## 12. Decision tree
Use this when in doubt about which component to pick.
**Need to tell the merchant something happened.**
- Was the merchant's action successful, and they do not need to think about it further? → `<Toast>`
- Did something fail or need attention that the merchant should see, in context, but can still keep working? → `<Banner>` (use the right tone)
- Does the merchant need to confirm or make a decision before continuing? → `<Modal>`
- Is it a form field problem? → inline `error` prop on the field + `<Banner tone="critical">` summary at top of form
**Need to lay something out.**
- Vertical stack of items? → `<BlockStack gap="...">`
- Horizontal row of items? → `<InlineStack gap="...">`
- Grid of items, responsive? → `<InlineGrid columns="..." gap="...">`
- Wrapper with padding / background / border? → `<Box ...>`
- A full page section with title and actions? → `<Card>` containing `<BlockStack>` containing `<Text variant="headingMd">` + body
**Need to show data.**
- Tabular, with sorting / selection / bulk actions? → `<IndexTable>`
- Browseable list with custom item rendering? → `<ResourceList>`
- Static key-value pairs? → `<DescriptionList>` or `<BlockStack>` with labeled rows
**Need to collect input.**
- Single line of text? → `<TextField>`
- Multi-line text? → `<TextField multiline={4}>`
- Pick one of many? → `<Select>` (5+ options) or `<ChoiceList>` (2–4 options, visible)
- Toggle on/off? → `<Checkbox>` (independent) or `<RadioButton>` (mutually exclusive)
- Date? → `<DatePicker>`
- Wrap fields with consistent spacing? → `<FormLayout>` inside `<Form>`
**Need to style something custom.**
- Can I do it with a Polaris prop? → use the prop
- Can I do it with `<Box>` and tokens? → use `<Box>`
- Neither? → build a custom component using `var(--p-*)` tokens; do not override Polaris CSS
---
## 13. Code review checklist (20 items)
Walk through this list on every PR that touches a Polaris file. Each item maps to one of the anti-patterns above.
- [ ] **1. No deprecated Stack.** No imports of `Stack`, `LegacyStack`, `VerticalStack`, `HorizontalStack`. Only `BlockStack` and `InlineStack`.
- [ ] **2. No className on Polaris components.** Custom styling uses `<Box>` + token props, not class overrides.
- [ ] **3. No hex codes.** All colors come from `var(--p-color-*)` tokens or `tone` props.
- [ ] **4. Modals only for blocking decisions.** No modals for success confirmations or non-blocking errors.
- [ ] **5. Toasts only for transient success.** No toasts for errors that need explanation or recovery.
- [ ] **6. Tone semantics are correct.** Success = positive, critical = destructive/error, warning = cautionary, info = neutral.
- [ ] **7. Forms use `<FormLayout>`.** Fields inside `<Form>` are wrapped in `<FormLayout>` for consistent spacing.
- [ ] **8. Validation is not on keystroke.** Errors appear on blur or submit, not as the merchant is typing.
- [ ] **9. `helpText` exists for non-obvious fields.** Format, examples, or constraints are explained inline.
- [ ] **10. Polaris CSS is imported at app root.** `import "@shopify/polaris/build/esm/styles.css"` runs before `<AppProvider>`.
- [ ] **11. Max 2 filled buttons per Card.** Extras move to `<ActionList>` or plain buttons.
- [ ] **12. IndexTable hides columns on mobile.** Column set changes via `useBreakpoints` or media query.
- [ ] **13. Modal `size` is responsive.** Small on mobile, medium/large on desktop.
- [ ] **14. Icon-only buttons have `accessibilityLabel`.** No bare `<Button icon={...} />` without a label.
- [ ] **15. Errors do not rely on color alone.** Red border + text message + (optional) icon.
- [ ] **16. Icon names are the new style.** `DeleteIcon` not `DeleteMinor`. No `*Minor` or `*Major` suffix.
- [ ] **17. No overrides of Polaris focus rings.** Focus styling is left alone or uses `--p-focused` tokens.
- [ ] **18. Cards have padding.** Either `<Card padding="400">` (v12+) or wrapped in `<Box padding="400">`.
- [ ] **19. Subdued tones are not stacked.** `tone="subdued"` text is not on subdued surface without contrast check.
- [ ] **20. AppProvider wraps the tree.** Exactly one `<AppProvider i18n={...}>` at the root.
---
## Quick reference — token cheat sheet
```css
/* Spacing — use on padding, gap, margin */
--p-space-100 /* 4px */
--p-space-200 /* 8px */
--p-space-300 /* 12px */
--p-space-400 /* 16px */
--p-space-500 /* 20px */
--p-space-600 /* 24px */
--p-space-800 /* 32px */
/* Color — semantic */
--p-color-bg-surface
--p-color-bg-surface-secondary
--p-color-bg-fill-success
--p-color-bg-fill-warning
--p-color-bg-fill-critical
--p-color-text
--p-color-text-subdued
--p-color-text-success
--p-color-text-critical
--p-color-border
--p-color-border-focused
/* Radius */
--p-border-radius-100 /* 4px */
--p-border-radius-200 /* 8px */
--p-border-radius-300 /* 12px */
/* Shadow */
--p-shadow-100
--p-shadow-200
--p-shadow-300
/* Typography */
--p-font-size-300
--p-font-weight-medium
--p-font-line-height-500
```
When in doubt: use the component prop. When no prop fits: use `<Box>` with token props. When `<Box>` does not fit: write CSS using `var(--p-*)` tokens. Never reach past these three layers.
---
## Sources
- [Migrating from v11 to v12 — Shopify Polaris React](https://polaris-react.shopify.com/version-guides/migrating-from-v11-to-v12)
- [Block stack — Shopify Polaris React](https://polaris-react.shopify.com/components/layout-and-structure/block-stack)
- [Inline stack — Shopify Polaris React](https://polaris-react.shopify.com/components/layout-and-structure/inline-stack)
- [Legacy stack — Shopify Polaris React](https://polaris-react.shopify.com/components/deprecated/legacy-stack)
- [Error messages — Shopify Polaris](https://legacy.polaris.shopify.com/patterns/error-messages)
- [Common actions — Shopify Polaris](https://polaris.shopify.com/patterns/common-actions/best-practices)
- [Tokens — Shopify Polaris React](https://polaris-react.shopify.com/design/layout/layout-tokens)
- [Color tokens — Shopify Polaris React](https://polaris-react.shopify.com/design/colors/color-tokens)
- [polaris-tokens — GitHub](https://github.com/Shopify/polaris-tokens)
- [Shopify Polaris App Design: Build Review-Ready Apps — grumspot](https://grumspot.com/blog/shopify-polaris-app-design)
webhooks15.8 KB
---
name: webhooks
description: "Webhook delivery methods, verification, retry behavior, payload handling, and implementation patterns for Shopify events. Triggers include: 'set up webhook', 'verify webhook signature', 'webhook delivery', 'HMAC verification', 'webhook retry', 'event subscription', 'webhook payload', 'AWS EventBridge Shopify', 'Google Pub/Sub webhook', 'webhook manifest'."
---
# Shopify Webhooks Implementation Guide
## When to Use This Skill
Use webhooks when you need to:
- Receive real-time event notifications from Shopify (orders, products, customers, fulfillments, etc.)
- Sync external systems with Shopify data changes
- Trigger automated workflows based on shop events
- Monitor GDPR compliance actions (customer data erasure, shop deletion)
- Process bulk operations completion
- Update inventory or pricing in third-party systems
## Webhook Delivery Methods
### 1. HTTPS (Traditional)
Most common delivery method. Webhooks sent as POST requests to your publicly accessible HTTPS endpoint.
**Characteristics:**
- Requires public HTTPS endpoint (443, TLS 1.2+)
- Real-time delivery (within seconds)
- Max 5 retries over 48 hours (exponential backoff: 10s, 30s, 1m, 2m, 8h)
- Request timeout: 30 seconds
- Payload size: max 2MB
- Rate limiting: respect Shopify API rate limits
**Configuration Example:**
```json
{
"deliveryMethod": {
"https": {
"address": "https://myapp.example.com/webhooks/shopify"
}
},
"query": "subscription { event { id occurredAt } }",
"filter": "orders/created"
}
```
### 2. AWS EventBridge
Asynchronous delivery via AWS EventBridge. Events queued in partner event bus.
**Characteristics:**
- No public endpoint needed
- Queued delivery (managed by EventBridge)
- Scalable, event-sourcing ready
- Requires AWS EventBridge partner event bus setup
- Lower operational overhead
- Better for high-volume events
**Setup Steps:**
```bash
# 1. Shopify creates AWS partner event bus in your account
# 2. Your app subscribes to webhooks via EventBridge configuration
# 3. Events appear in partner event bus: aws.partner/shopify.com/[app-id]/[account-id]
# 3. Create rule to route to your targets (SQS, Lambda, etc.)
aws events put-rule \
--name shopify-webhook-router \
--event-bus-name aws.partner/shopify.com/12345/default \
--state ENABLED
```
**Example Event:**
```json
{
"detail-type": "orders/created",
"detail": {
"id": "gid://shopify/Order/123456",
"displayOrderNumber": "#1001",
"email": "customer@example.com",
"createdAt": "2026-05-04T14:30:00Z",
"totalPriceSet": {
"shopMoney": {
"amount": "299.99",
"currencyCode": "USD"
}
}
},
"source": "aws.partner/shopify.com/12345/default"
}
```
### 3. Google Cloud Pub/Sub
Event streaming via Google Pub/Sub. Decoupled, scalable webhook delivery.
**Characteristics:**
- No public endpoint needed
- Managed queue with exactly-once semantics
- Scalable message processing
- Requires Google Cloud project setup
- Integration with Cloud Functions, Dataflow, etc.
- Best for data pipeline workflows
**Configuration:**
```bash
# 1. Create Pub/Sub topic in Google Cloud
gcloud pubsub topics create shopify-webhooks
# 2. Subscribe your app to receive messages
gcloud pubsub subscriptions create shopify-webhook-sub \
--topic=shopify-webhooks \
--push-endpoint=https://your-service.com/pubsub-handler
# 3. Create service account for Shopify to publish
gcloud iam service-accounts create shopify-publisher
gcloud pubsub topics add-iam-policy-binding shopify-webhooks \
--member=serviceAccount:shopify-publisher@PROJECT_ID.iam.gserviceaccount.com \
--role=roles/pubsub.publisher
```
## HMAC Verification (HTTPS Only)
All HTTPS webhooks are signed with HMAC-SHA256. Always verify signatures.
**Headers:**
- `X-Shopify-Hmac-SHA256`: Base64-encoded HMAC-SHA256 signature
- `X-Shopify-Shop-Api-Access-Token`: OAuth token used (for debugging)
- `X-Shopify-Webhook-Id`: Unique webhook instance ID
- `X-Shopify-Topic`: Event topic (e.g., "orders/created")
- `X-Shopify-Transmitted-At`: ISO 8601 timestamp
**Verification Algorithm:**
```javascript
// Node.js verification
const crypto = require('crypto');
function verifyWebhookSignature(req, secret) {
const hmacHeader = req.headers['x-shopify-hmac-sha256'];
const body = req.rawBody; // Must be raw bytes, not parsed JSON
// Compute expected HMAC
const computed = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('base64');
// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(hmacHeader),
Buffer.from(computed)
);
}
// Express middleware example
app.use(express.raw({ type: 'application/json' }));
app.post('/webhooks/shopify', (req, res) => {
if (!verifyWebhookSignature(req, process.env.SHOPIFY_WEBHOOK_SECRET)) {
return res.status(401).send('Unauthorized');
}
const event = JSON.parse(req.body);
// Process webhook...
res.status(200).send('OK');
});
```
**Python Verification:**
```python
import hmac
import hashlib
import base64
def verify_webhook_signature(request, secret):
hmac_header = request.headers.get('X-Shopify-Hmac-SHA256')
body = request.get_data() # Raw bytes
computed = base64.b64encode(
hmac.new(
secret.encode('utf-8'),
body,
hashlib.sha256
).digest()
).decode('utf-8')
return hmac.compare_digest(hmac_header, computed)
from flask import Flask, request
@app.route('/webhooks/shopify', methods=['POST'])
def handle_webhook():
if not verify_webhook_signature(request, SHOPIFY_WEBHOOK_SECRET):
return 'Unauthorized', 401
event = request.get_json()
# Process webhook...
return 'OK', 200
```
## Webhook Events and Payloads
### Order Events
**orders/created** - New order placed
```graphql
subscription {
event {
id
occurredAt
... on OrderCreatedEvent {
order {
id
displayOrderNumber
email
totalPriceSet { shopMoney { amount currencyCode } }
lineItems(first: 10) {
edges {
node {
id
title
quantity
variantId
}
}
}
}
}
}
}
```
**orders/updated** - Order modified
```graphql
subscription {
event {
... on OrderUpdatedEvent {
order {
id
status
fulfillmentStatus
tags
}
}
}
}
```
**orders/cancelled** - Order cancellation
```graphql
subscription {
event {
... on OrderCancelledEvent {
order {
id
cancelReason
cancelledAt
}
}
}
}
```
### Product Events
**products/create** - New product
**products/update** - Product modified
**products/delete** - Product deleted
### Customer Events
**customers/create** - New customer account
**customers/update** - Customer data changed
**customers/delete** - Customer deleted (GDPR)
### Fulfillment Events
**fulfillments/created** - Items shipped
**fulfillments/updated** - Fulfillment status changed
**fulfillment_orders/scheduled** - Order ready to ship
### GDPR Events
**shop/redact** - Shop deletion requested
**customers/redact** - Customer data erasure
**orders/redact** - Order redaction (72-hour compliance)
## Webhook Manifest Configuration
Modern apps define webhooks in `shopify.app.toml`:
```toml
scopes = "write_orders,read_products"
webhooks = {
orders_create = {
uri = "api/webhooks/orders-create"
filter_query = "query { event { id occurredAt } }"
}
orders_update = {
uri = "api/webhooks/orders-update"
}
products_create = {
uri = "api/webhooks/products-create"
}
fulfillments_create = {
uri = "api/webhooks/fulfillments-create"
}
customers_redact = {
uri = "api/webhooks/gdpr/customers-redact"
}
orders_redact = {
uri = "api/webhooks/gdpr/orders-redact"
}
shop_redact = {
uri = "api/webhooks/gdpr/shop-redact"
}
}
```
## Retry Behavior
HTTPS webhooks use exponential backoff:
| Attempt | Delay | Total Time |
|---------|-------|-----------|
| 1 | Immediate | 0s |
| 2 | 10 seconds | 10s |
| 3 | 30 seconds | 40s |
| 4 | 1 minute | 1m 40s |
| 5 | 2 minutes | 3m 40s |
| 6 | 8 hours | 8h 3m 40s |
**Retry Conditions:**
- HTTP 5xx errors: always retry
- HTTP 4xx errors: no retry (except 429)
- 429 Too Many Requests: respect Retry-After header, retry
- Connection timeout: retry
- SSL/TLS errors: no retry (fix cert, reregister)
- Response timeout (30s): retry
**Idempotent Processing Pattern:**
```javascript
const db = require('./database');
app.post('/webhooks/shopify', async (req, res) => {
if (!verifyWebhookSignature(req, SECRET)) {
return res.status(401).send('Unauthorized');
}
const webhookId = req.headers['x-shopify-webhook-id'];
const event = JSON.parse(req.body);
// Check if already processed
const existing = await db.webhookLog.findOne({ webhookId });
if (existing) {
return res.status(200).send('Already processed');
}
try {
// Process event
if (event.id.includes('Order')) {
await handleOrderEvent(event);
}
// Record successful processing
await db.webhookLog.create({
webhookId,
topic: req.headers['x-shopify-topic'],
processedAt: new Date(),
status: 'success'
});
res.status(200).send('OK');
} catch (error) {
// Log error, let retry happen
console.error('Webhook processing failed:', error);
res.status(500).send('Processing error');
}
});
```
## GDPR Compliance Webhooks
Handle data erasure requests within 30 days.
**Customer Redaction (24-hour notice):**
```graphql
subscription {
event {
... on CustomerRedactEvent {
customerId
ordersToRedact
}
}
}
```
Handler implementation:
```javascript
app.post('/webhooks/gdpr/customer-redact', async (req, res) => {
const { customerId, ordersToRedact } = req.body;
// Delete all customer data
await db.customers.deleteOne({ shopifyId: customerId });
await db.orders.updateMany(
{ _id: { $in: ordersToRedact } },
{ $unset: { customerEmail: '', customerPhone: '' } }
);
res.status(200).send('Redacted');
});
```
**Shop Redaction (48-hour notice):**
```javascript
app.post('/webhooks/gdpr/shop-redact', async (req, res) => {
const { shopId } = req.body;
// Delete all shop and customer data
await db.shops.deleteOne({ shopifyId: shopId });
await db.customers.deleteMany({ shopifyId });
res.status(200).send('Shop deleted');
});
```
## Implementation Patterns
### Remix Framework Pattern
```typescript
// app/routes/webhooks/shopify.tsx
import { json, type ActionFunction } from '@remix-run/node';
import crypto from 'crypto';
function verifyWebhookSignature(
request: Request,
secret: string
): boolean {
const hmacHeader = request.headers.get('x-shopify-hmac-sha256');
const body = request.body;
const computed = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(hmacHeader || ''),
Buffer.from(computed)
);
}
export const action: ActionFunction = async ({ request }) => {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
if (!verifyWebhookSignature(request, process.env.WEBHOOK_SECRET!)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const event = await request.json();
switch (request.headers.get('x-shopify-topic')) {
case 'orders/created':
await handleOrderCreated(event);
break;
case 'products/updated':
await handleProductUpdated(event);
break;
case 'customers/redact':
await handleCustomerRedact(event);
break;
}
return json({ success: true });
};
```
### Next.js API Route Pattern
```typescript
// pages/api/webhooks/shopify.ts
import { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';
function verifySignature(req: NextApiRequest, secret: string): boolean {
const signature = req.headers['x-shopify-hmac-sha256'] as string;
const body = (req as any).rawBody;
const hash = crypto
.createHmac('sha256', secret)
.update(body)
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(hash)
);
}
// Middleware to capture raw body
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
// Capture raw body
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk);
}
(req as any).rawBody = Buffer.concat(chunks).toString('utf-8');
if (!verifySignature(req, process.env.WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'Unauthorized' });
}
const body = JSON.parse((req as any).rawBody);
// Process webhook
await processWebhook(req.headers['x-shopify-topic'] as string, body);
res.status(200).json({ success: true });
}
```
## Webhook Payload Examples
**Order Created Payload:**
```json
{
"id": 1234567890,
"email": "customer@example.com",
"display_order_number": "#1001",
"created_at": "2026-05-04T14:30:00Z",
"updated_at": "2026-05-04T14:30:00Z",
"total_price": "299.99",
"currency": "USD",
"line_items": [
{
"id": 9876543210,
"title": "Blue T-Shirt",
"variant_id": 1111111111,
"quantity": 2,
"price": "29.99"
}
],
"customer": {
"id": 5555555555,
"email": "customer@example.com",
"first_name": "John",
"last_name": "Doe"
}
}
```
**Product Updated Payload:**
```json
{
"id": 1234567890,
"title": "Blue T-Shirt",
"handle": "blue-t-shirt",
"vendor": "Example Vendor",
"product_type": "Apparel",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-05-04T14:30:00Z",
"variants": [
{
"id": 1111111111,
"title": "Small / Blue",
"sku": "BTS-S-BLU",
"price": "29.99",
"inventory_quantity": 50
}
]
}
```
## Common Gotchas
| Issue | Cause | Solution |
|-------|-------|----------|
| Invalid signature | Raw body passed to parser | Capture raw body before JSON parsing |
| Duplicate processing | Retries without idempotency | Store webhook IDs, check before processing |
| Timeout errors | Slow processing in handler | Process asynchronously, return 200 immediately |
| Missing events | Webhook deregistered | Check app installation, reauth if needed |
| GDPR violation | Not respecting 30-day deadline | Implement automated redaction workflow |
| Rate limit rejection | Too many API calls in handler | Batch requests, use bulk operations |
| SSL certificate errors | Expired or invalid cert | Renew cert, restart webhook delivery |
| Event data incomplete | Querying without proper fields | Subscribe with full field selections |
| Webhook loop | Webhook triggers same event | Add idempotency guard, check source app |
| Lost messages | HTTPS retry limit exceeded | Implement event queue, use EventBridge/Pub/Sub |
## Decision Tree: Choosing Delivery Method
```
START: Do you need real-time delivery?
├─ YES → Can you expose public HTTPS endpoint?
│ ├─ YES → Use HTTPS (traditional, simplest)
│ └─ NO → Go to AWS/GCP check
├─ NO → Use EventBridge/Pub/Sub (async)
└─ Do you use AWS?
├─ YES → Use EventBridge
└─ NO → Use Google Pub/Sub
```
## Best Practices
1. **Always verify signatures** - Never skip HMAC verification
2. **Process asynchronously** - Use queues, return 200 immediately
3. **Implement idempotency** - Store webhook IDs, deduplicate
4. **Handle retries gracefully** - Exponential backoff already applied
5. **Monitor webhook health** - Track delivery success rates
6. **Log all events** - For debugging and audit trails
7. **Use webhooks manifest** - Declarative, cleaner than APIs
8. **Respect rate limits** - Don't make too many API calls in handlers
9. **GDPR compliance** - Process redaction webhooks within 30 days
10. **Test locally** - Use ngrok or Shopify CLI to tunnel webhooks
Package details
Publisher declarations from the archived package. These are separate from our research and the live service's terms.
- Package license
- MIT
- Package author
- Shopify App Builder Contributors
- Keywords
- See publisher keywords
Declared capabilities
- Read
- Write
Package observed Oct 2, 2026.
Technical details
- First seen
- Sep 30, 2026 · 22:02 UTC
- Last seen
- Oct 3, 2026 · 00:00 UTC
- Collection status
- Collected
plugins_6a701c7b1f9481919cf7c7448ddc1bd4
Download plugin data (JSON)