RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Articles/Dev Tools
Dev Tools/2026-04-19Advanced

Build a Brand EC App with Rork × Shopify Storefront API — Products, Cart, Checkout, Order History, and Push Notifications

A complete guide to building a branded e-commerce mobile app with Rork and the Shopify Storefront API. Covers product listing, cart management, checkout flow, order management, and push notifications — with production-ready code and App Store submission tips.

ShopifyStorefront APIecommerceReact Native209Expo149checkoutcartpush notifications11advanced6

If you run a Shopify store and want a dedicated mobile app, the conversion rate difference is hard to ignore. Web stores typically convert at 2–3%, while a well-built branded app can push that to 5–8% or higher. The reason is simple: an app is a committed relationship, not a casual visit.

Rork lets you build that app without a full engineering team. You can leverage your existing Shopify backend for products, inventory, and orders while building a polished native experience on iOS and Android.

This guide covers the full stack — from Storefront API authentication to push notifications triggered by Shopify webhooks. The code examples are production-tested, and I've included the mistakes I made along the way so you don't have to repeat them.


Understanding the Shopify Storefront API

Shopify offers multiple APIs, but the one you need for a mobile app is the Storefront API. Unlike the Admin API (which manages products and orders from the merchant side), the Storefront API is designed for customer-facing use: browsing products, managing carts, and completing checkout.

Getting Your Access Token

Create a custom app in your Shopify store admin or Partner Dashboard, then generate a Storefront API access token. This token is publicly accessible by design — unlike Admin API keys, it's safe to use in a client-side app.

Required Storefront API scopes:

unauthenticated_read_product_listings
unauthenticated_read_product_inventory
unauthenticated_write_checkouts
unauthenticated_read_checkouts
unauthenticated_write_customers
unauthenticated_read_customer_tags

Add these to your Rork project as environment variables:

EXPO_PUBLIC_SHOPIFY_STORE_DOMAIN=your-store.myshopify.com
EXPO_PUBLIC_SHOPIFY_STOREFRONT_TOKEN=your_storefront_access_token

Setting Up the GraphQL Client

The Storefront API uses GraphQL, not REST. Here's a lean fetch wrapper that handles both HTTP errors and GraphQL-level errors:

// lib/shopify.ts
const SHOPIFY_DOMAIN = process.env.EXPO_PUBLIC_SHOPIFY_STORE_DOMAIN;
const STOREFRONT_TOKEN = process.env.EXPO_PUBLIC_SHOPIFY_STOREFRONT_TOKEN;
const API_VERSION = "2024-01";
 
export async function shopifyFetch<T>({
  query,
  variables,
}: {
  query: string;
  variables?: Record<string, unknown>;
}): Promise<{ data: T; errors?: { message: string }[] }> {
  const response = await fetch(
    `https://${SHOPIFY_DOMAIN}/api/${API_VERSION}/graphql.json`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Shopify-Storefront-Access-Token": STOREFRONT_TOKEN\!,
      },
      body: JSON.stringify({ query, variables }),
    }
  );
 
  if (\!response.ok) {
    throw new Error(`Shopify API error: ${response.status} ${response.statusText}`);
  }
 
  const json = await response.json();
 
  // Shopify returns HTTP 200 even for GraphQL-level errors
  if (json.errors && json.errors.length > 0) {
    throw new Error(`GraphQL errors: ${json.errors.map((e: { message: string }) => e.message).join(", ")}`);
  }
 
  return json;
}

Why this matters: Shopify often returns HTTP 200 while embedding errors in the response body. Checking only response.ok will cause silent failures that are hard to debug. Always check both layers.


Product Listing with Pagination

Fetching Products

Shopify products are grouped into Collections. A home screen organized by collection tends to feel more intentional than a flat list.

// hooks/useProducts.ts
import { useState, useEffect } from "react";
import { shopifyFetch } from "../lib/shopify";
 
interface Product {
  id: string;
  title: string;
  handle: string;
  priceRange: {
    minVariantPrice: { amount: string; currencyCode: string };
  };
  images: {
    edges: { node: { url: string; altText: string | null } }[];
  };
  availableForSale: boolean;
}
 
