Developer reference

Shopify Storefront API for Variants — Complete Developer Reference

Everything you need to query, filter, and add Shopify product variants to a cart via the Storefront GraphQL API. Real queries, real mutations, real rate limit numbers, and copy-paste Hydrogen + Next.js examples.

Last updated: August 14, 2026~10 min readBy the Print It My Way team

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 typeWhere you use itRate limitHeader
Public access tokenBrowser / mobile client60 requests/minute per IPX-Shopify-Storefront-Access-Token
Private access tokenServer-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" }
    ]
  }]
}
Convention: Attributes with keys starting with _ (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:

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:

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 / SymptomCauseFix
{ errors: [{ message: "access denied" }] }Wrong token type for operationBuyer identity mutations require private token or Shopify customer access token
429 Too Many RequestsRate limit hitExponential backoff; cache aggressively; move to private token
Variant returns but availableForSale: falseInventory 0 or selling policy blocksEnable "continue selling when out of stock" if it's a POD product
Attributes not appearing on orderKey contains invalid characters or exceeds 100 charsSanitize keys; strip special chars; truncate to 100
Field 'quantityAvailable' doesn't existStore isn't Shopify Plus or hasn't enabled the fieldFall 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

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