Template
Replaces the hand-rolled shopifyFetch wrapper and raw query strings with
the hydrogen preview package (0.0.0-preview-116d5d7-20260730141607), the
same build pinned in hydrogen-preview/.
- services/shopify/client: createStorefrontClient({type: "public"}) with a
static request context. Two module-scoped clients — uncached for carts,
products and customers; revalidate-3600 for shop policies — using the
client's custom fetch option to carry Next's caching hints.
- graphql/*: every document wrapped in gql(), fragments composed via the
second argument instead of string interpolation, and $country/$language
declared with @inContext so hydrogen injects them.
- shopifyFetch keeps its {query, variables} call shape so call sites are
unchanged, but is now generic over the document, so data is inferred. It
re-raises GraphQL errors, which hydrogen returns rather than throws.
Env var names, the 2025-07 API version pin, and the client-side fetching
architecture are unchanged.
Turning on type coverage surfaced real defects, not just annotations:
- hydrogen gql check caught $discountCodes: [String!] used where the field
requires [String!]!.
- Cart and customer mutation payloads are nullable and were dereferenced
unconditionally, so a failed mutation threw a TypeError. Adds a shared
unwrapCartPayload helper (collapsing five copies of the same userErrors
check) and explicit null handling in the customer service, so a null
payload reads as an error rather than success with no errors.
- Search results are a Product | Page | Article union; adds __typename to
the queries and narrows on it.
- Widens nullable fields (altText, image, customer.email, totalTaxAmount)
in the domain interfaces and in the structural duplicates some
components declare locally.
Adds a typecheck script (tsc --noEmit && hydrogen gql check); it passes
with 0 errors. Two deprecation warnings are left alone as acting on them
would change behaviour: ProductOption.values and CartCost.totalTaxAmount.
Verified against mock.shop: build prerenders the policy pages through the
cached client, and in the browser the product grid, search with facets,
collection filter round-trip, add-to-cart and quantity update all work.
Customer account flows are untested — they need real credentials.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaxgqPbFxLsSuLPZom2kdC
109 lines
3.8 KiB
TypeScript
109 lines
3.8 KiB
TypeScript
// Shopify Storefront API access, backed by `@shopify/hydrogen` (preview build).
|
|
//
|
|
// The package supplies the transport (`createStorefrontClient`) and the typed
|
|
// query documents (`gql`, see graphql/*.ts). `shopifyFetch` keeps the call
|
|
// shape the rest of the app already uses — `{ query, variables }` in, `{ data }`
|
|
// out, throwing on GraphQL errors — but now infers `data` from the document.
|
|
import {
|
|
createShopifyRequestContext,
|
|
createStorefrontClient,
|
|
StorefrontApiError,
|
|
type AnyStorefrontQueryString,
|
|
type StorefrontApi,
|
|
} from '@shopify/hydrogen';
|
|
|
|
const SHOPIFY_STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN;
|
|
const SHOPIFY_STOREFRONT_ACCESS_TOKEN =
|
|
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN;
|
|
const SHOPIFY_API_VERSION =
|
|
process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION || '2025-07';
|
|
const SHOPIFY_STOREFRONT_API_URL = `https://${SHOPIFY_STORE_DOMAIN}/api/${SHOPIFY_API_VERSION}/graphql.json`;
|
|
|
|
// No incoming request and no buyer context: these clients are module-scoped and
|
|
// serve both server rendering and the browser-side hooks, so they must not close
|
|
// over per-request state. `$country`/`$language` are injected from this i18n.
|
|
const requestContext = createShopifyRequestContext({
|
|
request: { headers: new Headers() },
|
|
i18n: { country: 'US', language: 'EN' },
|
|
});
|
|
|
|
// Hydrogen calls `fetch(url, init, cacheOptions)`; Next's caching hints ride
|
|
// along on `init`, which is how the two ways of caching get to coexist.
|
|
const fetchWith = (overrides: RequestInit): typeof globalThis.fetch =>
|
|
((url, init) =>
|
|
globalThis.fetch(url, { ...init, ...overrides })) as typeof globalThis.fetch;
|
|
|
|
const config = {
|
|
storeDomain: SHOPIFY_STORE_DOMAIN!,
|
|
apiVersion: SHOPIFY_API_VERSION,
|
|
// `mock.shop` and other tokenless storefronts leave this unset.
|
|
publicStorefrontToken: SHOPIFY_STOREFRONT_ACCESS_TOKEN,
|
|
};
|
|
|
|
/**
|
|
* Default client. Uncached, because carts and customer reads must never serve a
|
|
* stale response.
|
|
*/
|
|
export const storefront = createStorefrontClient({
|
|
type: 'public',
|
|
requestContext,
|
|
config: { ...config, fetch: fetchWith({ cache: 'no-store' }) },
|
|
});
|
|
|
|
/**
|
|
* Client for catalogue data that changes rarely (shop policies, and anything
|
|
* else safe to serve from Next's data cache for an hour).
|
|
*/
|
|
export const cachedStorefront = createStorefrontClient({
|
|
type: 'public',
|
|
requestContext,
|
|
config: { ...config, fetch: fetchWith({ next: { revalidate: 3600 } }) },
|
|
});
|
|
|
|
interface ShopifyFetchArgs<TDoc extends AnyStorefrontQueryString> {
|
|
query: TDoc;
|
|
variables?: StorefrontApi.VariablesOf<TDoc>;
|
|
/** Serve from Next's data cache instead of hitting Shopify every time. */
|
|
cache?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Runs a `gql()` document and returns its data.
|
|
*
|
|
* Hydrogen returns GraphQL errors rather than throwing them (a 200 with partial
|
|
* data is still a valid response), so this re-raises them to preserve the
|
|
* fail-loudly behaviour the callers were written against. Transport failures
|
|
* already throw as `StorefrontApiError`.
|
|
*/
|
|
export async function shopifyFetch<TDoc extends AnyStorefrontQueryString>({
|
|
query,
|
|
variables,
|
|
cache = false,
|
|
}: ShopifyFetchArgs<TDoc>): Promise<{
|
|
data: StorefrontApi.ResultOf<TDoc>;
|
|
}> {
|
|
const client = cache ? cachedStorefront : storefront;
|
|
|
|
try {
|
|
const { data, errors } = await client.graphql(query, {
|
|
variables,
|
|
} as never);
|
|
|
|
if (errors?.length) {
|
|
console.error('Shopify API errors:', errors);
|
|
throw new Error(`Shopify GraphQL errors: ${JSON.stringify(errors)}`);
|
|
}
|
|
|
|
return { data: data as StorefrontApi.ResultOf<TDoc> };
|
|
} catch (error) {
|
|
if (error instanceof StorefrontApiError) {
|
|
console.error('Shopify fetch error:', error.toJSON());
|
|
} else {
|
|
console.error('Shopify fetch error:', error);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export { SHOPIFY_STORE_DOMAIN, SHOPIFY_STOREFRONT_API_URL };
|