const PRODUCTS_QUERY = `
  query GetProducts($first: Int\!, $after: String) {
    products(first: $first, after: $after, sortKey: UPDATED_AT, reverse: true) {
      pageInfo {
        hasNextPage
        endCursor
      }
      edges {
        node {
          id
          title
          handle
          availableForSale
          priceRange {
            minVariantPrice {
              amount
              currencyCode
            }
          }
          images(first: 1) {
            edges {
              node {
                url
                altText
              }
            }
          }
        }
      }
    }
  }
`;
 
export function useProducts() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [hasNextPage, setHasNextPage] = useState(false);
  const [cursor, setCursor] = useState<string | null>(null);
 
  const fetchProducts = async (after?: string) => {
    try {
      setLoading(true);
      const { data } = await shopifyFetch<{
        products: {
          pageInfo: { hasNextPage: boolean; endCursor: string };
          edges: { node: Product }[];
        };
      }>({
        query: PRODUCTS_QUERY,
        variables: { first: 20, after: after ?? null },
      });
 
      const newProducts = data.products.edges.map((edge) => edge.node);
      setProducts((prev) => (after ? [...prev, ...newProducts] : newProducts));
      setHasNextPage(data.products.pageInfo.hasNextPage);
      setCursor(data.products.pageInfo.endCursor);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to load products");
    } finally {
      setLoading(false);
    }
  };
 
  useEffect(() => {
    fetchProducts();
  }, []);
 
  const loadMore = () => {
    if (hasNextPage && cursor) fetchProducts(cursor);
  };
 
  return { products, loading, error, hasNextPage, loadMore };
}

Expected behavior: Loads 20 products initially. Calling loadMore() appends the next 20. Wire this to FlatList's onEndReached for infinite scroll.

Common Mistake #1: Ignoring Out-of-Stock Products

Products with availableForSale: false will cause a silent failure when added to cart. Filter them from the display list, or show them grayed out with the add-to-cart button disabled.

// ❌ Showing all products regardless of stock
const displayProducts = products;
 
// ✅ Filter out-of-stock items (or disable the add-to-cart button)
const displayProducts = products.filter((p) => p.availableForSale);

Cart Management with AsyncStorage Persistence

Shopify carts are managed through cartCreate, cartLinesAdd, cartLinesUpdate, and cartLinesRemove mutations. The key design decision: persist the cart ID in AsyncStorage so the cart survives app restarts.

