Skip to main content

Query the Storefront API

Hydrogen's Storefront API client is a typed GraphQL client for the Storefront API. It works anywhere you can call fetch, so you can use it in loaders, server components, route handlers, middleware, and background jobs in any framework. This guide shows how to choose a client type, create a client, and query the Storefront API.


createStorefrontClient() takes three inputs:

  • type: Which token the client uses, and whether it forwards buyer IP.
  • requestContext: The Shopify request context for the current request, created with createShopifyRequestContext(). It carries the request, the resolved market, and the buyer IP. To learn more, refer to The request context.
  • config: Your store domain, token, and optional settings like storefrontId, cache, waitUntil, and a custom fetch.

Queries go through client.graphql(query, {variables}). There are no typed helper methods like client.product(). You write normal Storefront API queries with gql(), and your editor infers the result type from the query.

Hydrogen's cart, predictive search, and request handlers all accept the same client, so one client per request serves your whole server.



Anchor to Step 1: Choose a client typeStep 1: Choose a client type

Pick the client type based on where the code runs, which token is safe there, and whether you have a customer's request:

TypeTokenUse it for
"public"Public token, or no tokenBrowser-safe reads, mock.shop, and the default scaffold before you have tokens.
"private"Private token, with a trusted buyer IPServer-rendered requests on behalf of a customer, like the cart and personalized pages.
"private_no_buyer_context"Private token, without a buyer IPCatalog reads that are the same for every customer, like prerendering, static generation, sitemaps, and webhooks.

A public client accepts undefined as its token, which means tokenless access. When you move to a real store, use a token-backed client: product tags, metaobjects, metafields, menus, and customer data require a token.

Private tokens are server-only. createStorefrontClient() throws if you create a private client in the browser.

Anchor to Use mock.shop during developmentUse mock.shop during development

mock.shop is a public Storefront API with demo data. It's a catalog of stores, each on its own host. The store at mock.shop sells apparel, and the directory at mock.shop/llms.txt lists the others, like pets.mock.shop.

To use mock.shop, create a tokenless public client and set the store domain to a mock.shop host:

const client = createStorefrontClient({
type: 'public',
requestContext,
config: {storeDomain: 'pets.mock.shop'},
});

mock.shop rejects private tokens. Carts work and checkout is mocked; the Customer Account API isn't available.


Anchor to Step 2: Create a clientStep 2: Create a client

Create the client in server-only code. Create a client that uses a customer's request, like a "private" client or any client with the incoming request's headers, once per request. You can create a client with a static request context, like a "private_no_buyer_context" client for catalog reads, once at module scope.

Create a request-scoped Storefront API client

lib/storefront.ts
import {
createShopifyRequestContext,
createStorefrontClient,
} from '@shopify/hydrogen';

import {getBuyerIp} from './buyer-ip';
import {env} from './env';

export function createRequestStorefrontClient(request: Pick<Request, 'headers'>) {
return createStorefrontClient({
type: 'private',
requestContext: createShopifyRequestContext({
request,
i18n: {country: 'US', language: 'EN'},
buyerIp: getBuyerIp(request.headers),
}),
config: {
storeDomain: env.PUBLIC_STORE_DOMAIN,
privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
storefrontId: env.PUBLIC_STOREFRONT_ID,
},
});
}

env stands for your server environment. Read the variables with your framework's environment API, like process.env on Node or the env binding on Oxygen and Cloudflare Workers. To cache queries, also pass a cache store and waitUntil in config. Refer to Caching.

Call createRequestStorefrontClient() once per request from your app's request entry point. Then share the client with your page loaders through request-scoped context. Run Hydrogen's request handlers in the same entry point, and apply the response headers at your framework's response boundary. For where this code goes in React Router and Next.js, refer to Framework wiring.

In Next.js, Server Components receive headers instead of a Request. createShopifyRequestContext() needs only the request headers, so pass {headers} as the request. Wrap the client in React's cache() so every component in one render shares it.

Anchor to Create a client for catalog readsCreate a client for catalog reads

Catalog pages that serve the same data to every customer don't need buyer context. A module-scoped "private_no_buyer_context" client with a static request context doesn't read request headers. In Next.js, that keeps the page statically renderable.

Module-scoped catalog client

lib/storefront-static.ts
import {
createShopifyRequestContext,
createStorefrontClient,
} from '@shopify/hydrogen';

import {env} from './env';

export const staticStorefrontClient = createStorefrontClient({
type: 'private_no_buyer_context',
requestContext: createShopifyRequestContext({
request: {headers: new Headers()},
i18n: {country: 'US', language: 'EN'},
}),
config: {
storeDomain: env.PUBLIC_STORE_DOMAIN,
privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
},
});

All requests through a client without buyer context share one rate limit bucket. That's fine for catalog data, but use a request-scoped "private" client for the cart and anything personalized.

In Next.js, use both clients: the static client for catalog reads, and the request-scoped client for the cart.


Anchor to Step 3: Pass a trusted buyer IPStep 3: Pass a trusted buyer IP

