Skip to content

Commit 673b422

Browse files
div-cowboyclaude
andcommitted
feat(hydrogen): resolve inbound ?variant=<id> deep links
Shopify's own surfaces — Liquid storefronts, Shopping feeds, email campaigns, paid ads, and Shop Pay — deep-link to a product with a bare variant id (`?variant=41565182099480`) rather than one param per option. Hydrogen's product page only reads option params (`?Color=Red&Size=M`), so those links silently resolve to the default variant instead of the one the shopper clicked. Add `getVariantIdParam` to normalize the param to a ProductVariant GID, returning null for anything else — including GIDs for other resource types, so an untrusted param can't reach a `node(id:)` lookup. Built on the existing `parseGid`, matching how `shop-pay.ts` validates variant ids. Wire it up in the Next.js example: resolve the variant, then 307 to the canonical option-param URL, preserving `utm_*` and other campaign params. This is additive. Option params stay canonical and stay the no-JS mechanism (F4); no existing URL contract changes. The redirect lives in `proxy.ts` rather than the page on purpose. Under Cache Components the shell streams before a page-level `redirect()` can set a status, so it degrades to a client-side redirect — measured at 53KB with no add-to-cart, versus 80KB at the canonical URL. That would leave a no-JS shopper following an ad link on an unusable page. Redirecting ahead of framework routing keeps it a real HTTP redirect for everyone, and skips the session-cookie decrypt on the redirect path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b262c53 commit 673b422

8 files changed

Lines changed: 223 additions & 5 deletions

File tree

.changeset/tidy-moons-repeat.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@shopify/hydrogen': minor
3+
---
4+
5+
Add `getVariantIdParam` for inbound `?variant=<id>` deep links.
6+
7+
Shopify's own surfaces — Liquid storefronts, Shopping feeds, email campaigns, paid ads, and Shop Pay links — deep-link to a product with a bare variant id (`/products/shoes?variant=41565182099480`) rather than one search param per option. A storefront that only reads option params (`?Color=Red&Size=M`) silently drops that selection and renders the default variant, so every link a merchant already has in market lands on the wrong variant after migrating to Hydrogen.
8+
9+
`getVariantIdParam` reads that param and normalizes it to a `ProductVariant` GID, returning `null` for anything that isn't one — including GIDs for other resource types, so an untrusted param can't be forwarded into a `node(id:)` lookup for an unrelated object.
10+
11+
```ts
12+
const variantId = getVariantIdParam({ searchParams });
13+
if (variantId) {
14+
// Resolve the variant's selectedOptions, then redirect to the canonical URL.
15+
}
16+
```
17+
18+
This is additive: option params remain the canonical, no-JS URL contract. The Next.js example shows the full pattern in `proxy.ts` — resolve the variant, then issue a real 307 to the equivalent option-param URL, preserving `utm_*` and other campaign params. Redirecting before framework routing matters: under Cache Components the shell streams before a page-level `redirect()` can set a status, so it degrades to a client-side redirect that never runs for a no-JS shopper.

examples/nextjs/lib/product-query.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,5 +113,28 @@ export const RELATED_PRODUCTS_QUERY = gql(
113113
[PRODUCT_CARD_FRAGMENT],
114114
);
115115

