Replace @shopify/hydrogen with direct Storefront API queries

Vercel's 'Hydrogen (v1)' preset matches any project depending on
@shopify/hydrogen and runs its v1 builder, which fails looking for
dist/worker. We only used the preview package as a Storefront API client,
so talk to the API directly instead:

- services/shopify/client.ts: plain fetch to /api/{version}/graphql.json
  with the public token header, @inContext country/language defaults, and
  the same graphql() / unwrapStorefrontResult API as before
- graphql/gql.ts: small helper that joins a document with its fragments
  (de-duplicated); the queries themselves are unchanged
- Explicit response types at each call site in place of the generated
  gql.tada types; local ProductFilter input type
- Drop @shopify/hydrogen, graphql, the TS plugin and 'hydrogen gql check'
- Copy no longer mentions Hydrogen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rami Bitar
2026-10-03 15:28:02 -04:00
co-authored by Claude Opus 5.5
parent d33a6894f3
commit 6fcfe59d0d
20 changed files with 273 additions and 249 deletions
-2
View File
@@ -6,6 +6,4 @@ dist
src/routeTree.gen.ts src/routeTree.gen.ts
*.tsbuildinfo *.tsbuildinfo
# Temp tsconfig written by `hydrogen gql check`; normally cleaned up on exit
.hydrogen-gql-*.json
.tanstack .tanstack
+7 -6
View File
@@ -1,9 +1,10 @@
# Vite Shopify Storefront # Vite Shopify Storefront
A server-rendered Shopify storefront built with Vite, TanStack Start (TanStack A server-rendered Shopify storefront built with Vite, TanStack Start (TanStack
Router + TanStack Query), Hono and Tailwind CSS v4. `@shopify/hydrogen` is used Router + TanStack Query), Hono and Tailwind CSS v4, talking to Shopify's
only as the Storefront API client and for typed `gql()` documents. It's a port [Storefront API](https://shopify.dev/docs/api/storefront) directly over
of `nextjs-templates/shopify-storefront` without the AI store assistant. GraphQL — no Shopify SDK. It's a port of `nextjs-templates/shopify-storefront`
without the AI store assistant.
Runs in the nodesandbox runtime: `npm install` → `npm run dev` → port 3000. Runs in the nodesandbox runtime: `npm install` → `npm run dev` → port 3000.
@@ -20,7 +21,7 @@ yarn dev # http://localhost:3000
| ---------------- | --------------------------------------------------------- | | ---------------- | --------------------------------------------------------- |
| `yarn dev` | Vite dev server with SSR and the Hono `/api` routes | | `yarn dev` | Vite dev server with SSR and the Hono `/api` routes |
| `yarn build` | Production build to `dist/` | | `yarn build` | Production build to `dist/` |
| `yarn typecheck` | `tsc` plus `hydrogen gql check` for the GraphQL documents | | `yarn typecheck` | `tsc` |
## nodesandbox compatibility ## nodesandbox compatibility
@@ -50,8 +51,8 @@ src/
account.ts login, logout, register, recover, reset, activate, me account.ts login, logout, register, recover, reset, activate, me
account.functions.ts server functions for account reads in loaders account.functions.ts server functions for account reads in loaders
hooks/ TanStack Query options + hooks over the Storefront API hooks/ TanStack Query options + hooks over the Storefront API
services/shopify Storefront client, catalogue and customer functions services/shopify Storefront API client (plain fetch), catalogue and customer functions
graphql/ Typed `gql()` documents graphql/ Storefront API queries and mutations (`gql()` joins fragments)
components/ Storefront UI (header, cart drawer, product detail, ...) components/ Storefront UI (header, cart drawer, product detail, ...)
``` ```
+1 -3
View File
@@ -6,14 +6,13 @@
"scripts": { "scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000", "dev": "vite dev --host 0.0.0.0 --port 3000",
"build": "vite build", "build": "vite build",
"typecheck": "tsc --noEmit && hydrogen gql check" "typecheck": "tsc --noEmit"
}, },
"packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e", "packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e",
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0",
"@remixicon/react": "^4.9.0", "@remixicon/react": "^4.9.0",
"@shopify/hydrogen": "0.0.0-preview-116d5d7-20260730141607",
"@tanstack/query-core": "^5.104.1", "@tanstack/query-core": "^5.104.1",
"@tanstack/react-query": "^5.104.1", "@tanstack/react-query": "^5.104.1",
"@tanstack/react-router": "^1.170.41", "@tanstack/react-router": "^1.170.41",
@@ -23,7 +22,6 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"framer-motion": "^12.42.2", "framer-motion": "^12.42.2",
"graphql": "^17.0.2",
"hono": "^4.13.12", "hono": "^4.13.12",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"radix-ui": "^1.6.7", "radix-ui": "^1.6.7",
+2 -2
View File
@@ -13,8 +13,8 @@ interface ProductsProps {
} }
const Products: React.FC<ProductsProps> = ({ const Products: React.FC<ProductsProps> = ({
title = 'Shopify Hydrogen Storefront', title = 'Shopify Storefront',
subtitle = 'A Shopify storefront built with Vite, TanStack and Hydrogen.', subtitle = 'A Shopify storefront built with Vite, TanStack and the Storefront API.',
limit = 12, limit = 12,
showLoadMore = true, showLoadMore = true,
}) => { }) => {
+2 -1
View File
@@ -1,7 +1,8 @@
/** Chrome shared by every route — rendered once in the root layout. */ /** Chrome shared by every route — rendered once in the root layout. */
export const site = { export const site = {
storeName: 'Shop', storeName: 'Shop',
description: 'A Shopify storefront built with Vite, TanStack and Hydrogen.', description:
'A Shopify storefront built with Vite, TanStack and the Storefront API.',
// Absolute origin for canonical and Open Graph URLs (set in vite.config.ts). // Absolute origin for canonical and Open Graph URLs (set in vite.config.ts).
url: import.meta.env.VITE_SITE_URL ?? 'http://localhost:3000', url: import.meta.env.VITE_SITE_URL ?? 'http://localhost:3000',
logoUrl: '', logoUrl: '',
+1 -1
View File
@@ -1,4 +1,4 @@
import { gql } from '@shopify/hydrogen'; import { gql } from '@/graphql/gql';
// Cart Fragment for consistent cart data // Cart Fragment for consistent cart data
const CartFragment = gql(` const CartFragment = gql(`
+1 -1
View File
@@ -1,4 +1,4 @@
import { gql } from '@shopify/hydrogen'; import { gql } from '@/graphql/gql';
import { ProductFragment } from '@/graphql/products'; import { ProductFragment } from '@/graphql/products';
// Get all collections // Get all collections
+1 -1
View File
@@ -1,6 +1,6 @@
// Customer account operations (classic Storefront customer accounts). // Customer account operations (classic Storefront customer accounts).
// https://shopify.dev/docs/api/storefront/latest/objects/Customer // https://shopify.dev/docs/api/storefront/latest/objects/Customer
import { gql } from '@shopify/hydrogen'; import { gql } from '@/graphql/gql';
const CustomerFragment = gql(` const CustomerFragment = gql(`
fragment CustomerFragment on Customer { fragment CustomerFragment on Customer {
+27
View File
@@ -0,0 +1,27 @@
/**
* Builds a GraphQL document string: the operation or fragment source, followed
* by any fragments it spreads. Fragments that themselves include fragments are
* flattened and de-duplicated, so each definition appears once — the
* Storefront API rejects a document that defines the same fragment twice.
*/
export function gql(source: string, fragments: readonly string[] = []): string {
const definitions: string[] = [];
const seen = new Set<string>();
const add = (text: string) => {
// A composed fragment is "its own source + its fragments", joined by blank
// lines; split it back into definitions so shared ones de-duplicate.
for (const part of text.split(/\n(?=\s*fragment\s)/)) {
const definition = part.trim();
if (definition && !seen.has(definition)) {
seen.add(definition);
definitions.push(definition);
}
}
};
add(source);
for (const fragment of fragments) add(fragment);
return definitions.join('\n\n');
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { gql } from '@shopify/hydrogen'; import { gql } from '@/graphql/gql';
// Shop policies are exposed on the `shop` object of the Storefront API. // Shop policies are exposed on the `shop` object of the Storefront API.
// There is no lookup-by-handle field, so we fetch all of them and match. // There is no lookup-by-handle field, so we fetch all of them and match.
+1 -1
View File
@@ -1,4 +1,4 @@
import { gql } from '@shopify/hydrogen'; import { gql } from '@/graphql/gql';
// Product Fragment for consistent product data // Product Fragment for consistent product data
export const ProductFragment = gql(` export const ProductFragment = gql(`
+1 -1
View File
@@ -1,4 +1,4 @@
import { gql } from '@shopify/hydrogen'; import { gql } from '@/graphql/gql';
import { ProductFragment } from '@/graphql/products'; import { ProductFragment } from '@/graphql/products';
// Storefront search over products. `productFilters` accepts the raw `input` // Storefront search over products. `productFilters` accepts the raw `input`
+34 -11
View File
@@ -89,6 +89,12 @@ export interface Cart {
}; };
} }
// Every cart mutation selects this payload; both it and its cart are nullable.
type CartPayload = {
cart: Cart | null;
userErrors: Array<{ field?: string[] | null; message: string }>;
} | null;
// ─── Shopify API functions ─────────────────────────────────────────── // ─── Shopify API functions ───────────────────────────────────────────
/** /**
@@ -118,9 +124,12 @@ function unwrapCartPayload<T>(
async function createCartApi(lines: CartLineInput[] = []): Promise<Cart> { async function createCartApi(lines: CartLineInput[] = []): Promise<Cart> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(CREATE_CART_MUTATION, { await storefront.graphql<{ cartCreate: CartPayload }>(
CREATE_CART_MUTATION,
{
variables: { lines: lines.length > 0 ? lines : null }, variables: { lines: lines.length > 0 ? lines : null },
}), }
),
'CreateCart' 'CreateCart'
); );
@@ -132,9 +141,12 @@ async function addCartLinesApi(
lines: CartLineInput[] lines: CartLineInput[]
): Promise<Cart> { ): Promise<Cart> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(ADD_CART_LINES_MUTATION, { await storefront.graphql<{ cartLinesAdd: CartPayload }>(
ADD_CART_LINES_MUTATION,
{
variables: { cartId, lines }, variables: { cartId, lines },
}), }
),
'AddCartLines' 'AddCartLines'
); );
@@ -146,9 +158,12 @@ async function updateCartLinesApi(
lines: CartLineUpdateInput[] lines: CartLineUpdateInput[]
): Promise<Cart> { ): Promise<Cart> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(UPDATE_CART_LINES_MUTATION, { await storefront.graphql<{ cartLinesUpdate: CartPayload }>(
UPDATE_CART_LINES_MUTATION,
{
variables: { cartId, lines }, variables: { cartId, lines },
}), }
),
'UpdateCartLines' 'UpdateCartLines'
); );
@@ -160,9 +175,12 @@ async function removeCartLinesApi(
lineIds: string[] lineIds: string[]
): Promise<Cart> { ): Promise<Cart> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(REMOVE_CART_LINES_MUTATION, { await storefront.graphql<{ cartLinesRemove: CartPayload }>(
REMOVE_CART_LINES_MUTATION,
{
variables: { cartId, lineIds }, variables: { cartId, lineIds },
}), }
),
'RemoveCartLines' 'RemoveCartLines'
); );
@@ -174,9 +192,12 @@ async function updateCartDiscountCodesApi(
discountCodes: string[] discountCodes: string[]
): Promise<Cart> { ): Promise<Cart> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(UPDATE_CART_DISCOUNT_CODES_MUTATION, { await storefront.graphql<{ cartDiscountCodesUpdate: CartPayload }>(
UPDATE_CART_DISCOUNT_CODES_MUTATION,
{
variables: { cartId, discountCodes }, variables: { cartId, discountCodes },
}), }
),
'UpdateCartDiscountCodes' 'UpdateCartDiscountCodes'
); );
@@ -185,7 +206,9 @@ async function updateCartDiscountCodesApi(
async function getCartApi(cartId: string): Promise<Cart | null> { async function getCartApi(cartId: string): Promise<Cart | null> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(GET_CART_QUERY, { variables: { cartId } }), await storefront.graphql<{ cart: Cart | null }>(GET_CART_QUERY, {
variables: { cartId },
}),
'GetCart' 'GetCart'
); );
+3 -1
View File
@@ -22,7 +22,9 @@ export const POLICY_HANDLES = [
export async function getShopPolicies(): Promise<ShopPolicy[]> { export async function getShopPolicies(): Promise<ShopPolicy[]> {
try { try {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(GET_SHOP_POLICIES_QUERY), await storefront.graphql<{
shop: Record<string, ShopPolicy | null> | null;
}>(GET_SHOP_POLICIES_QUERY),
'GetShopPolicies' 'GetShopPolicies'
); );
+2 -2
View File
@@ -15,8 +15,8 @@ export const Route = createFileRoute('/')({
head: () => seo({ title: site.storeName, absolute: true, path: '/' }), head: () => seo({ title: site.storeName, absolute: true, path: '/' }),
component: () => ( component: () => (
<Products <Products
title="Vite + Shopify Hydrogen" title="Vite + Shopify Storefront"
subtitle="A Shopify storefront built with Vite, TanStack and Hydrogen." subtitle="A Shopify storefront built with Vite, TanStack and the Storefront API."
/> />
), ),
}); });
+50 -23
View File
@@ -3,7 +3,6 @@
// These are plain async functions with no React imports, so they can be called // These are plain async functions with no React imports, so they can be called
// from the Hono server as well as from the TanStack Query options in // from the Hono server as well as from the TanStack Query options in
// hooks/use-shopify-*.ts, which re-export them. // hooks/use-shopify-*.ts, which re-export them.
import type { StorefrontApi } from '@shopify/hydrogen';
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client'; import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import { import {
GET_PRODUCTS_QUERY, GET_PRODUCTS_QUERY,
@@ -19,6 +18,19 @@ import {
SEARCH_SUGGESTIONS_QUERY, SEARCH_SUGGESTIONS_QUERY,
} from '@/graphql/search'; } from '@/graphql/search';
// Storefront API connection shape (`edges { node }` + `pageInfo`).
interface Connection<T> {
edges: Array<{ node: T }>;
pageInfo?: { hasNextPage: boolean; endCursor: string | null } | null;
}
// `search` returns a union; `types: PRODUCT` narrows it, `__typename` proves it.
type SearchNode<T> = (T & { __typename: 'Product' }) | { __typename: string };
const isProductNode = <T>(
node: SearchNode<T>
): node is T & { __typename: 'Product' } => node.__typename === 'Product';
// Optional fields are `| null` rather than just optional: the Storefront API // Optional fields are `| null` rather than just optional: the Storefront API
// returns explicit nulls, and the typed `gql()` documents now surface that. // returns explicit nulls, and the typed `gql()` documents now surface that.
interface ProductImage { interface ProductImage {
@@ -150,9 +162,10 @@ export async function getProductsPage({
reverse = false, reverse = false,
}: UseProductsOptions = {}): Promise<ProductsPage> { }: UseProductsOptions = {}): Promise<ProductsPage> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(GET_PRODUCTS_QUERY, { await storefront.graphql<{ products: Connection<Product> }>(
variables: { first, after, query, sortKey, reverse }, GET_PRODUCTS_QUERY,
}), { variables: { first, after, query, sortKey, reverse } }
),
'GetProducts' 'GetProducts'
); );
@@ -168,7 +181,9 @@ export async function getProductsPage({
// Fetch a single product by handle // Fetch a single product by handle
export async function getProduct(handle: string): Promise<Product | null> { export async function getProduct(handle: string): Promise<Product | null> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(GET_PRODUCT_QUERY, { variables: { handle } }), await storefront.graphql<{ product: Product | null }>(GET_PRODUCT_QUERY, {
variables: { handle },
}),
'GetProduct' 'GetProduct'
); );
@@ -178,9 +193,10 @@ export async function getProduct(handle: string): Promise<Product | null> {
// Fetch product recommendations // Fetch product recommendations
export async function getProductRecommendations(productId: string): Promise<Product[]> { export async function getProductRecommendations(productId: string): Promise<Product[]> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(QUERY_PRODUCT_RECOMMENDATIONS, { await storefront.graphql<{ productRecommendations: Product[] | null }>(
variables: { productId }, QUERY_PRODUCT_RECOMMENDATIONS,
}), { variables: { productId } }
),
'GetProductRecommendations' 'GetProductRecommendations'
); );
@@ -245,7 +261,10 @@ export interface ProductFilterFacet {
// Fetch all collections // Fetch all collections
export async function getCollections(first = 50): Promise<Collection[]> { export async function getCollections(first = 50): Promise<Collection[]> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }), await storefront.graphql<{ collections: Connection<Collection> }>(
GET_COLLECTIONS_QUERY,
{ variables: { first } }
),
'GetCollections' 'GetCollections'
); );
@@ -277,7 +296,13 @@ export async function getCollectionProductsPage(
const filters = parseFilterInputs(filterInputs); const filters = parseFilterInputs(filterInputs);
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(GET_COLLECTION_PRODUCTS_QUERY, { await storefront.graphql<{
collection:
| (Collection & {
products: Connection<Product> & { filters: ProductFilterFacet[] };
})
| null;
}>(GET_COLLECTION_PRODUCTS_QUERY, {
variables: { variables: {
handle, handle,
first, first,
@@ -364,11 +389,10 @@ interface SearchProductsOptions {
filterInputs?: string[]; filterInputs?: string[];
} }
// The `ProductFilter` input shape, taken from the query that consumes it so it // The Storefront API's `ProductFilter` input. Facet `input` strings decode to
// tracks the schema rather than being restated here. // one of these, so only the shape matters here.
type ProductFilterInput = NonNullable< // https://shopify.dev/docs/api/storefront/latest/input-objects/ProductFilter
StorefrontApi.VariablesOf<typeof GET_COLLECTION_PRODUCTS_QUERY>['filters'] type ProductFilterInput = Record<string, unknown>;
>[number];
// Facet `input` values are opaque JSON strings produced by Shopify and handed // Facet `input` values are opaque JSON strings produced by Shopify and handed
// straight back as filter inputs, so they are parsed, not constructed. // straight back as filter inputs, so they are parsed, not constructed.
@@ -392,7 +416,12 @@ export async function searchProducts({
filterInputs = [], filterInputs = [],
}: SearchProductsOptions): Promise<SearchProductsResult> { }: SearchProductsOptions): Promise<SearchProductsResult> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(SEARCH_PRODUCTS_QUERY, { await storefront.graphql<{
search: Connection<SearchNode<Product>> & {
totalCount: number;
productFilters: SearchFilter[];
};
}>(SEARCH_PRODUCTS_QUERY, {
variables: { variables: {
query, query,
first, first,
@@ -412,9 +441,7 @@ export async function searchProducts({
return { return {
products: search.edges products: search.edges
.map((edge) => edge.node) .map((edge) => edge.node)
.filter((node): node is Extract<typeof node, { __typename: 'Product' }> => .filter(isProductNode),
node.__typename === 'Product'
),
totalCount: search.totalCount ?? 0, totalCount: search.totalCount ?? 0,
filters: search.productFilters ?? [], filters: search.productFilters ?? [],
hasNextPage: Boolean(search.pageInfo?.hasNextPage), hasNextPage: Boolean(search.pageInfo?.hasNextPage),
@@ -427,7 +454,9 @@ export async function searchSuggestions(
first = 3 first = 3
): Promise<{ products: SearchSuggestion[]; totalCount: number }> { ): Promise<{ products: SearchSuggestion[]; totalCount: number }> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(SEARCH_SUGGESTIONS_QUERY, { await storefront.graphql<{
search: Connection<SearchNode<SearchSuggestion>> & { totalCount: number };
}>(SEARCH_SUGGESTIONS_QUERY, {
variables: { query, first }, variables: { query, first },
}), }),
'SearchSuggestions' 'SearchSuggestions'
@@ -438,9 +467,7 @@ export async function searchSuggestions(
return { return {
products: search.edges products: search.edges
.map((edge) => edge.node) .map((edge) => edge.node)
.filter((node): node is Extract<typeof node, { __typename: 'Product' }> => .filter(isProductNode),
node.__typename === 'Product'
),
totalCount: search.totalCount ?? 0, totalCount: search.totalCount ?? 0,
}; };
} }
+105 -56
View File
@@ -1,80 +1,129 @@
// Storefront API clients, built on `@shopify/hydrogen`. // Storefront API client: plain `fetch` against the GraphQL endpoint, no SDK.
// https://shopify.dev/docs/api/storefront
// //
// Call sites use the package's own API — `storefront.graphql(DOCUMENT, { // Call sites use `storefront.graphql<TData>(QUERY, { variables })` and pass the
// variables })` — and pass the result through `unwrapStorefrontResult`, which // result through `unwrapStorefrontResult`, which applies this app's error
// applies this app's error policy: fail loudly. Hydrogen deliberately does not // policy: fail loudly. `graphql()` itself returns `{ data, errors }` like the
// do that itself, because a 200 carrying partial data and GraphQL errors is a // API does, because a 200 can carry partial data alongside GraphQL errors.
// valid response that some callers want to render.
import {
createShopifyRequestContext,
createStorefrontClient,
type GraphQLFormattedError,
} from '@shopify/hydrogen';
import { import {
SHOPIFY_API_VERSION, SHOPIFY_API_VERSION,
SHOPIFY_PUBLIC_ACCESS_TOKEN, SHOPIFY_PUBLIC_ACCESS_TOKEN,
SHOPIFY_STORE_DOMAIN, SHOPIFY_STORE_DOMAIN,
} from '@/services/shopify/config'; } from '@/services/shopify/config';
// No incoming request and no buyer context: this client is module-scoped and export interface StorefrontGraphQLError {
// serves both the Hono API and the browser-side hooks, so it must not close message: string;
// over per-request state. `$country`/`$language` are injected from this i18n. path?: ReadonlyArray<string | number>;
const requestContext = createShopifyRequestContext({ extensions?: Record<string, unknown>;
request: { headers: new Headers() }, }
i18n: { country: 'US', language: 'EN' },
});
/** export interface StorefrontResult<TData> {
* Workaround for a bug in this preview build of `@shopify/hydrogen`. data: TData | null;
* errors?: StorefrontGraphQLError[];
* The client tags every request with `X-Hydrogen-Version`, but the Storefront }
* API does not list that header in its CORS `access-control-allow-headers`.
* Browsers therefore reject the preflight and `fetch` throws, which hydrogen
* reports as the generic "SFAPI request failed". It only bites against real
* stores — `mock.shop` answers `access-control-allow-headers: *`.
*
* Stripped in the browser only: server-side requests are not subject to CORS,
* so they keep sending the header. Remove this once the API allows it (or once
* these queries move server-side, which is the better long-term fix).
*/
const CORS_BLOCKED_HEADERS = ['X-Hydrogen-Version'];
const storefrontFetch = ((url, init) => { /** Transport failures: non-2xx responses and bodies that aren't GraphQL JSON. */
const headers = new Headers(init?.headers); export class StorefrontApiError extends Error {
constructor(
message: string,
readonly status?: number
) {
super(message);
this.name = 'StorefrontApiError';
}
}
if (typeof document !== 'undefined') { // Buyer context for `@inContext(country: $country, language: $language)`.
for (const header of CORS_BLOCKED_HEADERS) headers.delete(header); // The client is module-scoped and serves both the Hono API and the browser, so
// it carries no per-request state; queries that declare these variables get
// them filled in unless the caller passes its own.
const DEFAULT_CONTEXT = { country: 'US', language: 'EN' } as const;
const STORE_URL = /^https?:\/\//.test(SHOPIFY_STORE_DOMAIN)
? SHOPIFY_STORE_DOMAIN.replace(/\/+$/, '')
: `https://${SHOPIFY_STORE_DOMAIN}`;
export const STOREFRONT_API_URL = `${STORE_URL}/api/${SHOPIFY_API_VERSION}/graphql.json`;
function withContextVariables(
query: string,
variables: Record<string, unknown> = {}
): Record<string, unknown> {
const merged = { ...variables };
for (const [name, value] of Object.entries(DEFAULT_CONTEXT)) {
if (merged[name] === undefined && query.includes(`$${name}`)) {
merged[name] = value;
}
}
return merged;
}
async function graphql<TData>(
query: string,
options: { variables?: Record<string, unknown> } = {}
): Promise<StorefrontResult<TData>> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
// Public token only; mock.shop needs none. Never send a private token here.
if (SHOPIFY_PUBLIC_ACCESS_TOKEN) {
headers['X-Shopify-Storefront-Access-Token'] = SHOPIFY_PUBLIC_ACCESS_TOKEN;
} }
return globalThis.fetch(url, { ...init, cache: 'no-store', headers }); const response = await fetch(STOREFRONT_API_URL, {
}) as typeof globalThis.fetch; method: 'POST',
headers,
body: JSON.stringify({
query,
variables: withContextVariables(query, options.variables),
}),
// Carts and customer reads must never be served stale; catalogue caching
// happens in TanStack Query instead (see the `staleTime`s).
cache: 'no-store',
});
/** let body: unknown;
* The one Storefront client. Uncached at the HTTP layer, because carts and try {
* customer reads must never serve a stale response — response caching for body = await response.json();
* catalogue data happens in TanStack Query instead (see `staleTime`s). } catch {
*/ throw new StorefrontApiError(
export const storefront = createStorefrontClient({ `Storefront API returned a non-JSON response (${response.status}).`,
type: 'public', response.status
requestContext, );
config: { }
storeDomain: SHOPIFY_STORE_DOMAIN,
apiVersion: SHOPIFY_API_VERSION, // The API answers GraphQL-level problems with 200 + `errors`; anything else
publicStorefrontToken: SHOPIFY_PUBLIC_ACCESS_TOKEN, // that lacks a GraphQL body is a transport failure.
fetch: storefrontFetch, const result = body as Partial<StorefrontResult<TData>> | null;
}, if (!result || (!('data' in result) && !('errors' in result))) {
}); throw new StorefrontApiError(
`Storefront API request failed (${response.status}).`,
response.status
);
}
if (!response.ok && !result.errors?.length) {
throw new StorefrontApiError(
`Storefront API request failed (${response.status}).`,
response.status
);
}
return { data: result.data ?? null, errors: result.errors };
}
/** The one Storefront client. */
export const storefront = { graphql };
/** /**
* Returns the data from a `graphql()` result, throwing if Shopify reported any * Returns the data from a `graphql()` result, throwing if Shopify reported any
* GraphQL errors. Transport failures — non-200, timeouts, unparseable bodies — * GraphQL errors. Transport failures have already thrown `StorefrontApiError`.
* have already thrown as `StorefrontApiError` by this point.
* *
* `operation` names the query in the log and the thrown message, so a failure * `operation` names the query in the log and the thrown message, so a failure
* points at the call site rather than just at "Shopify". * points at the call site rather than just at "Shopify".
*/ */
export function unwrapStorefrontResult<TData>( export function unwrapStorefrontResult<TData>(
result: { data: TData | null; errors?: GraphQLFormattedError[] }, result: StorefrontResult<TData>,
operation: string operation: string
): TData { ): TData {
if (result.errors?.length) { if (result.errors?.length) {
+29 -14
View File
@@ -80,6 +80,15 @@ export interface AuthResult {
errors: CustomerUserError[]; errors: CustomerUserError[];
} }
// Payload shapes the customer mutations select. Shopify returns a null payload
// when a mutation could not run at all.
interface UserErrorsPayload {
customerUserErrors: CustomerUserError[] | null;
}
type TokenPayload = UserErrorsPayload & {
customerAccessToken: AccessToken | null;
};
const firstMessage = (errors: CustomerUserError[]) => const firstMessage = (errors: CustomerUserError[]) =>
errors[0]?.message ?? 'Something went wrong. Please try again.'; errors[0]?.message ?? 'Something went wrong. Please try again.';
@@ -100,9 +109,10 @@ export async function createCustomer(input: {
acceptsMarketing?: boolean; acceptsMarketing?: boolean;
}): Promise<{ errors: CustomerUserError[] }> { }): Promise<{ errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_CREATE_MUTATION, { await storefront.graphql<{ customerCreate: UserErrorsPayload | null }>(
variables: { input }, CUSTOMER_CREATE_MUTATION,
}), { variables: { input } }
),
'CustomerCreate' 'CustomerCreate'
); );
@@ -117,9 +127,10 @@ export async function login(
password: string password: string
): Promise<AuthResult> { ): Promise<AuthResult> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, { await storefront.graphql<{ customerAccessTokenCreate: TokenPayload | null }>(
variables: { input: { email, password } }, CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION,
}), { variables: { input: { email, password } } }
),
'CustomerAccessTokenCreate' 'CustomerAccessTokenCreate'
); );
@@ -161,9 +172,10 @@ export async function resetPassword(
password: string password: string
): Promise<AuthResult> { ): Promise<AuthResult> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_RESET_MUTATION, { await storefront.graphql<{ customerReset: TokenPayload | null }>(
variables: { id, input: { resetToken, password } }, CUSTOMER_RESET_MUTATION,
}), { variables: { id, input: { resetToken, password } } }
),
'CustomerReset' 'CustomerReset'
); );
@@ -182,9 +194,10 @@ export async function activateAccount(
password: string password: string
): Promise<AuthResult> { ): Promise<AuthResult> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_ACTIVATE_MUTATION, { await storefront.graphql<{ customerActivate: TokenPayload | null }>(
variables: { id, input: { activationToken, password } }, CUSTOMER_ACTIVATE_MUTATION,
}), { variables: { id, input: { activationToken, password } } }
),
'CustomerActivate' 'CustomerActivate'
); );
@@ -203,7 +216,7 @@ export async function getCustomer(
): Promise<Customer | null> { ): Promise<Customer | null> {
try { try {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_QUERY, { await storefront.graphql<{ customer: Customer | null }>(CUSTOMER_QUERY, {
variables: { customerAccessToken: accessToken, orderCount }, variables: { customerAccessToken: accessToken, orderCount },
}), }),
'GetCustomer' 'GetCustomer'
@@ -227,7 +240,9 @@ export async function updateCustomer(
} }
): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> { ): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_UPDATE_MUTATION, { await storefront.graphql<{
customerUpdate: (UserErrorsPayload & { customer: Customer | null }) | null;
}>(CUSTOMER_UPDATE_MUTATION, {
variables: { customerAccessToken: accessToken, customer }, variables: { customerAccessToken: accessToken, customer },
}), }),
'CustomerUpdate' 'CustomerUpdate'
-1
View File
@@ -12,7 +12,6 @@
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"types": ["vite/client", "node"], "types": ["vite/client", "node"],
"plugins": [{ "name": "@shopify/hydrogen/ts-plugin" }],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
-116
View File
@@ -5,31 +5,6 @@ __metadata:
version: 10 version: 10
cacheKey: 10c0 cacheKey: 10c0
"@0no-co/graphql.web@npm:^1.0.5, @0no-co/graphql.web@npm:^1.3.1":
version: 1.3.4
resolution: "@0no-co/graphql.web@npm:1.3.4"
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
peerDependenciesMeta:
graphql:
optional: true
checksum: 10c0/58c61e16664121bd0974609890bf68135469fa1af23c70c3966cf50f0215efac7062a4ff2ded60faaf7575e420b73c0800467e46fc0440942d48490d2f6c13bd
languageName: node
linkType: hard
"@0no-co/graphqlsp@npm:^1.12.13":
version: 1.17.5
resolution: "@0no-co/graphqlsp@npm:1.17.5"
dependencies:
"@gql.tada/internal": "npm:^1.2.1"
graphql: "npm:^15.5.0 || ^16.0.0 || ^17.0.0"
peerDependencies:
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
typescript: ^5.0.0 || ^6.0.0
checksum: 10c0/6b40d666ce9b237d0aebb13d17770c009e4416e8955c6636acaef49174b1bde5d612c7a6d7656a3053518cbbfbb85f9d039df78b595233c365878d2091987ed2
languageName: node
linkType: hard
"@babel/code-frame@npm:7.29.7, @babel/code-frame@npm:^7.29.7": "@babel/code-frame@npm:7.29.7, @babel/code-frame@npm:^7.29.7":
version: 7.29.7 version: 7.29.7
resolution: "@babel/code-frame@npm:7.29.7" resolution: "@babel/code-frame@npm:7.29.7"
@@ -313,52 +288,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@gql.tada/cli-utils@npm:1.7.3":
version: 1.7.3
resolution: "@gql.tada/cli-utils@npm:1.7.3"
dependencies:
"@0no-co/graphqlsp": "npm:^1.12.13"
"@gql.tada/internal": "npm:1.0.9"
graphql: "npm:^15.5.0 || ^16.0.0 || ^17.0.0"
peerDependencies:
"@0no-co/graphqlsp": ^1.12.13
"@gql.tada/svelte-support": 1.0.2
"@gql.tada/vue-support": 1.0.2
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
typescript: ^5.0.0 || ^6.0.0
peerDependenciesMeta:
"@gql.tada/svelte-support":
optional: true
"@gql.tada/vue-support":
optional: true
checksum: 10c0/55405687375cbda9a9e963fc627939a55c64e81816c8d6433add3564f2fc2c0600ec60c98e01de77fa21511713daf9e84b644362d9ce571f0ea5c43851c68b6f
languageName: node
linkType: hard
"@gql.tada/internal@npm:1.0.9":
version: 1.0.9
resolution: "@gql.tada/internal@npm:1.0.9"
dependencies:
"@0no-co/graphql.web": "npm:^1.0.5"
peerDependencies:
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
typescript: ^5.0.0 || ^6.0.0
checksum: 10c0/5599ab58bc5859bf9d38f13e4ca0aae564cf7e94fbfa9afdb9932d4719fb945cb19da917fe23ed0cf115550ce07d31bcac89c229385fe7077754b54c2bed16e9
languageName: node
linkType: hard
"@gql.tada/internal@npm:^1.2.1":
version: 1.2.2
resolution: "@gql.tada/internal@npm:1.2.2"
dependencies:
"@0no-co/graphql.web": "npm:^1.3.1"
peerDependencies:
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
checksum: 10c0/2861061a64fa88dbd8bac84cff87bc33a30e9ed971cbee9f8d9a52ec27a17ffc64c389fb6a75f2231458524556829b81f4744b55ccb433e46d28e1066a5d4655
languageName: node
linkType: hard
"@isaacs/fs-minipass@npm:^4.0.0": "@isaacs/fs-minipass@npm:^4.0.0":
version: 4.0.1 version: 4.0.1
resolution: "@isaacs/fs-minipass@npm:4.0.1" resolution: "@isaacs/fs-minipass@npm:4.0.1"
@@ -2010,25 +1939,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607":
version: 0.0.0-preview-116d5d7-20260730141607
resolution: "@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607"
dependencies:
gql.tada: "npm:1.9.2"
peerDependencies:
react: ^18.0.0 || ^19.0.0
vue: ^3.5.0
peerDependenciesMeta:
react:
optional: true
vue:
optional: true
bin:
hydrogen: bin/hydrogen.mjs
checksum: 10c0/b12e230d174a910d8abb7ab18ecb99357dce82d2125054b30af6dcbecf280c6fea40c8ca5fd67d8931e1079fd73e587e3ea1484f50c294f4a79ad714c9664ecd
languageName: node
linkType: hard
"@tailwindcss/node@npm:4.3.1": "@tailwindcss/node@npm:4.3.1":
version: 4.3.1 version: 4.3.1
resolution: "@tailwindcss/node@npm:4.3.1" resolution: "@tailwindcss/node@npm:4.3.1"
@@ -2876,23 +2786,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"gql.tada@npm:1.9.2":
version: 1.9.2
resolution: "gql.tada@npm:1.9.2"
dependencies:
"@0no-co/graphql.web": "npm:^1.0.5"
"@0no-co/graphqlsp": "npm:^1.12.13"
"@gql.tada/cli-utils": "npm:1.7.3"
"@gql.tada/internal": "npm:1.0.9"
peerDependencies:
typescript: ^5.0.0 || ^6.0.0
bin:
gql-tada: bin/cli.js
gql.tada: bin/cli.js
checksum: 10c0/e6995d5ddd41a0049f7e2494758d605567027c57b270e59c48355d7fbd0ca95db77db2385c14d2add1198ef59b91c1b5a7f9b70d9eac8668c2be534e55a56517
languageName: node
linkType: hard
"graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": "graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6":
version: 4.2.11 version: 4.2.11
resolution: "graceful-fs@npm:4.2.11" resolution: "graceful-fs@npm:4.2.11"
@@ -2900,13 +2793,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"graphql@npm:^15.5.0 || ^16.0.0 || ^17.0.0, graphql@npm:^17.0.2":
version: 17.0.2
resolution: "graphql@npm:17.0.2"
checksum: 10c0/766546216b891439ed09fd8584963df2013e26f2f79f096036eaaa2788c38964e049c8a142d68e3afdabdd8bbe70042639e83f0f4b82edbc7c6850421f072bb6
languageName: node
linkType: hard
"h3-v2@npm:h3@2.0.1-rc.20": "h3-v2@npm:h3@2.0.1-rc.20":
version: 2.0.1-rc.20 version: 2.0.1-rc.20
resolution: "h3@npm:2.0.1-rc.20" resolution: "h3@npm:2.0.1-rc.20"
@@ -3923,7 +3809,6 @@ __metadata:
"@fontsource-variable/geist": "npm:^5.3.0" "@fontsource-variable/geist": "npm:^5.3.0"
"@fontsource-variable/geist-mono": "npm:^5.3.0" "@fontsource-variable/geist-mono": "npm:^5.3.0"
"@remixicon/react": "npm:^4.9.0" "@remixicon/react": "npm:^4.9.0"
"@shopify/hydrogen": "npm:0.0.0-preview-116d5d7-20260730141607"
"@tailwindcss/vite": "npm:4.3.1" "@tailwindcss/vite": "npm:4.3.1"
"@tanstack/query-core": "npm:^5.104.1" "@tanstack/query-core": "npm:^5.104.1"
"@tanstack/react-query": "npm:^5.104.1" "@tanstack/react-query": "npm:^5.104.1"
@@ -3938,7 +3823,6 @@ __metadata:
clsx: "npm:^2.1.1" clsx: "npm:^2.1.1"
cmdk: "npm:^1.1.1" cmdk: "npm:^1.1.1"
framer-motion: "npm:^12.42.2" framer-motion: "npm:^12.42.2"
graphql: "npm:^17.0.2"
hono: "npm:^4.13.12" hono: "npm:^4.13.12"
lucide-react: "npm:^0.562.0" lucide-react: "npm:^0.562.0"
radix-ui: "npm:^1.6.7" radix-ui: "npm:^1.6.7"