Template
Move Storefront queries onto @shopify/hydrogen preview client
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
95b191eeb8
commit
f8c3ef9e0e
@@ -0,0 +1,108 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user