// context/CartContext.tsx
import React, { createContext, useContext, useState, useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { shopifyFetch } from "../lib/shopify";
 
interface CartLine {
  id: string;
  quantity: number;
  merchandise: {
    id: string;
    title: string;
    price: { amount: string; currencyCode: string };
    product: { title: string; images: { edges: { node: { url: string } }[] } };
  };
}
 
interface Cart {
  id: string;
  checkoutUrl: string;
  lines: { edges: { node: CartLine }[] };
  cost: {
    totalAmount: { amount: string; currencyCode: string };
  };
}
 
const CART_ID_KEY = "@shopify_cart_id";
 
const CREATE_CART = `
  mutation CreateCart($lines: [CartLineInput\!]) {
    cartCreate(input: { lines: $lines }) {
      cart {
        id checkoutUrl
        lines(first: 50) { edges { node { id quantity merchandise { id title price { amount currencyCode } product { title images(first: 1) { edges { node { url } } } } } } } }
        cost { totalAmount { amount currencyCode } }
      }
      userErrors { field message }
    }
  }
`;
 
const ADD_LINES = `
  mutation AddCartLines($cartId: ID\!, $lines: [CartLineInput\!]\!) {
    cartLinesAdd(cartId: $cartId, lines: $lines) {
      cart {
        id checkoutUrl
        lines(first: 50) { edges { node { id quantity merchandise { id title price { amount currencyCode } product { title images(first: 1) { edges { node { url } } } } } } } }
        cost { totalAmount { amount currencyCode } }
      }
      userErrors { field message }
    }
  }
`;
 
export function useCartOperations() {
  const [cart, setCart] = useState<Cart | null>(null);
  const [loading, setLoading] = useState(false);
 
  const addToCart = useCallback(async (merchandiseId: string, quantity: number) => {
    setLoading(true);
    try {
      const savedCartId = await AsyncStorage.getItem(CART_ID_KEY);
 
      if (savedCartId) {
        const { data } = await shopifyFetch<{ cartLinesAdd: { cart: Cart; userErrors: { message: string }[] } }>({
          query: ADD_LINES,
          variables: { cartId: savedCartId, lines: [{ merchandiseId, quantity }] },
        });
 
        if (data.cartLinesAdd.userErrors.length > 0) {
          throw new Error(data.cartLinesAdd.userErrors[0].message);
        }
        setCart(data.cartLinesAdd.cart);
      } else {
        const { data } = await shopifyFetch<{ cartCreate: { cart: Cart; userErrors: { message: string }[] } }>({
          query: CREATE_CART,
          variables: { lines: [{ merchandiseId, quantity }] },
        });
 
        if (data.cartCreate.userErrors.length > 0) {
          throw new Error(data.cartCreate.userErrors[0].message);
        }
        const newCart = data.cartCreate.cart;
        await AsyncStorage.setItem(CART_ID_KEY, newCart.id);
        setCart(newCart);
      }
    } finally {
      setLoading(false);
    }
  }, []);
 
  return { cart, loading, addToCart };
}

Common Mistake #2: Cart Expiration After 10 Days

Shopify carts expire after 10 days of inactivity. When the stored cart ID becomes invalid, the API returns a "Cart not found" error. Handle this gracefully:

async function safeGetCart(cartId: string): Promise<Cart | null> {
  try {
    return await fetchCart(cartId);
  } catch (err) {
    if (err instanceof Error && err.message.includes("Cart not found")) {
      await AsyncStorage.removeItem(CART_ID_KEY);
      return null; // Will trigger fresh cart creation on next add
    }
    throw err;
  }
}

Checkout Flow — WebView vs. Native Sheet

Two approaches for checkout in a Shopify mobile app:

Approach A: Open checkoutUrl in a WebView (recommended for most teams)

Every cart includes a checkoutUrl pointing to Shopify's hosted checkout page. Open it with expo-web-browser and you get credit card, Apple Pay, and Shop Pay support out of the box.

import * as WebBrowser from "expo-web-browser";
import AsyncStorage from "@react-native-async-storage/async-storage";
 
export async function openCheckout(checkoutUrl: string): Promise<{ success: boolean }> {
  try {
    const result = await WebBrowser.openAuthSessionAsync(
      checkoutUrl,
      "yourapp://checkout-complete"
    );
 
    if (result.type === "success") {
      // Clear the cart after successful checkout
      await AsyncStorage.removeItem("@shopify_cart_id");
      return { success: true };
    }
 
    return { success: false };
  } catch (err) {
    console.error("Checkout error:", err);
    throw new Error("Failed to open checkout. Please try again.");
  }
}

Approach B: @shopify/checkout-sheet-kit (advanced)

Shopify's official library renders a native checkout sheet inside the app. It requires additional native module setup for Expo managed workflow. Start with Approach A, then migrate to B once you've validated the conversion rate justifies the engineering investment.

A note on App Store guidelines: Apple requires in-app purchases through StoreKit for digital content. However, physical goods are explicitly exempt. A Shopify EC app selling physical products can use external payment without StoreKit.


Push Notifications via Shopify Webhooks

The Architecture

Shopify → Webhook → Your server (Cloudflare Workers / Supabase Edge Functions) → Expo Push Service → Customer device. For a detailed walkthrough of setting up Expo push notifications from scratch, see the Expo Push Notification Backend Guide.

// Cloudflare Worker handling Shopify fulfillment webhook
export default {
  async fetch(request: Request): Promise<Response> {
    const body = await request.json() as {
      id: number;
      email: string;
      fulfillment_status: string;
      shipping_address?: { name: string };
    };
 
    // Always verify the webhook signature first
    const hmacHeader = request.headers.get("X-Shopify-Hmac-Sha256");
    const isValid = await verifyShopifyWebhook(request, hmacHeader ?? "");
    if (\!isValid) {
      return new Response("Unauthorized", { status: 401 });
    }
 
    if (body.fulfillment_status === "fulfilled") {
      const pushToken = await getPushTokenByEmail(body.email);
      if (pushToken) {
        await fetch("https://exp.host/--/api/v2/push/send", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            to: pushToken,
            title: "🎁 Your order has shipped\!",
            body: "Your order is on its way. Track it in the app.",
            data: { orderId: body.id.toString(), screen: "OrderDetail" },
          }),
        });
      }
    }
 
    return new Response("OK", { status: 200 });
  },
};
 
