🛍️
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 } }
}
}
`;