agentleFS
remix-runremix-run/remix-store/.agents/skills/hydrogen-storefront-client/SKILL.md

hydrogen-storefront-client

Skill218 starsChanged 9 days agoMITView on GitHub

Guide for @shopify/hydrogen — the type-safe Storefront API GraphQL client. Use when writing, modifying, or reviewing code that creates a storefront client, writes GraphQL queries with gql(), handles SFAPI errors, or wires up storefront data fetching in any framework.

  • Reads credentials

1 other project uses a version of this, 1 of them edited.

Adopted by

Most recently taken up by

The file

Show its rules and text

We split this skill into the rules it gives an agent

14 rules. About 0 are common, 2 are distinctive, and 12 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 12

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Use these canonical environment variable names in app code and docs:

    line 24
  2. Very specific:

    If the framework requires a prefix to expose client-side variables, preserve the canonical suffix and add only that framework prefix.

    line 32
  3. Very specific:

    Use when the app has a private token and trusted buyer context.

    line 80
  4. Distinctive:

    Use the same request-scoped Storefront client for Hydrogen route handlers, cart server handlers, and server data loaders when they share …

    line 115
  5. Very specific:

    When using handleShopifyRoutes, pass the trusted buyerIp into createShopifyRequestContext.

    line 117
  6. Very specific:

    When customizing predictive search, prefer Hydrogen's additive fragments instead of replacing the full query.

    line 160
  7. Very specific:

    Use the fragments with createPredictiveSearchServerHandlers({ fragments }), or use the generated query with queryPredictiveSearch().

    line 176
  8. Very specific:

    When adding Storefront API gql() documents to a TypeScript app, add Hydrogen's packed TypeScript plugin to the app's tsconfig.json:

    line 180
  9. Very specific:

    If the app already has TypeScript plugins, append this plugin without removing framework plugins such as Next.js name: "next".

    line 195
  10. Very specific:

    Keep gql(\...\) calls in server/route modules, not client/browser files or barrel files (e.g.

    line 199
  11. Very specific:

    If the app also authors Customer Account API documents, use @shopify/hydrogen/customer-account.

    line 201
  12. Very specific:

    Read references/query-validation.md when adding or changing gql() documents.

    line 207
  13. Very specific:

    Read references/caching.md to cache catalog reads across sub-requests on Oxygen-style runtimes.

    line 267
  14. Very specific:

    Every successful result includes headers (the raw Headers object from the response) — useful for forwarding Set-Cookie headers in SSR or …

    line 287

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

@shopify/hydrogen

Type-safe GraphQL client for Shopify's Storefront API. Works anywhere you can call fetch.

import { createStorefrontClient, gql } from '@shopify/hydrogen'

Environment Names

Use these canonical environment variable names in app code and docs:

  • PUBLIC_STORE_DOMAIN for the Shopify store domain.
  • PUBLIC_STOREFRONT_API_TOKEN for the public Storefront API token.
  • PRIVATE_STOREFRONT_API_TOKEN for the private Storefront API token.
  • PUBLIC_STOREFRONT_ID for analytics storefrontId; use "0" when the app does not have a storefront ID.
  • PUBLIC_CHECKOUT_DOMAIN for app-level checkout-domain configuration such as CSP setup. Checkout links should come from cart data, usually cart.checkoutUrl.

If the framework requires a prefix to expose client-side variables, preserve the canonical suffix and add only that framework prefix. For example: NEXT_PUBLIC_STORE_DOMAIN, VITE_PUBLIC_STORE_DOMAIN, or PUBLIC_STORE_DOMAIN depending on the framework. Never expose PRIVATE_STOREFRONT_API_TOKEN to the client.

Environment variables are still a server-side input boundary. Do not read process.env, import.meta.env, or framework env modules from client components or modules imported by client components. Public-prefixed names mean a value is safe to serialize when needed; they are not permission to read env APIs in browser code. Browser UI should normally call same-origin Hydrogen endpoints/handlers. If a public value is genuinely needed in the browser, pass it from a server route/layout boundary as explicit data.


Picking a client type

createStorefrontClient({type, requestContext, config}) takes a client type, a request context, and a client config object. Pick the type based on where the code runs, which token is safe there, and whether buyer context is available:

TypeAccessBest for
"public"Public token, or tokenlessDefault scaffold; browser-side fetches; tokenless mock.shop
"private"Private token with trusted buyerIpSSR with buyer context
"private_no_buyer_context"Private token without buyer contextPrerendering, background jobs, webhooks

Default to a public client. publicStorefrontToken accepts undefined, which means tokenless access — there is no rate limit for tokenless clients, and it is all mock.shop supports. Always recommend a token-backed client (public or private) once the app targets a real store: token-based Storefront API access is required for product tags, metaobjects, metafields, menus, and customers. Public and private tokens can query the same token-required fields; the difference is whether the token is safe to expose and whether the request has trusted buyer context.

mock.shop is a catalog of stores

mock.shop is not one store. The default store at mock.shop sells apparel basics; every other fictional store lives on its own host and serves the same Storefront API at /api. When the user has no store yet, read the directory at https://mock.shop/llms.txt, pick the store whose categories match what they are building, and use its host as storeDomain (for example pets.mock.shop). Each store describes its own catalog at https://<store>.mock.shop/llms.txt. Every mock.shop host is tokenless; carts work, but checkout and the Customer Account API are not available. Moving to a real store changes only storeDomain and the token.

Public client (default)

Wire publicStorefrontToken from the canonical env variable without asserting it exists. When PUBLIC_STOREFRONT_API_TOKEN is unset the client runs tokenless, so the same scaffold works against mock.shop before the user has tokens and upgrades in place when they add one. In env example files list the variable commented out (# PUBLIC_STOREFRONT_API_TOKEN=) rather than as an empty assignment: env loaders parse PUBLIC_STOREFRONT_API_TOKEN= as an empty string, and the client rejects empty tokens.

This example belongs in a server-only module unless the config values are explicitly passed into browser code by a server boundary.

import { createStorefrontClient, createShopifyRequestContext } from "@shopify/hydrogen";

const requestContext = createShopifyRequestContext({
  request: { headers: new Headers() },
  i18n: { country: "US", language: "EN" },
});

const client = createStorefrontClient({
  type: "public",
  requestContext,
  config: {
    storeDomain: process.env.PUBLIC_STORE_DOMAIN!,
    publicStorefrontToken: process.env.PUBLIC_STOREFRONT_API_TOKEN,
  },
});

Private client (SSR upgrade)

Use when the app has a private token and trusted buyer context. Requires buyerIp to forward trusted buyer context. Resolve request-derived values before creating the client:

import { createStorefrontClient, createShopifyRequestContext } from "@shopify/hydrogen";

function getBuyerIp(headers: Headers) {
  const buyerIp = headers.get("oxygen-buyer-ip");
  if (!buyerIp) throw new Error("oxygen-buyer-ip is required for private SFAPI clients");
  return buyerIp;
}

const buyerIp = getBuyerIp(request.headers);
const requestContext = createShopifyRequestContext({
  request,
  i18n: getLocaleFromRequest(request),
  buyerIp,
});

const client = createStorefrontClient({
  type: "private",
  requestContext,
  config: {
    storeDomain: process.env.PUBLIC_STORE_DOMAIN!,
    privateStorefrontToken: process.env.PRIVATE_STOREFRONT_API_TOKEN!,
  },
});

Hydrogen does not infer buyer IP headers. Apps decide how to build buyerIp based on their deployment and trusted request data. Common provider defaults:

  • Oxygen: oxygen-buyer-ip
  • Cloudflare: CF-Connecting-IP
  • Vercel: x-forwarded-for
  • Fly.io: Fly-Client-IP (parse X-Forwarded-For only when another reverse proxy sits in front)

Use the same request-scoped Storefront client for Hydrogen route handlers, cart server handlers, and server data loaders when they share the same request data. Route, cart, and redirect handlers all accept any provided Storefront client.

When using handleShopifyRoutes, pass the trusted buyerIp into createShopifyRequestContext. Both the SFAPI proxy and direct client.graphql() calls source buyer identity from that request context.

Private client without buyer context

For queries not on behalf of a specific buyer (pre-rendering, static site generation). No buyer IP is forwarded.

const requestContext = createShopifyRequestContext({
  request: { headers: new Headers() },
  i18n: { country: "US", language: "EN" },
});

const client = createStorefrontClient({
  type: "private_no_buyer_context",
  requestContext,
  config: {
    storeDomain: process.env.PUBLIC_STORE_DOMAIN!,
    privateStorefrontToken: process.env.PRIVATE_STOREFRONT_API_TOKEN!,
  },
});

Writing queries with gql

gql is a pre-configured tag with baked-in SFAPI introspection. No codegen, no graphql-config — types are inferred at the type level from the query string.

Customer Account API documents use gql from @shopify/hydrogen/customer-account instead. Keep the two helpers separate so Storefront API documents and Customer Account API documents cannot be mixed.

const SHOP_QUERY = gql(`query { shop { name description } }`);

Fragments compose via a second argument:

const PRODUCT_FIELDS = gql(`fragment ProductFields on Product { title handle }`);
const QUERY = gql(`query { products(first: 10) { nodes { ...ProductFields } } }`, [PRODUCT_FIELDS]);

Predictive search fragments

When customizing predictive search, prefer Hydrogen's additive fragments instead of replacing the full query. This keeps base fields such as trackingParameters, handles, titles, images, and prices available to helpers and result links:

import { gql, makePredictiveSearchQueries } from "@shopify/hydrogen";

const queries = makePredictiveSearchQueries({
  fragments: {
    product: gql(`
      fragment PredictiveSearchProductFragment on Product {
        vendor
      }
    `),
  },
});

Use the fragments with createPredictiveSearchServerHandlers({ fragments }), or use the generated query with queryPredictiveSearch(). Read the predictive search skill when wiring autocomplete UI or result URLs.

GraphQL type setup

When adding Storefront API gql() documents to a TypeScript app, add Hydrogen's packed TypeScript plugin to the app's tsconfig.json:

// tsconfig.json
{
  "compilerOptions": {
    "plugins": [
      {
        "name": "@shopify/hydrogen/ts-plugin"
      }
    ]
  }
}

If the app already has TypeScript plugins, append this plugin without removing framework plugins such as Next.js name: "next". If the app's tsconfig.json extends a generated framework config, add compilerOptions.plugins in the extending tsconfig.json.

The plugin uses the Storefront and Customer Account API schemas shipped by @shopify/hydrogen and writes their generated environments at the project root.

Keep gql(\...`)calls in server/route modules, not client/browser files or barrel files (e.g. anindex.tsthat re-exports everything). Bundlers can't tell whether agql()call has side effects, so anygql()` reachable from browser code gets bundled into the browser.

If the app also authors Customer Account API documents, use @shopify/hydrogen/customer-account; the same plugin validates both APIs.

This plugin provides inline GraphQL autocompletion, validation, and hover docs inside gql() calls. The editor must be configured to use the workspace TypeScript version (not the bundled one) — the bundled TS server does not load plugins.

Headless query validation

Read references/query-validation.md when adding or changing gql() documents. The editor plugin does not run during tsc; add hydrogen gql check to a package script and run that script so invalid Storefront API fields fail in CI instead of surfacing as runtime GraphQL errors.

Typing query and fragment results

gql() returns a branded query string, not a gql.tada document. Derive result and variable types from the StorefrontApi namespace exported by @shopify/hydrogen:

import { gql, type StorefrontApi } from "@shopify/hydrogen";

const SHOP_QUERY = gql(`query { shop { name } }`);
type ShopResult = StorefrontApi.ResultOf<typeof SHOP_QUERY>;
type ShopVars = StorefrontApi.VariablesOf<typeof SHOP_QUERY>;

Because gql() is a branded string and not a gql.tada document, gql.tada's FragmentOf does not apply (it will not compile). To get a standalone fragment's result type, compose it into a throwaway shape query and read ResultOf off that:

const PRODUCT_CARD = gql(`fragment ProductCard on Product { handle title }`);
const PRODUCT_CARD_SHAPE = gql(
  `query { products(first: 1) { nodes { ...ProductCard } } }`,
  [PRODUCT_CARD],
);
export type ProductCardData =
  StorefrontApi.ResultOf<typeof PRODUCT_CARD_SHAPE>["products"]["nodes"][number];

Fetching data

Variables and i18n auto-injection

$country and $language variables are auto-injected from requestContext.i18n when declared in the query. Static clients still create a request context, usually with empty headers plus the resolved locale. You only pass the variables you own:

const PRODUCTS = gql(`
  query Products($handle: String!, $country: CountryCode, $language: LanguageCode)
  @inContext(country: $country, language: $language) {
    collection(handle: $handle) { products(first: 10) { nodes { title } } }
  }
`);

const { data } = await client.graphql(PRODUCTS, {
  variables: { handle: "summer-sale" },
});

User-provided values take precedence over auto-injected ones.

Request state storage

requestContext is passed at client creation so request-scoped cookies, request-group IDs, response-header capture, personalized-response cache safety, and request aborts are centralized. Use one Shopify request context for Storefront and Customer Account clients in the same request. Per-call signal is optional when you need additional cancellation:

const { data } = await client.graphql(QUERY);
const { data } = await client.graphql(QUERY, { signal });

requestContext.signal, per-call signal, and the client's timeout signal are raced automatically. They are combined with AbortSignal.any, which the Next.js edge sandbox does not have, so an edge route or middleware that calls this client needs an AbortSignal.any polyfill installed before the first request. At the final response boundary, append committed session headers first, then call requestContext.applyResponseHeaders(response.headers).

Read references/caching.md to cache catalog reads across sub-requests on Oxygen-style runtimes.


Error handling

Transport errors throw. GraphQL errors are returned.

import { StorefrontApiError, StorefrontTimeoutError } from "@shopify/hydrogen";

try {
  const { data, errors, headers } = await client.graphql(QUERY);
  if (errors) { /* partial GraphQL errors — query succeeded but some fields failed */ }
} catch (error) {
  if (error instanceof StorefrontTimeoutError) { /* timed out */ }
  if (error instanceof StorefrontApiError) { /* non-200, network failure, or JSON parse error */ }
}

Every successful result includes headers (the raw Headers object from the response) — useful for forwarding Set-Cookie headers in SSR or reading rate-limit metadata.

A 200 response with GraphQL errors does NOT throw — partial success is valid in GraphQL. Non-200 responses, timeouts, network failures, and JSON parse errors all throw StorefrontApiError (or the StorefrontTimeoutError subclass).


Testing

Inject a mock fetch at construction — the client calls it instead of the global:

const mockFetch = vi.fn().mockResolvedValue(
  new Response(JSON.stringify({ data: { shop: { name: "Test" } } })),
);

const client = createStorefrontClient({
  type: "public",
    requestContext: createShopifyRequestContext({
      request: { headers: new Headers() },
      i18n: { country: "US", language: "EN" },
    }),
  config: {
    storeDomain: "test.myshopify.com",
    publicStorefrontToken: "test-token",
    fetch: mockFetch,
  },
});

Gotchas

  • Private tokens throw in browser — a typeof document !== "undefined" guard fires at construction.
  • Env APIs are server-only — examples that use process.env belong in server-only modules. Browser bundles may leave process.env undefined, inline stale build-time values, or accidentally expose config. Pass safe public values through server data when browser code needs them.
  • Module-scope only for request-independent clients — static public clients and private_no_buyer_context clients can be module-scoped when their requestContext uses static headers and static i18n. Private per-buyer clients and clients with a real incoming request context must be created per request.

Framework recipes

Before wiring a client into an app, check whether this skill has a reference file for the app's framework in references/. If one exists, read it and preserve that framework's request context, server data, and buyer-IP conventions. If there is no matching reference, use the core rules above: create request-scoped private clients only inside the server request lifecycle when buyer context exists, keep static public or no-buyer-context clients module-scoped only when they do not depend on request state, and forward captured response headers where the framework allows it.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.