116+
/**
117+
* Resolves an inbound `?variant=<id>` deep link (Liquid storefronts, Shopping
118+
* feeds, email, ads, Shop Pay) to the option params the PDP is built around.
119+
* Returns the owning product handle too, so a variant belonging to a different
120+
* product in a combined listing redirects to the right page.
121+
*/
122+
export const VARIANT_DEEP_LINK_QUERY = gql(`
123+
query VariantDeepLink($id: ID!, $country: CountryCode, $language: LanguageCode)
124+
@inContext(country: $country, language: $language) {
125+
node(id: $id) {
126+
... on ProductVariant {
127+
product {
128+
handle
129+
}
130+
selectedOptions {
131+
name
132+
value
133+
}
134+
}
135+
}
136+
}
137+
`);
138+
116139
/** The typed product data consumed by the React product bindings. */
117140
export type ProductData = NonNullable<StorefrontApi.ResultOf<typeof PRODUCT_QUERY>["product"]>;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { getVariantIdParam, type StorefrontClient } from "@shopify/hydrogen";
2+
import { NextResponse, type NextRequest } from "next/server";
3+
4+
import { VARIANT_DEEP_LINK_QUERY } from "./product-query";
5+
6+
/** Matches `getVariantIdParam`'s param — kept here to strip it on the way out. */
7+
const VARIANT_PARAM = "variant";
8+
const PRODUCT_PATHNAME = /^\/products\/[^/]+$/;
9+
10+
/** Shopify's sentinel option for single-variant products — not a real choice. */
11+
const DEFAULT_OPTION_NAME = "Title";
12+
const DEFAULT_OPTION_VALUE = "Default Title";
13+
14+
/**
15+
* Normalizes `?variant=<id>` deep links to the option-param URL the PDP reads.
16+
*
17+
* Shopify's own surfaces — Liquid storefronts, Shopping feeds, email campaigns,
18+
* paid ads, and Shop Pay — link to a product with a bare variant id. The PDP is
19+
* built around option params (`?Size=Large`), so without this those links land
20+
* on the default variant instead of the one the shopper clicked.
21+
*
22+
* This runs in `proxy.ts` rather than the page on purpose. Under Cache
23+
* Components the shell streams before a page-level `redirect()` can set a
24+
* status, so it degrades to a client-side redirect — which never runs for a
25+
* no-JS shopper, leaving them on a shell with no add-to-cart (F4). Redirecting
26+
* before framework routing keeps it a real HTTP redirect for everyone.
27+
*
28+
* Costs nothing on ordinary requests: the Storefront lookup only happens on a
29+
* `/products/*` URL that actually carries a well-formed `variant` param.
30+
* Anything unresolvable falls through and renders the default variant (F8) —
31+
* a stale id in an old feed should not 404.
32+
*/
33+
export async function variantDeepLinkRedirect(
34+
request: NextRequest,
35+
storefrontClient: StorefrontClient,
36+
): Promise<NextResponse | null> {
37+
const { pathname, searchParams } = request.nextUrl;
38+
if (!PRODUCT_PATHNAME.test(pathname)) return null;
39+
40+
const variantId = getVariantIdParam({ searchParams });
41+
if (!variantId) return null;
42+
43+
const { data, errors } = await storefrontClient.graphql(VARIANT_DEEP_LINK_QUERY, {
44+
variables: { id: variantId },
45+
});
46+
47+
if (errors) {
48+
console.error("[hydrogen] Variant deep link query failed", errors);
49+
}
50+
51+
const node = data?.node;
52+
if (!node || !("selectedOptions" in node)) return null;
53+
54+
// Clone so unrelated params (`utm_*`, `ref`) survive — a redirect must not
55+
// cost the merchant their campaign attribution.
56+
const url = request.nextUrl.clone();
57+
// A combined listing can point at a variant on a different product.
58+
url.pathname = `/products/${node.product.handle}`;
59+
url.searchParams.delete(VARIANT_PARAM);
60+
for (const option of node.selectedOptions) {
61+
if (option.name === DEFAULT_OPTION_NAME && option.value === DEFAULT_OPTION_VALUE) continue;
62+
url.searchParams.set(option.name, option.value);
63+
}
64+
65+
// 307, not 308: option values are merchant-editable, so this mapping must not
66+
// be cached in shoppers' browsers forever.
67+
return NextResponse.redirect(url, 307);
68+
}

examples/nextjs/proxy.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createCustomerSessionManager } from "@/lib/customer-account";
1212
import { customerSessionHandlers } from "@/lib/customer-session-handlers";
1313
import { predictiveSearchHandlers } from "@/lib/predictive-search-handlers";
1414
import { MOCK_SHOP_DOMAIN, resolveStorefrontConfig } from "@/lib/storefront-config";
15+
import { variantDeepLinkRedirect } from "@/lib/variant-deep-link";
1516