async function verifyShopifyWebhook(request: Request, hmacHeader: string): Promise<boolean> {
  const rawBody = await request.clone().text();
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(process.env.SHOPIFY_WEBHOOK_SECRET\!),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  );
  const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(rawBody));
  const computedHmac = btoa(String.fromCharCode(...new Uint8Array(signature)));
  return computedHmac === hmacHeader;
}

Common Mistake #3: Skipping Webhook Verification

I see this cut in demos all the time. Without signature verification, anyone who knows your webhook endpoint can trigger push notifications to your users. The verification code is four lines. Don't skip it.

Registering Push Tokens

Link Expo push tokens to Shopify customer emails in your own database. After customer login, call this:

import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
 
export async function registerPushToken(customerEmail: string, supabase: ReturnType<typeof createClient>) {
  const { status } = await Notifications.requestPermissionsAsync();
  if (status \!== "granted") return;
 
  const tokenData = await Notifications.getExpoPushTokenAsync({
    projectId: process.env.EXPO_PUBLIC_PROJECT_ID,
  });
 
  const { error } = await supabase.from("push_tokens").upsert(
    {
      customer_email: customerEmail,
      push_token: tokenData.data,
      platform: Platform.OS,
      updated_at: new Date().toISOString(),
    },
    { onConflict: "customer_email,platform" }
  );
 
  if (error) {
    console.error("Failed to register push token:", error.message);
  }
}

Common Implementation Pitfalls

Pitfall #1: Shopify Cart IDs use GraphQL global ID format

Cart IDs look like gid://shopify/Cart/xxxxxxxx. Store and use this string exactly as returned. Any encoding or transformation will cause "Invalid cart ID" errors.

Pitfall #2: Confusing Product ID with Variant (Merchandise) ID

cartLinesAdd expects a merchandiseId, which is the ProductVariant ID, not the Product ID. Always fetch variants in your product query:

variants(first: 10) {
  edges {
    node {
      id
      title
      availableForSale
      price { amount currencyCode }
    }
  }
}

Then pass the selected variant's id as merchandiseId when adding to cart.

Pitfall #3: Inventory data is cached, not real-time

The Storefront API caches inventory data for a short period. It's possible for an availableForSale: true product to become unavailable between when the user views it and when they check out. Design your UX to handle out-of-stock errors gracefully at the checkout step, not just at the product detail level.

Pitfall #4: checkoutUrl is single-use

Each checkoutUrl is tied to the cart state at a specific moment. After a successful checkout, calling the same URL again returns a "Checkout is already completed" error. Always clear the local cart ID after payment and recreate the cart for new sessions.


App Store Review — What Gets E-commerce Apps Rejected

Physical goods e-commerce apps have specific review considerations that are different from digital content apps.

Review point #1: External payment disclosure

Apple requires that the payment flow using an external processor be clearly communicated to users. A short note near the checkout button ("Checkout is handled by Shopify's secure payment system") satisfies this in practice.

Review point #2: Privacy policy and data handling

You're collecting emails, order history, and shipping addresses. Your App Store privacy policy page must accurately reflect this. Pay particular attention to the "Data Linked to You" section in App Store Connect's privacy questionnaire.

Review point #3: Demo account with functional checkout

Reviewers need to be able to test the complete purchase flow. Set up a demo Shopify account with test products and provide the credentials in your review notes. Use Shopify's test payment gateway so reviewers can complete checkout without a real card.

Review point #4: Account deletion

Since 2022, any app with account creation must also include account deletion. Implement a customer delete flow using Shopify's customerDelete mutation, or provide a clear path to contact support for account removal.


Where to Go From Here

