🛍️

Shopify

App development, Liquid, API at scale, Hydrogen headless, and Checkout Extensibility — notes from 6 production apps and 32K+ stores.

Shopify App Architecture

Shopify apps come in two main types: **Public Apps** — listed on the Shopify App Store, use OAuth, support multiple merchants. **Custom Apps** — built for a single store, use Admin API access tokens directly. **Embedded Apps (App Bridge)** - Render inside Shopify Admin via iframe - Use `@shopify/app-bridge-react` for session tokens and Polaris UI - Auth via OAuth 2.0 + session token (no cookies in iframe) **Tech stack (production-proven):** - Backend: Node.js/NestJS or Laravel + Shopify API library - Frontend: Remix (recommended by Shopify) or Next.js + App Bridge - DB: PostgreSQL with per-shop data isolation - Queue: BullMQ / Redis for webhook processing
// Session token auth with App Bridge
import { useAppBridge } from '@shopify/app-bridge-react';
import { getSessionToken } from '@shopify/app-bridge-utils';

const app = useAppBridge();
const token = await getSessionToken(app);

// Send token in Authorization header
fetch('/api/data', {
  headers: { Authorization: `Bearer ${token}` },
});

Webhooks at Scale

Shopify sends webhooks for shop events (orders, products, checkouts...). At scale (500K+/day) you need: **Key principles:** 1. **Respond in < 5 seconds** — Shopify retries up to 19 times over 48 hours if it gets no 200 OK. 2. **Acknowledge fast, process async** — Return 200 immediately, push work to a queue (BullMQ/Redis). 3. **Verify HMAC** — Every webhook has an `X-Shopify-Hmac-Sha256` header. Always verify. 4. **Idempotency** — Webhooks can be delivered more than once. Use the webhook ID to deduplicate. 5. **Dead-letter queue** — Failed jobs after retries should land in a DLQ for inspection. **Throughput achieved:** 500K+ webhooks/day at 0.374s avg response with NestJS + BullMQ + Redis.
// HMAC verification (Node.js)
import crypto from 'crypto';

function verifyWebhook(rawBody: Buffer, hmacHeader: string, secret: string): boolean {
  const digest = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('base64');
  return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmacHeader));
}

// Async processing with BullMQ
@Post('webhook')
async handleWebhook(@Req() req, @Headers('x-shopify-hmac-sha256') hmac) {
  if (!verifyWebhook(req.rawBody, hmac, process.env.SHOPIFY_SECRET)) {
    throw new UnauthorizedException();
  }
  await this.webhookQueue.add('process', req.body); // async
  return { ok: true }; // 200 fast
}

Shopify API — REST vs GraphQL

Shopify has two APIs: **REST Admin API** (legacy, simpler) and **GraphQL Admin API** (recommended for new apps). **REST Admin API** - Straightforward CRUD on resources (/orders, /products…) - Rate limit: bucket-based (40 requests/bucket, refills 2/sec) - Still fully supported but no new features **GraphQL Admin API** - Fetch exactly the fields you need — less data transfer - Rate limit: cost-based (each query has a "cost", max 1000/second) - Supports `@defer` for large payloads, bulk operations for exports - Required for new features (Checkout Extensibility, metafields, etc.) **Bulk Operations** — for exporting large datasets: ``` mutation { bulkOperationRunQuery(query: "{ products { edges { node { id title } } } }") { ... } } ``` Poll `currentBulkOperation` until complete, then download the JSONL result URL.
// GraphQL — fetch product with metafields
const query = `
  query getProduct($id: ID!) {
    product(id: $id) {
      id
      title
      metafields(first: 10, namespace: "custom") {
        edges {
          node { key value type }
        }
      }
    }
  }
`;

const response = await shopify.graphql(query, { id: 'gid://shopify/Product/123' });

Liquid Templating

Liquid is Shopify's open-source template language. Used in themes for all storefront rendering. **Key concepts:** - **Objects**: `{{ product.title }}`, `{{ cart.total_price | money }}` - **Tags**: `{% if %}`, `{% for %}`, `{% unless %}`, `{% paginate %}` - **Filters**: `| money`, `| date`, `| truncate: 50`, `| img_url: '800x'` - **Sections**: reusable blocks with `schema` JSON for Shopify's theme editor - **Snippets**: partial files rendered with `{% render 'snippet-name' %}` **Section schema** lets merchants customize content in the theme editor without touching code — critical for Shopify Plus merchants.
{% comment %} sections/featured-product.liquid {% endcomment %}
<div class="featured-product">
  <h2>{{ section.settings.title }}</h2>
  {% assign product = all_products[section.settings.product] %}
  {% if product %}
    <img src="{{ product.featured_image | img_url: '600x' }}" alt="{{ product.title }}">
    <p>{{ product.price | money }}</p>
    <a href="{{ product.url }}">{{ section.settings.button_text }}</a>
  {% endif %}
</div>

{% schema %}
{
  "name": "Featured Product",
  "settings": [
    { "type": "text", "id": "title", "label": "Heading", "default": "Featured" },
    { "type": "product", "id": "product", "label": "Product" },
    { "type": "text", "id": "button_text", "label": "Button", "default": "Shop Now" }
  ]
}
{% endschema %}

Headless Shopify — Hydrogen

**Hydrogen** is Shopify's React framework for headless storefronts, built on Remix. **When to go headless:** - Need full UI control (custom animations, non-standard UX) - Multi-channel (web, mobile, kiosk) from one backend - Performance-critical stores on Shopify Plus **Storefront API** — the GraphQL API powering headless: - Public-facing (products, collections, cart, checkout) - Uses Storefront access token (public, safe for client-side) - Supports cart, checkout, customer account queries **Key Hydrogen primitives:** - `createStorefrontClient` — typed Storefront API client - `<ShopifyProvider>` — context for cart & i18n - `<CartForm>` — handles add/update/remove cart actions - Remix loaders/actions for server-side data fetching + mutations
// app/routes/products.$handle.tsx (Hydrogen + Remix)
import { useLoaderData } from '@remix-run/react';
import { json, type LoaderFunctionArgs } from '@shopify/remix-oxygen';

export async function loader({ params, context }: LoaderFunctionArgs) {
  const { product } = await context.storefront.query(PRODUCT_QUERY, {
    variables: { handle: params.handle },
  });
  if (!product) throw new Response('Not found', { status: 404 });
  return json({ product });
}

const PRODUCT_QUERY = `
  query Product($handle: String!) {
    product(handle: $handle) {
      id title descriptionHtml
      priceRange { minVariantPrice { amount currencyCode } }
      variants(first: 10) { nodes { id title availableForSale } }
    }
  }
`;