A "private" client forwards trusted buyer context, including the buyer's IP address, to the Storefront API with each request. Hydrogen doesn't guess which header holds the IP address. Your app reads it from a header that your hosting provider sets and that customers can't spoof.

ProviderHeader
Oxygenoxygen-buyer-ip
CloudflareCF-Connecting-IP
Vercelx-forwarded-for
Fly.ioFly-Client-IP

On Fly.io, parse X-Forwarded-For only when another reverse proxy sits in front of your app.

The following helper reads the first address from x-forwarded-for, which Vercel sets. It falls back to a loopback address during local development and throws in production when the header is missing:

Read the buyer IP

lib/buyer-ip.ts
export function getBuyerIp(headers: Pick<Headers, 'get'>): string {
const buyerIp = headers.get('x-forwarded-for')?.split(',')[0]?.trim();
if (buyerIp) return buyerIp;

if (process.env.NODE_ENV !== 'production') return '127.0.0.1';

throw new Error('x-forwarded-for is required for private Storefront API clients');
}

Pass the buyer IP to createShopifyRequestContext(), not to the client config. Hydrogen's Storefront API proxy and your own client.graphql() calls both read buyer identity from the request context.


Write queries with gql() from @shopify/hydrogen, then run them with client.graphql(). Keep gql() calls in server modules and route modules. If a gql() call is reachable from browser code, then bundlers include it in the browser bundle.

Query a product

lib/product.ts
import {gql, type StorefrontClient} from '@shopify/hydrogen';

const PRODUCT_QUERY = gql(`
query Product($handle: String!) {
product(handle: $handle) {
title
description
}
}
`);

export async function loadProduct(storefront: StorefrontClient, handle: string) {
const {data} = await storefront.graphql(PRODUCT_QUERY, {
variables: {handle},
});

return data?.product ?? null;
}

export type Product = Awaited<ReturnType<typeof loadProduct>>;

Call loadProduct() from your page's server data loader, then render the result:

Render the product

lib/render-product.ts

import type {Product} from './product';

export function renderProduct(root: HTMLElement, product: Product) {
const heading = document.createElement('h1');
heading.textContent = product?.title ?? 'Product not found';
root.replaceChildren(heading);
}
import type {Product} from '~/lib/product';

export function ProductTitle({product}: {product: Product}) {
return <h1>{product?.title ?? 'Product not found'}</h1>;
}
<script setup lang="ts">
import type {Product} from '~/lib/product';

defineProps<{product: Product}>();
</script>

<template>
<h1>{{ product?.title ?? 'Product not found' }}</h1>
</template>

data is typed from the query. To set up editor validation and derive types for fragments and variables, refer to Typed queries and validation.

To cache catalog queries, refer to Caching.


Anchor to Step 5: Localize queriesStep 5: Localize queries

The Storefront API returns prices, availability, and translated content for a market when a query uses the @inContext directive. Hydrogen fills in the $country and $language variables from the request context's i18n whenever your query declares them. You pass only the variables you own:

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

const {data} = await storefront.graphql(COLLECTION_QUERY, {
variables: {handle: 'summer-sale'},
});

Hydrogen doesn't add @inContext for you. If your query doesn't declare $country and $language and use them in @inContext, then the Storefront API returns the store's default market. The request context's country and language override values in variables.

To resolve the market for each request, refer to Markets.


Anchor to Step 6: Handle errorsStep 6: Handle errors

Transport errors throw, and the client returns GraphQL errors:

  • Thrown: Non-2xx responses, network failures, and JSON parse errors throw StorefrontApiError. Timeouts throw StorefrontTimeoutError, which extends StorefrontApiError.
  • Returned: A 200 response with a GraphQL errors array doesn't throw, because partial success is valid in GraphQL. Check errors alongside data.
import {StorefrontApiError, StorefrontTimeoutError} from '@shopify/hydrogen';

try {
const {data, errors, headers} = await storefront.graphql(PRODUCT_QUERY, {
variables: {handle},
});

if (errors) {
console.error('Storefront API returned errors', errors);
}
} catch (error) {
if (error instanceof StorefrontTimeoutError) {
// The request timed out.
} else if (error instanceof StorefrontApiError) {
// The request failed, or the response wasn't valid JSON.
}
throw error;
}

Every successful result includes headers, the Headers object from the Storefront API response.

Queries with missing or renamed fields can pass tsc but fail at runtime, often looking like empty results. On a product page, that can look like a missing product and turn into a misleading 404. To catch these errors before you deploy, validate your queries in CI.


Anchor to Step 7: Test your clientStep 7: Test your client

To test code that uses the client, pass a mock fetch in the client config. The client calls it instead of the global fetch:

Mock the Storefront API

storefront.test.ts
import {
createShopifyRequestContext,
createStorefrontClient,
gql,
} from '@shopify/hydrogen';
import {expect, test, vi} from 'vitest';

test('reads the shop name', async () => {
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,
},
});

const {data} = await client.graphql(gql(`query { shop { name } }`));

expect(data?.shop.name).toBe('Test');
});


Was this page helpful?