The Shopify Storefront API covers far more than what fits in one article — collections, discount codes, metafields for custom product data, and the Customer API for full account management are all worth exploring as your app grows.

The best starting point is to create a free Shopify development store in the Partners Dashboard, generate a Storefront API token, and get a product list rendering in Rork. Once you see real products on screen, the rest of the implementation — cart, checkout, notifications — follows naturally from the patterns in this guide.


Customer Authentication and Order History

Storefront Customer Login

Shopify's Storefront API supports customer authentication through the customerAccessTokenCreate mutation. This gives you access to order history, saved addresses, and personalized experiences.

// lib/shopifyAuth.ts
const LOGIN_MUTATION = `
  mutation CustomerLogin($email: String\!, $password: String\!) {
    customerAccessTokenCreate(input: { email: $email, password: $password }) {
      customerAccessToken {
        accessToken
        expiresAt
      }
      customerUserErrors {
        code
        field
        message
      }
    }
  }
`;
 
const REGISTER_MUTATION = `
  mutation CustomerRegister($input: CustomerCreateInput\!) {
    customerCreate(input: $input) {
      customer {
        id
        email
        firstName
        lastName
      }
      customerUserErrors {
        code
        field
        message
      }
    }
  }
`;
 
export async function loginCustomer(email: string, password: string) {
  const { data } = await shopifyFetch<{
    customerAccessTokenCreate: {
      customerAccessToken: { accessToken: string; expiresAt: string } | null;
      customerUserErrors: { code: string; field: string; message: string }[];
    };
  }>({
    query: LOGIN_MUTATION,
    variables: { email, password },
  });
 
  const result = data.customerAccessTokenCreate;
 
  if (result.customerUserErrors.length > 0) {
    const error = result.customerUserErrors[0];
    switch (error.code) {
      case "UNIDENTIFIED_CUSTOMER":
        throw new Error("Invalid email or password");
      case "TOO_MANY_FAILED_ATTEMPTS":
        throw new Error("Too many failed attempts. Please try again later.");
      case "CUSTOMER_DISABLED":
        throw new Error("Your account has been disabled. Contact support.");
      default:
        throw new Error(error.message);
    }
  }
 
  if (\!result.customerAccessToken) {
    throw new Error("Login failed — please try again");
  }
 
  return result.customerAccessToken;
}
 
export async function registerCustomer(input: {
  email: string;
  password: string;
  firstName?: string;
  lastName?: string;
}) {
  const { data } = await shopifyFetch<{
    customerCreate: {
      customer: { id: string; email: string } | null;
      customerUserErrors: { code: string; field: string; message: string }[];
    };
  }>({
    query: REGISTER_MUTATION,
    variables: { input },
  });
 
  const result = data.customerCreate;
 
  if (result.customerUserErrors.length > 0) {
    const error = result.customerUserErrors[0];
    if (error.code === "CUSTOMER_DISABLED") {
      throw new Error("An account with this email already exists");
    }
    if (error.field?.includes("email")) {
      throw new Error("Please enter a valid email address");
    }
    if (error.field?.includes("password")) {
      throw new Error("Password must be at least 5 characters");
    }
    throw new Error(error.message);
  }
 
  if (\!result.customer) {
    throw new Error("Registration failed — please try again");
  }
 
  return result.customer;
}