1617
/**
1718
* Next.js request lifecycle (`hydrogen-request-handlers` /
@@ -20,6 +21,13 @@ import { MOCK_SHOP_DOMAIN, resolveStorefrontConfig } from "@/lib/storefront-conf
2021
* `/api/{ver}/graphql.json`, `/admin`, …) short-circuit here. Storefront URL
2122
* redirects run in `app/not-found.tsx` (post-404), never here.
2223
*
24+
* `?variant=<id>` deep links (Liquid storefronts, Shopping feeds, email, ads,
25+
* Shop Pay) are normalized to the PDP's option-param URL here too — distinct
26+
* from the admin-configured Storefront URL redirects above, and placed before
27+
* framework routing so it stays a real HTTP redirect for no-JS shoppers (F4)
28+
* rather than the client-side one a page-level `redirect()` degrades to under
29+
* Cache Components.
30+
*
2331
* The original request URL is forwarded to Server Components via
2432
* `requestContext.getForwardedRequestHeaders()` (carries `x-storefront-url` for
2533
* `not-found.tsx` and `getMarketFromHeaders`). SFAPI response headers are merged
@@ -37,8 +45,6 @@ export async function proxy(request: NextRequest) {
3745
buyerIp,
3846
});
3947

40-
const sessionManager = await createCustomerSessionManager(request);
41-
4248
const { storeDomain, privateStorefrontToken } = resolveStorefrontConfig();
4349

4450
const storefrontClient = createStorefrontClient({
@@ -51,6 +57,17 @@ export async function proxy(request: NextRequest) {
5157
},
5258
});
5359

60+
// Normalize `?variant=<id>` deep links to the PDP's option-param URL. Runs
61+
// ahead of the session manager: a redirected request never reads the session,
62+
// so there's no reason to pay its cookie decrypt first.
63+
const variantRedirect = await variantDeepLinkRedirect(request, storefrontClient);
64+
if (variantRedirect) {
65+
requestContext.applyResponseHeaders(variantRedirect.headers);
66+
return variantRedirect;
67+
}
68+
69+
const sessionManager = await createCustomerSessionManager(request);
70+
5471
// Customer Accounts are only available on a real store (mock.shop has no
5572
// Customer Account API). Inline the resolved `storeDomain` check rather than
5673
// calling `isCustomerAccountsAvailable()` to avoid a second config read.

packages/hydrogen/src/core/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export type {
178178
ValidProductSelectionResult,
179179
VariantSelectionResult,
180180
} from "./product";
181-
export { getSelectedProductOptions } from "./product";
181+
export { getSelectedProductOptions, getVariantIdParam } from "./product";
182182
export type {
183183
ProductInput,
184184
ProductAddToCartProps,

packages/hydrogen/src/core/product/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export type {
1313
ValidProductSelectionResult,
1414
VariantSelectionResult,
1515
} from "./product-form";
16-
export { getSelectedProductOptions } from "./options";
16+
export { getSelectedProductOptions, getVariantIdParam } from "./options";
1717
export { createProductFormRegister } from "./form";
1818
export type {
1919
ProductAddToCartProps,

packages/hydrogen/src/core/product/options.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest";
22

3-
import { getSelectedProductOptions } from "./options";
3+
import { getSelectedProductOptions, getVariantIdParam } from "./options";
44

55
describe("getSelectedProductOptions", () => {
66
it("decodes special characters in option names and values", () => {
@@ -31,3 +31,48 @@ describe("getSelectedProductOptions", () => {
3131
expect(getSelectedProductOptions({ searchParams: params, allowedOptionNames: [] })).toEqual([]);
3232
});
3333
});
34+
35+
describe("getVariantIdParam", () => {
36+
const gid = "gid://shopify/ProductVariant/41565182099480";
37+
38+
it("normalizes the bare legacy id Liquid storefronts emit", () => {
39+
const params = new URLSearchParams("variant=41565182099480");
40+
41+
expect(getVariantIdParam({ searchParams: params })).toBe(gid);
42+
});
43+
44+
it("accepts a full Storefront API GID", () => {
45+
const params = new URLSearchParams();
46+
params.set("variant", gid);
47+
48+
expect(getVariantIdParam({ searchParams: params })).toBe(gid);
49+
});
50+
51+
it("returns null when the param is absent or empty", () => {
52+
expect(getVariantIdParam({ searchParams: new URLSearchParams() })).toBeNull();
53+
expect(getVariantIdParam({ searchParams: new URLSearchParams("variant=") })).toBeNull();
54+
expect(getVariantIdParam({ searchParams: new URLSearchParams("variant=%20") })).toBeNull();
55+
});
56+
57+
it("rejects GIDs for other resource types so they can't reach node(id:)", () => {
58+
const params = new URLSearchParams();
59+
params.set("variant", "gid://shopify/Customer/1");
60+
61+
expect(getVariantIdParam({ searchParams: params })).toBeNull();
62+
});
63+
64+
it("rejects non-numeric and decorated ids", () => {
65+
for (const value of ["not-an-id", "41565182099480 OR 1=1", `${gid}?namespace=x`, "-1", "1.5"]) {
66+
const params = new URLSearchParams();
67+
params.set("variant", value);
68+
69+
expect(getVariantIdParam({ searchParams: params })).toBeNull();
70+
}
71+
});
72+
73+
it("uses the first value when the param repeats", () => {
74+
const params = new URLSearchParams("variant=41565182099480&variant=99999999999999");
75+
76+
expect(getVariantIdParam({ searchParams: params })).toBe(gid);
77+
});
78+
});

packages/hydrogen/src/core/product/options.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { parseGid } from "../utils/parse-gid";
12
import type {
23
ProductInput,
34
ProductOptionValueFrom,
@@ -62,6 +63,52 @@ export function getSelectedProductOptions({
6263
return selectedOptions;
6364
}
6465

66+
const VARIANT_ID_PARAM = "variant";
67+
const PRODUCT_VARIANT_GID_PREFIX = "gid://shopify/ProductVariant/";
68+
69+
/**
70+
* Reads an inbound `?variant=<id>` search param and normalizes it to a
71+
* `ProductVariant` GID.
72+
*
73+
* Shopify's own surfaces — Liquid storefronts, Shopping feeds, email campaigns,
74+
* paid ads, and Shop Pay links — deep-link to a product with a bare variant id
75+
* (`/products/shoes?variant=41565182099480`) rather than one param per option.
76+
* A storefront that only understands option params (`?Color=Red&Size=M`)
77+
* silently drops that selection and renders the default variant, so every link
78+
* a merchant already has in market lands on the wrong variant.
79+
*
80+
* Use this to detect such a link, resolve the variant, and redirect to your
81+
* canonical option-param URL. Existing marketing links keep working without
82+
* changing the URL contract the product page is built around.
83+
*
84+
* Returns `null` when the param is absent, empty, or is not a product variant
85+
* id — including GIDs for other resource types, so an untrusted param can't be
86+
* forwarded into a `node(id:)` lookup for an unrelated object.
87+
*
88+
* @example
89+
* ```ts
90+
* const variantId = getVariantIdParam({ searchParams });
91+
* if (variantId) {
92+
* // Resolve the variant's selectedOptions, then redirect to the canonical URL.
93+
* }
94+
* ```
95+
*/
96+
export function getVariantIdParam({
97+
searchParams,
98+
}: {
99+
searchParams: URLSearchParams;
100+
}): string | null {
101+
const raw = searchParams.get(VARIANT_ID_PARAM)?.trim();
102+
if (!raw) return null;
103+
104+
// Accept both the bare legacy id Liquid emits and a full Storefront API GID.
105+
const parsed = parseGid(raw);
106+
if (parsed.resource && parsed.resource !== "ProductVariant") return null;
107+
108+
const bareId = parsed.id || raw;
109+
return /^\d+$/.test(bareId) ? `${PRODUCT_VARIANT_GID_PREFIX}${bareId}` : null;
110+
}
111+
65112
export function getAdjacentAndFirstSelectableVariants<TProduct extends ProductInput>(
66113
product: TProduct,
67114
): ProductVariantFrom<TProduct>[] {

0 commit comments

Comments
 (0)