Storefront API

How the template calls Shopify's Storefront GraphQL API - the fetch client, query patterns, caching, and error handling.

All Shopify data flows through a single shopifyFetch function in lib/shopify/client.ts. It wraps the native fetch API with Shopify authentication, variable hashing for cache keys, and error handling.

For Shopify admin setup and required token scopes, see Storefront API Permissions.

shopifyFetch

ts
import { shopifyFetch } from "@/lib/shopify/client";

const data = await shopifyFetch<{ productByHandle: ShopifyProduct }>({
  operation: "getProductByHandle",
  query: GET_PRODUCT_BY_HANDLE_QUERY,
  variables: { handle, country, language },
});

The function accepts three fields:

  • operation - the GraphQL operation name. Must match the name in the query string. Appended to the request URL as a query parameter for cache key differentiation.
  • query - the full GraphQL query or mutation as a template string, including any fragment interpolations.
  • variables - an optional object passed as JSON in the request body. Never interpolate values into the query string directly.

The return type is generic - pass the expected shape of data from the GraphQL response as the type parameter.

Request details

Every request is a POST to https://{SHOPIFY_STORE_DOMAIN}/api/2025-01/graphql.json with headers:

HeaderValue
Content-Typeapplication/json
X-Shopify-Storefront-Access-TokenYour public storefront token
Accept-Encodinggzip, deflate

The URL includes query parameters for operation and a 12-character SHA-1 hash of the serialized variables. This ensures Next.js treats requests with different variables as distinct cache entries, even though they hit the same endpoint.

Writing a query

Define the query as a template string, interpolating shared fragments at the top:

ts
import { PRODUCT_FRAGMENT } from "@/lib/shopify/fragments";

const GET_PRODUCT_BY_HANDLE_QUERY = `
  ${PRODUCT_FRAGMENT}
  query getProductByHandle(
    $handle: String!
    $country: CountryCode
    $language: LanguageCode
  ) @inContext(country: $country, language: $language) {
    productByHandle(handle: $handle) {
      ...ProductFields
    }
  }
`;

The @inContext directive passes country and language for localized pricing and content. Use getCountryCode(locale) and getLanguageCode(locale) from lib/i18n to extract these from a locale string.

Always verify fields against the live Storefront API schema with shopify-ai-toolkit or /vercel-shop:shopify-graphql-reference when adding or modifying queries - don't guess field names.

Fragments

Shared fragments in lib/shopify/fragments.ts avoid repeating field selections:

FragmentCovers
MONEY_FRAGMENTamount and currencyCode on MoneyV2
IMAGE_FRAGMENTurl, altText, width, height on Image
PRODUCT_VARIANT_FRAGMENTVariant price, options, image, availability
PRODUCT_FRAGMENTFull product: media, variants (up to 50), options with swatches, metafields, price ranges, SEO
CATEGORY_PRODUCT_FRAGMENTLightweight product for listing pages (fewer fields)

Embed fragments in a query with template literal interpolation: `${PRODUCT_FRAGMENT} query ...`.

Writing an operation function

Operation functions live in lib/shopify/operations/ and follow a consistent pattern:

ts
import { cacheLife, cacheTag } from "next/cache";
import { shopifyFetch } from "../client";
import { PRODUCT_FRAGMENT } from "../fragments";
import { defaultLocale, getCountryCode, getLanguageCode } from "@/lib/i18n";

export async function getProduct(handle: string, locale: string = defaultLocale) {
  "use cache: remote";
  cacheLife("max");
  cacheTag("products", `product-${handle}`);

  const data = await shopifyFetch<{ productByHandle: ShopifyProduct }>({
    operation: "getProductByHandle",
    query: GET_PRODUCT_BY_HANDLE_QUERY,
    variables: {
      handle,
      country: getCountryCode(locale),
      language: getLanguageCode(locale),
    },
  });

  return transformShopifyProductDetails(data.productByHandle);
}

Key elements:

  1. "use cache: remote" - opts the function into Next.js request-level caching
  2. cacheLife("max") - caches indefinitely until manually revalidated
  3. cacheTag(...) - assigns tags for granular invalidation via revalidateTag()
  4. Transform - convert the Shopify response to a domain type before returning. Components never import Shopify types directly.

Caching

All read operations use "use cache: remote" with cacheLife("max") and one or more cache tags. Tags follow a hierarchy:

Tag patternScope
productsAll product queries
product-{handle}Single product by handle
product-{numericId}Single product by Shopify numeric ID
recommendations-{handle}Product recommendations
collectionsAll collection queries
collection-{handle}Single collection
menusNavigation menus
cartAll cart operations

Invalidate with revalidateTag("product-blue-tee") or updateTag("cart") in server actions. Cart mutations must always call invalidateCartCache() from lib/cart-cache - this is a hard requirement.

Mutations

Cart mutations use the same shopifyFetch function but are not cached. They call Shopify cartLinesAdd, cartLinesUpdate, and cartLinesRemove mutations:

ts
export async function addToCart(lines: CartLineInput[], cartId: string, locale: string) {
  const data = await shopifyFetch<{ cartLinesAdd: { cart: ShopifyCart } }>({
    operation: "addToCart",
    query: ADD_TO_CART_MUTATION,
    variables: { cartId, lines, country: getCountryCode(locale) },
  });

  invalidateCartCache();
  return transformShopifyCart(data.cartLinesAdd.cart);
}

Shopify returns the full updated cart in every mutation response, so there's no need for a follow-up query.

Error handling

The client handles errors at two levels:

HTTP errors - a non-200 response throws immediately with the status code and text.

GraphQL errors - if the response contains errors and no data, the client throws. If there are errors but data is also present (partial success), it logs a warning and returns the partial data.

Individual operations add their own handling on top. For example, getCart() wraps the call in try-catch and returns undefined on failure, while getProduct() throws if the product isn't found.

Debug logging

Set DEBUG_SHOPIFY=true to log every request with timing and a variable preview:

[shopify] getProductByHandle 45ms handle=blue-widget
[shopify] searchProducts 120ms query=tee first=50 after=cursor123

Transforms

Every operation converts the Shopify response to a domain type before returning. Transforms live in lib/shopify/transforms/:

TransformInput → Output
transformShopifyProductDetailsShopifyProductProductDetails
transformShopifyCartShopifyCartCart

Domain types are defined in lib/types.ts and are provider-agnostic. Components import these types, never the Shopify-specific response shapes. This keeps the UI decoupled from the API layer.

Key files and tools

ResourcePurpose
lib/shopify/client.tsCore fetch wrapper with variable hashing and error handling
lib/shopify/fragments.tsReusable GraphQL fragment definitions
lib/shopify/operations/products.tsProduct queries: single, batch, search, recommendations
lib/shopify/operations/cart.tsCart queries and mutations
lib/shopify/operations/collections.tsCollection queries
lib/shopify/operations/search.tsPredictive search
lib/shopify/operations/menu.tsNavigation menu queries
lib/shopify/transforms/product.tsShopify product → domain type
lib/shopify/transforms/cart.tsShopify cart → domain type
lib/cart-cache.tsinvalidateCartCache() for mutation cache busting
shopify-ai-toolkitLive Storefront and Customer Account schema inspection

Chat

Tip: You can open and close chat with I

0 / 1000