Store the returned accessToken in expo-secure-store (not AsyncStorage — it's encrypted). The token expires after a period set by your Shopify config, typically 30 days.

import * as SecureStore from "expo-secure-store";
 
const TOKEN_KEY = "shopify_customer_token";
const TOKEN_EXPIRY_KEY = "shopify_customer_token_expiry";
 
export async function saveCustomerToken(accessToken: string, expiresAt: string) {
  await SecureStore.setItemAsync(TOKEN_KEY, accessToken);
  await SecureStore.setItemAsync(TOKEN_EXPIRY_KEY, expiresAt);
}
 
export async function getValidCustomerToken(): Promise<string | null> {
  const token = await SecureStore.getItemAsync(TOKEN_KEY);
  const expiresAt = await SecureStore.getItemAsync(TOKEN_EXPIRY_KEY);
 
  if (\!token || \!expiresAt) return null;
 
  const isExpired = new Date(expiresAt) <= new Date();
  if (isExpired) {
    await SecureStore.deleteItemAsync(TOKEN_KEY);
    await SecureStore.deleteItemAsync(TOKEN_EXPIRY_KEY);
    return null;
  }
 
  return token;
}

Fetching Order History

With a valid customer access token, you can fetch the customer's order history. Pass the token in the request headers:

const CUSTOMER_ORDERS_QUERY = `
  query CustomerOrders($customerAccessToken: String\!, $first: Int\!) {
    customer(customerAccessToken: $customerAccessToken) {
      orders(first: $first, sortKey: PROCESSED_AT, reverse: true) {
        edges {
          node {
            id
            name
            processedAt
            fulfillmentStatus
            financialStatus
            currentTotalPrice {
              amount
              currencyCode
            }
            lineItems(first: 5) {
              edges {
                node {
                  title
                  quantity
                  variant {
                    image {
                      url
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
`;
 
export async function fetchCustomerOrders(customerAccessToken: string, first = 10) {
  const { data } = await shopifyFetch<{
    customer: {
      orders: {
        edges: {
          node: {
            id: string;
            name: string;
            processedAt: string;
            fulfillmentStatus: string;
            financialStatus: string;
            currentTotalPrice: { amount: string; currencyCode: string };
            lineItems: { edges: { node: { title: string; quantity: number; variant: { image: { url: string } | null } | null } }[] };
          };
        }[];
      };
    } | null;
  }>({
    query: CUSTOMER_ORDERS_QUERY,
    variables: { customerAccessToken, first },
  });
 
  if (\!data.customer) {
    // Token likely expired
    throw new Error("Session expired — please log in again");
  }
 
  return data.customer.orders.edges.map((edge) => edge.node);
}

What fulfillmentStatus values mean:

  • UNFULFILLED — Order received, not yet shipped
  • PARTIALLY_FULFILLED — Some items shipped
  • FULFILLED — All items shipped
  • RESTOCKED — Order cancelled and inventory returned

Display these with human-readable labels and map shipping status icons accordingly in your UI.


Database Schema for Push Token Management

Your server needs to store the mapping between Shopify customer emails and Expo push tokens. Here's a Supabase schema that handles multiple devices per customer:

-- Supabase migration
create table push_tokens (
  id uuid primary key default gen_random_uuid(),
  customer_email text not null,
  push_token text not null,
  platform text not null check (platform in ('ios', 'android')),
  app_version text,
  created_at timestamptz default now(),
  updated_at timestamptz default now(),
  unique (customer_email, push_token)
);
 
-- Index for fast lookups by email
create index idx_push_tokens_email on push_tokens (customer_email);
 
-- Auto-update the updated_at timestamp
create or replace function update_updated_at()
returns trigger as $$
begin
  new.updated_at = now();
  return new;
end;
$$ language plpgsql;
 
create trigger push_tokens_updated_at
  before update on push_tokens
  for each row execute function update_updated_at();

The unique(customer_email, push_token) constraint means the same device won't be registered twice if the user logs in multiple times. Using upsert on this table keeps everything idempotent.


Handling Discount Codes

Discount codes are a common request for branded e-commerce apps. Shopify supports applying discounts directly to the cart through cartDiscountCodesUpdate:

const APPLY_DISCOUNT_MUTATION = `
  mutation ApplyDiscount($cartId: ID\!, $discountCodes: [String\!]\!) {
    cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) {
      cart {
        id
        discountCodes {
          code
          applicable
        }
        cost {
          totalAmount { amount currencyCode }
          subtotalAmount { amount currencyCode }
          totalTaxAmount { amount currencyCode }
        }
      }
      userErrors {
        field
        message
      }
    }
  }
`;
 
export async function applyDiscountCode(cartId: string, code: string) {
  const { data } = await shopifyFetch<{
    cartDiscountCodesUpdate: {
      cart: {
        id: string;
        discountCodes: { code: string; applicable: boolean }[];
        cost: { totalAmount: { amount: string; currencyCode: string } };
      };
      userErrors: { field: string; message: string }[];
    };
  }>({
    query: APPLY_DISCOUNT_MUTATION,
    variables: { cartId, discountCodes: [code] },
  });
 
  const result = data.cartDiscountCodesUpdate;
 
  if (result.userErrors.length > 0) {
    throw new Error(result.userErrors[0].message);
  }
 
  const appliedCode = result.cart.discountCodes.find((d) => d.code === code);
 
  if (\!appliedCode?.applicable) {
    throw new Error("This discount code is not applicable to your current cart");
  }
 
  return result.cart;
}

Important: The applicable field tells you whether the discount actually applied. A code can be "valid" but not applicable if it doesn't meet conditions (e.g., minimum order value). Always check this field and provide clear feedback to the user.


Performance Considerations for Product Images

Shopify product images can be large. The Storefront API supports server-side image transformation through URL parameters:

// Transform Shopify image URL for optimal mobile display
export function getOptimizedImageUrl(
  originalUrl: string,
  options: {
    width?: number;
    height?: number;
    scale?: 1 | 2 | 3;
    format?: "jpg" | "png" | "webp";
    crop?: "center" | "top" | "bottom" | "left" | "right";
  } = {}
): string {
  const { width = 400, height, scale = 2, format = "webp", crop = "center" } = options;
  const url = new URL(originalUrl);
 
  // Shopify image transformation parameters
  url.searchParams.set("width", String(width));
  if (height) url.searchParams.set("height", String(height));
  url.searchParams.set("scale", String(scale));
  url.searchParams.set("format", format);
  url.searchParams.set("crop", crop);
 
  return url.toString();
}
 
// Usage in component
const thumbnailUrl = getOptimizedImageUrl(product.images.edges[0]?.node.url, {
  width: 300,
  height: 300,
  scale: 2,
  crop: "center",
});

For a product grid with 20 items, loading full-resolution images would download 20–40MB per screen load. Requesting 300×300 WebP images at 2x scale brings this down to 1–3MB while still looking sharp on modern screens.


Monitoring and Error Tracking

Production e-commerce apps need monitoring. A crashed checkout flow means lost revenue. Set up Sentry with Expo for automatic error capture:

// app/_layout.tsx
import * as Sentry from "@sentry/react-native";
 
Sentry.init({
  dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
  tracesSampleRate: 0.2, // Sample 20% of transactions for performance
  environment: __DEV__ ? "development" : "production",
});
 
// Wrap checkout operations with explicit Sentry breadcrumbs
export async function trackedCheckout(checkoutUrl: string, cartId: string) {
  Sentry.addBreadcrumb({
    category: "checkout",
    message: "Opening checkout",
    data: { cartId },
    level: "info",
  });
 
  try {
    const result = await openCheckout(checkoutUrl);
    Sentry.addBreadcrumb({
      category: "checkout",
      message: result.success ? "Checkout completed" : "Checkout cancelled",
      level: "info",
    });
    return result;
  } catch (err) {
    Sentry.captureException(err, {
      tags: { feature: "checkout" },
      extra: { cartId, checkoutUrl },
    });
    throw err;
  }
}

Pair this with Shopify's native analytics (available in your store admin) to correlate mobile checkout events with actual order data.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Dev Tools2026-04-09
Rork × OneSignal: Supercharging Push Notifications in Your App
Learn how to integrate OneSignal into your Rork app for advanced push notifications — covering SDK setup, user segmentation, rich notifications, and delivery analytics.
Dev Tools2026-07-15
The Comma That Fell to the Start of a Line: Rork, Quote Cards, and Japanese Line Breaking
React Native's Text component does not guarantee Japanese line-breaking rules. Here are the violation rates I measured, a WORD JOINER implementation that survives copy and VoiceOver, an onTextLayout audit harness, and how Rork Max (SwiftUI) differs.
Dev Tools2026-07-10
Adding React Compiler to Expo Let Me Delete 41 Hand-Written memo Calls
I enabled React Compiler on Rork-generated React Native screens and measured the rerender counts with Profiler. Here is how I decided which memo and useCallback calls were safe to delete, how to find the components the compiler bailed out on, and how to catch regressions in CI.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →