When you need the Storefront API
You need the Storefront API when you're building outside Shopify's theme layer — a Hydrogen storefront, a Next.js app, a mobile app, a headless commerce experience, or a custom checkout flow. Anything that reads product data or manipulates a cart from a browser client uses the Storefront API. It's public-facing, safe to expose in JavaScript bundles, and rate-limited per IP.
You do not use the Storefront API for merchant admin operations (creating products, editing prices, managing inventory) — those live in the Admin API and require app authentication. This article covers Storefront only.
Authentication: public vs private tokens
The Storefront API accepts two token types, each with different rate limits and use cases:
| Token type | Where you use it | Rate limit | Header |
|---|---|---|---|
| Public access token | Browser / mobile client | 60 requests/minute per IP | X-Shopify-Storefront-Access-Token |
| Private access token | Server-side (Node, edge functions) | Bucket-based (~500-2000 pts/min) | Shopify-Storefront-Private-Token |
Get a public token: Shopify admin → Sales channels → Headless → generate. Get a private token: Custom app → Storefront API access scopes → private token. Use private for anything you can move server-side — better rate limits and you can add mutations that require elevated scopes (buyer identity, checkout).
The core variant query
Fetching a product with all its variants + inventory + options is the most common operation. Here's the shape you'll use 90% of the time:
query ProductWithVariants($handle: String!) {
product(handle: $handle) {
id
title
descriptionHtml
options {
name
values
}
variants(first: 100) {
edges {
node {
id
title
sku
availableForSale
quantityAvailable
price { amount currencyCode }
compareAtPrice { amount currencyCode }
selectedOptions { name value }
image { url altText width height }
}
}
}
}
}
Pass { "handle": "cotton-tshirt" } as variables. Returns everything you need to render a variant picker — option names, values, per-variant price, availability, image, and inventory count. availableForSale combines inventory + selling policy; use it directly for sold-out UI. quantityAvailable only returns on Shopify Plus stores that explicitly enable it.
Paginating variants past 100
Since Shopify raised the variant limit to 2,048 per product in October 2025, products can have far more than a single page can return. Paginate with a cursor:
query ProductVariantsPaginated($handle: String!, $cursor: String) {
product(handle: $handle) {
variants(first: 250, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node { id sku price { amount } selectedOptions { name value } }
}
}
}
}
Loop client-side: pass endCursor as $cursor on the next request until hasNextPage is false. For products with 500+ variants, load the first 100 for initial render + lazy-load the rest on interaction.
Adding a variant to cart with line-item properties
Cart mutations are the second most common Storefront operation. Adding a personalized product means passing custom attributes (line-item properties) alongside the variant ID:
mutation AddPersonalizedToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
id
totalQuantity
lines(first: 20) {
edges { node {
id
quantity
merchandise { ... on ProductVariant { title price { amount } } }
attributes { key value }
}}
}
cost { totalAmount { amount currencyCode } }
}
userErrors { field message }
}
}
Variables:
{
"cartId": "gid://shopify/Cart/hWN3...",
"lines": [{
"merchandiseId": "gid://shopify/ProductVariant/40123456789",
"quantity": 1,
"attributes": [
{ "key": "Engraving Text", "value": "Emma 2026" },
{ "key": "Font", "value": "Great Vibes" },
{ "key": "_print_file_url","value": "https://cdn.printitmyway.app/orders/abc123.png" }
]
}]
}
_ (underscore) are hidden from customer-facing cart/checkout displays but still travel with the order and appear in the merchant admin. Use them for internal metadata like print file URLs, generated IDs, and timestamps.
Creating a cart from scratch
The first time a visitor adds to cart, you create a cart and persist its ID (localStorage or a cookie) for subsequent operations:
mutation CartCreate($input: CartInput!) {
cartCreate(input: $input) {
cart { id checkoutUrl }
userErrors { field message }
}
}
Save the returned id for future cartLinesAdd / cartLinesUpdate / cartLinesRemove calls. Send the visitor to checkoutUrl when they're ready to check out — Shopify's hosted checkout takes it from there.
Rate limits + throttling
Storefront API uses two limiting strategies:
- Public token: hard cap of 60 requests/minute per IP address
- Private token: query cost bucket system, refilled at ~50 points/second up to a 1,000-point bucket (roughly 500 simple queries/minute per store)
Every response includes extensions.cost with actualQueryCost and throttleStatus.currentlyAvailable. Watch these on private token requests. On a 429 response, back off with exponential retry — 1s, 2s, 4s, 8s. Persistent 429s mean you're querying too aggressively; add caching.
Caching strategy
Cache aggressively:
- Product catalog (title, description, options) — cache 5-15 minutes at the edge (Vercel, Cloudflare)
- Variant prices + inventory — cache 30-60 seconds; users tolerate slight staleness better than a slow site
- Cart operations — never cache; must be real-time per user
- Checkout URL — never cache; changes per cart
Hydrogen (Shopify's React framework) example
// app/routes/products.$handle.tsx
import { defer } from '@shopify/remix-oxygen';
import { useLoaderData, Await } from '@remix-run/react';
import { Suspense } from 'react';
const PRODUCT_QUERY = `#graphql
query Product($handle: String!) {
product(handle: $handle) {
id title
variants(first: 100) {
edges { node { id title sku availableForSale price { amount } selectedOptions { name value } }}
}
}
}`;
export async function loader({ params, context }) {
const { product } = await context.storefront.query(PRODUCT_QUERY, {
variables: { handle: params.handle },
cache: context.storefront.CacheShort(), // ~1 min edge cache
});
return defer({ product });
}
export default function Product() {
const { product } = useLoaderData();
return <VariantPicker product={product} />;
}
Next.js (App Router) example
// app/products/[handle]/page.tsx
async function getProduct(handle) {
const res = await fetch('https://your-shop.myshopify.com/api/2025-07/graphql.json', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': process.env.SHOPIFY_STOREFRONT_TOKEN,
},
body: JSON.stringify({
query: `{ product(handle: "${handle}") { id title variants(first: 100) { edges { node { id title sku availableForSale price { amount }}}}}}`,
}),
next: { revalidate: 60 }, // Next.js ISR — 60-second cache
});
const { data } = await res.json();
return data.product;
}
export default async function ProductPage({ params }) {
const product = await getProduct(params.handle);
return <VariantPicker product={product} />;
}
Common errors + how to handle
| Error / Symptom | Cause | Fix |
|---|---|---|
{ errors: [{ message: "access denied" }] } | Wrong token type for operation | Buyer identity mutations require private token or Shopify customer access token |
429 Too Many Requests | Rate limit hit | Exponential backoff; cache aggressively; move to private token |
Variant returns but availableForSale: false | Inventory 0 or selling policy blocks | Enable "continue selling when out of stock" if it's a POD product |
| Attributes not appearing on order | Key contains invalid characters or exceeds 100 chars | Sanitize keys; strip special chars; truncate to 100 |
Field 'quantityAvailable' doesn't exist | Store isn't Shopify Plus or hasn't enabled the field | Fall back to availableForSale (boolean) |
What Print It My Way exposes for headless stores
If you're building a headless Shopify storefront and want personalization (custom text, photo upload, per-character pricing) without rebuilding the personalizer, Print It My Way exposes a REST endpoint at https://api.printitmyway.app/v1/personalizer/{productId} returning the personalizer template as JSON. You render fields in your own UI, POST the customer's input back, and receive a { line_item_attributes: [...], print_file_url: "..." } response ready to pass into cartLinesAdd. Full docs on the app dashboard.
Frequently asked questions
How do I query Shopify variants via Storefront API?
Use the product(handle: "…") { variants(first: 100) { edges { node { … } } } } query. Returns SKU, price, availability, options, and image per variant. Paginate with after: $cursor for products with over 100 variants.
Storefront API vs Admin API — which for variants?
Storefront API for reading + cart operations from client code (public-safe). Admin API for creating, updating, or deleting variants server-side (requires app auth). Never expose Admin API tokens in browser bundles.
How do I query variant inventory?
availableForSale (boolean) is universal. quantityAvailable (integer) requires Shopify Plus and explicit enablement. For "only 3 left" messaging, use quantityAvailable; for basic sold-out UI, use availableForSale.
How do I add a variant to cart via API?
cartLinesAdd mutation with merchandiseId: "gid://shopify/ProductVariant/…", quantity, and optional attributes: [{key, value}] array for line-item properties.
How do I pass line-item properties via API?
The attributes field on the CartLineInput. Each attribute is {key, value}. Keys starting with underscore are hidden from customer displays. Max 100 chars on key, 255 on value.
What are Storefront API rate limits?
Public token: 60 req/min per IP. Private token: cost-based bucket, roughly 500 simple queries/minute per store. Check extensions.cost.throttleStatus in each response.
Best framework for headless Shopify?
Hydrogen (Shopify's official React framework, deployed on Oxygen) has the deepest Storefront integration. Next.js with the App Router + ISR is the most popular non-Shopify choice. Both support cart, checkout, and product queries out of the box.
Can I cache Storefront API responses?
Yes for product catalog (5-15 min edge cache). Short cache for prices + inventory (30-60 sec). Never cache cart operations or checkout URLs — must be real-time per user.
How do I handle rate limit errors?
On 429, back off exponentially (1s, 2s, 4s, 8s). If persistent, add caching or move heavy queries to private token. Bulk operations belong in the Admin API, not Storefront.
Do Storefront API variants respect Markets pricing?
Yes if you pass @inContext(country: US, language: EN) directive on the query. Returns market-specific price + currency. Required for multi-market headless storefronts.
Related developer references
- Shopify GraphQL for Variants — Admin API mutations
- Shopify Line Item Properties — Complete Guide
- Cart Transform Functions — Developer Guide
- Shopify Headless Commerce with Options
Personalization for headless Shopify
Print It My Way exposes a JSON personalizer API for headless storefronts — fields, validation, print-file generation. Free plan available.
Install Print It My Way